From a04d7d716850f1b5d1b965680506d63491af26bf Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Mon, 1 Jun 2026 15:47:34 -0700 Subject: [PATCH] Migrate to domstack-sync --- README.md | 7 +- bin.js | 71 +++--- docs/v12-migration.md | 27 ++- examples/blog/src/README.md | 2 - examples/default-layout/package.json | 8 +- examples/string-layouts/package.json | 8 +- index.js | 212 ++++++++++-------- lib/builder.js | 2 + lib/logger.js | 80 +++++++ package.json | 5 +- plans/progressive-rebuild.md | 4 +- .../constructor-copy-paths/index.test.js | 13 +- test-cases/watch/index.test.js | 106 ++++++--- 13 files changed, 339 insertions(+), 206 deletions(-) create mode 100644 lib/logger.js diff --git a/README.md b/README.md index 60eca6d0..1b04d23d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ domstack (v12.0.0) `domstack` is also aliased to a `dom` bin. - Running `domstack` will result in a `build` by default. -- Running `domstack --watch` or `domstack -w` will build the site and start an auto-reloading development web-server that watches for changes (provided by [Browsersync](https://browsersync.io)). +- Running `domstack --watch` or `domstack -w` will build the site and start an auto-reloading development web-server that watches for changes (provided by [`@domstack/sync`][domstack-sync]). - Running `domstack --eject` or `domstack -e` will extract the default layout, global styles, and client-side JavaScript into your source directory and add the necessary dependencies to your package.json. `domstack` is primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. @@ -1754,7 +1754,7 @@ Variable Resolution Layers: When you run `domstack --watch` (or `domstack -w`), domstack performs an initial build and then watches for changes, rebuilding only what's necessary. Watch mode uses two independent watch loops: -**esbuild watch** — JS and CSS bundles are handled by esbuild's native `context.watch()`. In watch mode, output filenames are stable (no content hashes), so bundle changes never require a page HTML rebuild. Browser-sync detects the updated files on disk and reloads the browser directly. +**esbuild watch** — JS and CSS bundles are handled by esbuild's native `context.watch()`. In watch mode, output filenames are stable (no content hashes), so bundle changes never require a page HTML rebuild. `@domstack/sync` detects the updated files on disk and reloads the browser directly. **chokidar watch** — Page files, layouts, templates, and config files are watched by chokidar. When a file changes, domstack determines the minimal set of pages to rebuild using dependency tracking maps built at startup. @@ -1823,7 +1823,7 @@ Some notable features are included below, see the [roadmap](https://github.com/u - [x] Docs website built with `domstack`: https://domstack.net - [x] `--eject` cli flag - [x] Global assets can live anywhere -- [x] Built in browsersync dev server +- [x] Built in `@domstack/sync` dev server - [x] Real default layout style builds - [x] Esbuild settings escape hatch - [x] Copy folders @@ -1875,6 +1875,7 @@ It is also an homage to [substack](https://substack.net) as well as a play on th [fragtml]: https://www.npmjs.com/package/fragtml [fragtml-docs]: https://github.com/bcomnes/fragtml#readme [preact]: https://preactjs.com/ +[domstack-sync]: https://www.npmjs.com/package/@domstack/sync [hb]: https://handlebarsjs.com [esbuild]: http://esbuild.github.io [neocities-img]: https://img.shields.io/website/https/domstack.neocities.org?label=neocities&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAGhlWElmTU0AKgAAAAgABAEGAAMAAAABAAIAAAESAAMAAAABAAEAAAEoAAMAAAABAAIAAIdpAAQAAAABAAAAPgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAAueefIAAACC2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlBob3RvbWV0cmljSW50ZXJwcmV0YXRpb24+MjwvdGlmZjpQaG90b21ldHJpY0ludGVycHJldGF0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8dGlmZjpDb21wcmVzc2lvbj4xPC90aWZmOkNvbXByZXNzaW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kpl32MAAABzBJREFUWAnFVwtwnFUV/v5//31ks5tsE9I8moS0iWETSNKUVpBKDKFQxtrCUIpacHQEGYk16FQHaZ3ajjqjOGWqOKUyMCl2xFoKhQJDBQftpOnAmDZoOyRNjCS1SdO8H5vXPv7rd/7NZvIipQjjmfn23Me555x77rnnv6sppTT8H0n/tG1rmlZIVBG+eW1JBD4t0GA8cYZQcS7ncXL7bFuYPfBJ9mlwtxg3bJoSTvx0tn7LAU48IJNE3GyBj9unrlJC2XRt4vGvLFGGrkXYDxEl03WyDyfRRoiHrxOfiBPU85bovPezi5pHnlmhHq5IsaLAXHhltgPXi+A0VE8X+Dht6lov+uw2rf/8nmIlDjQ+fp1yO/SYnaKYXoOC5QSu8trgddnND7rHv0EvOymwTcbnI867OZ5PLCOKiUIijQgS54nPE3hsfXog2WNY2Z+V5MDXVifjd3/ths/jquL0QyIj9EdC3V6UoLr25KurU73D0ieOEIniKbkc063EduLPRDcR2828/DOpzrbBp0ut3UsEBMe3X2PJuhw2sWHplgjkEViyyBGM93gcf3kkxVP2hNZ1sWfoLg7/jbttJC8jMgiLHHYj4EuIb81I9gQLM92O0iyH+9pUlZSdGDHCJjA0biI/zZ3NxIstsfjKpfFYmROHutYxDwduIo6JAxI6LIq3cSmtpCSg9jF3UsXuix2tHb3L7YZevHRx/FBZvrNzTaEnLTfFQHaSna6CSrghjbVMJzRbtC1KFqC1xT5xAFdnZdxPMcsBS1wpDLHhEoWpiXbj3R8mZ1zoT0Caz677PE4fdDunJYIzd2UtvoKfWwq9+PnRiwgMDd5RX/PGVRIBixLjbNNKpQaP1wO/NzYb47ON0yEzAhUJQjOYJhKFy9DybDcyk+y40DeSdOz5J+5h7CBAxDQdl1k7d5rGHWW74Cz/GdM0gQGSWrMwxTl0VBRSlnSmoblMjIel0zkgN+gKSDFl7G7YMm+C4d8Ix4pvQ4XGPpKC8snQ/vPfvYXiwPuy6tylK3RAFokTpuU/NF8u08dAzbkA/nCylyVeBOanJawJQpcGxjMkB04QdzS0j5ujQVNntZK5BSkwYaIvEEZmQgjm4AeweTOguRah4ZKJdbubeZwKaYl23HptNNQxZeMhE0fqBrDthXZraHTCtKydlF73cFhv67l8FGRnm55sQcGjZ/GTI50IN75kKdMTsywnzMmtj4XmhuDRP13Ag8+2YnA0GrVgWDFmwFld10dN03TXNg2jIMNlKfywn//0BXGyKWBNv904isj5GqjhdmjeJSjMzUDttmUYChpYnS+1ZiY9+IUUrCvxIS/Nic/tbAiOBBkBltoeGn9PRA+c6Jm5Yp5edrIDlWsWw09Ht23IgBrvQ+i9Zy1JcaKE1+zmZTp0c240i7LiwJIPXdPACMnmw9ZriOV2Czu/ES3v7izAdZlx0rw8SQLy/jtu/AEmstfhTP3fcUPRUkS6ziB0eh/M/hZovCkx6ugP4ccvtuO1+gGMMI9IfbGM289j6JSRY/8YEIbmSxM4enoA+2t60MuEm0NyA2xOuL5UDaPgXjQ0NODmW27DgVeOw5a3Dq6Nh2DLWcMnyOjU0v6RME63jloJOjnYZ0VAOozCb8kq4506fG4bOgZCU1fphe/m4osliZNrokwFA3Cs/A7sq6qsgU0bN+LwS9GE9Pv9cLvd8Ofn4Zl7wlC9zXRWSnmUnqvpDVY+1yZ38WgsAjKzX34kNF1DYeQtduLOFT4ceSRvjnFEQrClFMK2/FsIBALYu3evZfw2mxe/Yj1obGzExY4OfPmr98Hu38QCOSGqp+j3tT3RLAZek0SwiMlYxyjIFu6WgX3fzMGNufKonYd49kNGOspLrkdTUxMikQhS4r34tZGDZObEHkccdu3chQ0bNiDc/OoMBQdqe/HOv0aSONhBHJ5yYFLqR+QVoYjyPcT7+mJVLsZ5n988O4gTvHrfX5uKMimjzOJEewhbt25FZ2cnWlpaUF1djdcTR1A6NoH24BiC/E4IKSaiyMuX9OVT/Xh4f5tkn0R+Czc9MOdZzokHLGmuiLPr8qqViqKchqYObcmNvnCeLlajz9+uzGCAOpTiNVabN2+25ETWMAxVV1enzPEBS254X5GqWpsmHwqRkfP4OpdF8y/WmM4psJ3HIVuYMr7n/qwZz6uRp/xq4uQvuSxK4sTBgwfVjh07VH19veInWnW9+j11uDJdlebEj0zqaiC/gSum/gxN3QJOzCA6sIIDv2D0KlhdrWS9Jt2F9aU+FKQ7eeYKi3kaSaur4C29j98lE4P9XWg59z5OnXgDb7/1pvlOY7c5EbYKjug+RFTSeJ90pmi6N/O1KbiKeIqOtJFPhXl6m87OGae8hPoU8SSxaj7dMvahEeCiGUQjcm/LiHLCT8hbUsaGCKk2wqWWNxHykD1LA13kC9JHdmBBLf/D5H8By9d+IkwR5NMAAAAASUVORK5CYII= diff --git a/bin.js b/bin.js index 77576f9b..e5cdbcfa 100755 --- a/bin.js +++ b/bin.js @@ -1,8 +1,9 @@ #!/usr/bin/env node /** - * @import {DomStackOpts as DomStackOpts} from './lib/builder.js' + * @import { BuildStepWarnings, DomStackOpts as DomStackOpts } from './lib/builder.js' * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts' + * @import { Logger as PinoLogger } from 'pino' */ import { readFile } from 'node:fs/promises' @@ -23,6 +24,7 @@ import { DomStack } from './index.js' import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.js' import { generateTreeData } from './lib/helpers/generate-tree-data.js' import { askYesNo } from './lib/helpers/cli-prompt.js' +import { createDomStackLogger } from './lib/logger.js' const __dirname = import.meta.dirname @@ -212,6 +214,8 @@ domstack eject actions: opts.copy = copyPaths.map(p => resolve(cwd, p)) } + const logger = createDomStackLogger() + opts.logger = logger const domStack = new DomStack(src, dest, opts) process.once('SIGINT', quit) @@ -219,60 +223,55 @@ domstack eject actions: async function quit () { if (domStack.watching) { - const results = await domStack.stopWatching() - console.log(results) - console.log('watching stopped') + await domStack.stopWatching() + logger.info('Watching stopped') } - console.log('\nquitting cleanly') + logger.info('Quitting cleanly') process.exit(0) } if (!argv['watch'] && !argv['watch-only']) { try { const results = await domStack.build() - console.log(tree(generateTreeData(cwd, src, dest, results))) - if (results?.warnings?.length > 0) { - console.log( - '\nThere were build warnings:\n' - ) - } - for (const warning of results?.warnings) { - if ('message' in warning) { - console.log(` ${warning.message}`) - } else { - console.warn(warning) - } - } - console.log('\nBuild Success!\n\n') + logger.info(tree(generateTreeData(cwd, src, dest, results))) + logWarnings(logger, results?.warnings) + logger.info('\nBuild Success!\n\n') } catch (err) { if (!(err instanceof Error || err instanceof AggregateError)) throw new Error('Non-error thrown', { cause: err }) if (err instanceof DomStackAggregateError) { if (err?.results?.siteData?.pages) { - console.log(tree(generateTreeData(cwd, src, dest, err.results))) + logger.error(tree(generateTreeData(cwd, src, dest, err.results))) } } if ('results' in err) delete err.results - console.error(inspect(err, { depth: 999, colors: true })) - - console.log('\nBuild Failed!\n\n') + logger.error(inspect(err, { depth: 999, colors: true })) + logger.error('\nBuild Failed!\n\n') process.exit(1) } } else { - const initialResults = await domStack.watch({ + await domStack.watch({ serve: !argv['watch-only'], + onInitialBuild: (initialResults) => { + logger.info(tree(generateTreeData(cwd, src, dest, initialResults))) + logWarnings(logger, initialResults?.warnings) + }, }) - console.log(tree(generateTreeData(cwd, src, dest, initialResults))) - if (initialResults?.warnings?.length > 0) { - console.log( - '\nThere were build warnings:\n' - ) - } - for (const warning of initialResults?.warnings) { - if ('message' in warning) { - console.log(` ${warning.message}`) - } else { - console.warn(warning) - } + } +} + +/** + * @param {PinoLogger} logger + * @param {BuildStepWarnings | undefined} warnings + */ +function logWarnings (logger, warnings) { + if ((warnings?.length ?? 0) === 0) return + + logger.warn('\nThere were build warnings:\n') + for (const warning of warnings ?? []) { + if ('message' in warning) { + logger.warn(` ${warning.message}`) + } else { + logger.warn(inspect(warning, { depth: 999, colors: true })) } } } diff --git a/docs/v12-migration.md b/docs/v12-migration.md index 873177af..89f1f6aa 100644 --- a/docs/v12-migration.md +++ b/docs/v12-migration.md @@ -8,10 +8,11 @@ Then apply the v12 changes below. ## Table of Contents 1. [Type exports moved to `@domstack/static/types.js`](#1-type-exports-moved-to-domstackstatictypesjs) -2. [Default Layout Uses fragtml](#2-default-layout-uses-fragtml) -3. [Keep Layout Dependencies Explicit](#3-keep-layout-dependencies-explicit) -4. [JSX Runtime Is Opt-In](#4-jsx-runtime-is-opt-in) -5. [Migration Checklist](#5-migration-checklist) +2. [Development Server Uses @domstack/sync](#2-development-server-uses-domstacksync) +3. [Default Layout Uses fragtml](#3-default-layout-uses-fragtml) +4. [Keep Layout Dependencies Explicit](#4-keep-layout-dependencies-explicit) +5. [JSX Runtime Is Opt-In](#5-jsx-runtime-is-opt-in) +6. [Migration Checklist](#6-migration-checklist) --- @@ -30,7 +31,16 @@ import type { LayoutFunction, PageFunction, DomStackOpts } from '@domstack/stati --- -## 2. Default Layout Uses fragtml +## 2. Development Server Uses @domstack/sync + +Watch/serve mode now uses [`@domstack/sync`](https://www.npmjs.com/package/@domstack/sync) for the local development server. + +This provides live reload, CSS injection, ghost mode, and the UI panel. +If you were relying on BrowserSync-specific behavior or output, update your expectations around logs, access URLs, and reload handling. + +--- + +## 3. Default Layout Uses fragtml The bundled default `root.layout.js` now uses [`fragtml`](https://github.com/bcomnes/fragtml#readme) for server-side HTML rendering. @@ -62,7 +72,7 @@ Markdown output passed to a layout as `children` is one example. --- -## 3. Keep Layout Dependencies Explicit +## 4. Keep Layout Dependencies Explicit DOMStack only installs dependencies for its bundled defaults. Your project is responsible for any packages imported by pages, layouts, globals, or browser clients. @@ -72,7 +82,7 @@ If you migrate those server-side templates to `fragtml`, replace those dependenc --- -## 4. JSX Runtime Is Opt-In +## 5. JSX Runtime Is Opt-In Client `.jsx` and `.tsx` bundles are still supported through esbuild. Domstack no longer configures Preact as the default JSX runtime. @@ -113,9 +123,10 @@ export default async function esbuildSettingsOverride (esbuildSettings) { --- -## 5. Migration Checklist +## 6. Migration Checklist - [ ] If you import public types from `@domstack/static`, update those imports to `@domstack/static/types.js`. +- [ ] If you rely on BrowserSync-specific dev-server behavior, test watch mode with `@domstack/sync`. - [ ] If you rely on the bundled default layout, make sure pages and child layouts return HTML strings or `fragtml` template results, not Preact or HTM VNodes. - [ ] If you want to keep the v11 Preact default layout, eject on v11 before upgrading to v12. - [ ] If your ejected layout or server-side pages still import `htm/preact`, `preact`, or `preact-render-to-string`, keep those dependencies in your own `package.json`. diff --git a/examples/blog/src/README.md b/examples/blog/src/README.md index 00a932bc..519e906c 100644 --- a/examples/blog/src/README.md +++ b/examples/blog/src/README.md @@ -11,5 +11,3 @@ at build time, with no manual wiring needed in this file. {{{ vars.recentPostsHtml }}} See [all posts →](/blog/) - -Hello diff --git a/examples/default-layout/package.json b/examples/default-layout/package.json index 01c91475..8933b848 100644 --- a/examples/default-layout/package.json +++ b/examples/default-layout/package.json @@ -7,17 +7,11 @@ "start": "npm run watch", "build": "npm run clean && domstack", "clean": "rm -rf public && mkdir -p public", - "watch": "npm run clean && run-p watch:*", - "watch:serve": "browser-sync start --server 'public' --files 'public'", - "watch:domstack": "npm run build -- --watch" + "watch": "npm run clean && domstack --watch" }, "dependencies": { "@domstack/static": "file:../../." }, - "devDependencies": { - "browser-sync": "^2.26.7", - "npm-run-all2": "^6.0.0" - }, "keywords": [], "author": "Bret Comnes (https://bret.io/)", "license": "MIT" diff --git a/examples/string-layouts/package.json b/examples/string-layouts/package.json index 9f8fc0d5..f0fe0cb5 100644 --- a/examples/string-layouts/package.json +++ b/examples/string-layouts/package.json @@ -6,17 +6,11 @@ "start": "npm run watch", "build": "npm run clean && domstack", "clean": "rm -rf public && mkdir -p public", - "watch": "npm run clean && run-p watch:*", - "watch:serve": "browser-sync start --server 'public' --files 'public'", - "watch:domstack": "npm run build -- --watch" + "watch": "npm run clean && domstack --watch" }, "author": "Bret Comnes (https://bret.io/)", "license": "MIT", "dependencies": { "@domstack/static": "file:../../." - }, - "devDependencies": { - "browser-sync": "^2.26.7", - "npm-run-all2": "^6.0.0" } } diff --git a/index.js b/index.js index 33cb8cf1..0325027f 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,8 @@ * @import { BuildContext } from 'esbuild' * @import { PageInfo, TemplateInfo } from './lib/identify-pages.js' * @import { TestBuildResult } from './types.js' + * @import { BsInstance } from '@domstack/sync' + * @import { Logger as PinoLogger } from 'pino' */ import { once } from 'events' import assert from 'node:assert' @@ -18,7 +20,7 @@ import makeArray from 'make-array' import ignore from 'ignore' import { watch as cpxWatch } from 'cpx2' import { inspect } from 'util' -import browserSync from 'browser-sync' +import { createServer } from '@domstack/sync' import { find } from '@11ty/dependency-tree-typescript' import { getCopyGlob } from './lib/build-static/index.js' @@ -45,6 +47,7 @@ import { import { resolveVars } from './lib/build-pages/resolve-vars.js' import { ensureDest } from './lib/helpers/ensure-dest.js' import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.js' +import { createDomStackLogger } from './lib/logger.js' export { PageData } from './lib/build-pages/page-data.js' @@ -58,18 +61,16 @@ const DEFAULT_IGNORES = /** @type {const} */ ([ 'yarn.lock', ]) -/** - * @template {DomStackOpts} [CurrentOpts=DomStackOpts] - The type of options for the DomStack instance - */ export class DomStack { /** @type {string} */ #src = '' /** @type {string} */ #dest = '' - /** @type {Readonly} */ opts + /** @type {Readonly} */ opts /** @type {FSWatcher?} */ #watcher = null /** @type {any[]?} */ #cpxWatchers = null - /** @type {browserSync.BrowserSyncInstance?} */ #browserSyncServer = null + /** @type {BsInstance?} */ #syncServer = null /** @type {BuildContext?} */ #esbuildContext = null /** @type {SiteData?} */ #siteData = null + /** @type {PinoLogger} */ #logger // Watch maps (rebuilt after every full rebuild) /** @type {Map>} depFilepath → Set */ @@ -99,29 +100,19 @@ export class DomStack { * * @param {string} src - The src path of the page build * @param {string} dest - The dest path of the page build - * @param {CurrentOpts} [opts] - The options for the site build + * @param {DomStackOpts} [opts] - The options for the site build */ - constructor (src, dest, opts = /** @type {CurrentOpts} */ ({})) { + constructor (src, dest, opts = {}) { if (!src || typeof src !== 'string') throw new TypeError('src should be a (non-empty) string') if (!dest || typeof dest !== 'string') throw new TypeError('dest should be a (non-empty) string') if (!opts || typeof opts !== 'object') throw new TypeError('opts should be an object') this.#src = src this.#dest = dest + this.#logger = opts.logger ?? createDomStackLogger() + this.opts = normalizeDomStackOpts(opts, dest) - const copyDirs = (opts?.copy ?? []).map(dir => resolve(dir)) - - this.opts = { - ...opts, - copy: copyDirs, - ignore: [ - ...DEFAULT_IGNORES, - basename(dest), - ...copyDirs.map(dir => basename(dir)), - ...makeArray(opts.ignore), - ], - } - + const copyDirs = this.opts.copy ?? [] if (copyDirs.length > 0) { const absDest = resolve(this.#dest) for (const copyDir of copyDirs) { @@ -146,10 +137,12 @@ export class DomStack { * Build and watch a domstack build * @param {object} [params] * @param {boolean} params.serve + * @param {(results: Results) => void | Promise} [params.onInitialBuild] * @return {Promise} */ async watch ({ serve, + onInitialBuild, } = { serve: true, }) { @@ -184,10 +177,10 @@ export class DomStack { siteData, pageBuildResults, } - buildLogger(report) - console.log('Initial JS, CSS and Page Build Complete') + buildLogger(report, this.#logger) + this.#logger.info('Initial JS, CSS and Page Build Complete') } catch (err) { - errorLogger(err) + errorLogger(err, this.#logger) if (!(err instanceof DomStackAggregateError)) throw new Error('Non-aggregate error thrown', { cause: err }) report = err.results } @@ -195,38 +188,29 @@ export class DomStack { // Build watch maps after initial build await this.#rebuildMaps(siteData) - // ── Copy watchers & browser-sync ───────────────────────────────────── - const copyDirs = getCopyDirs(this.opts.copy) + // ── Copy watchers & dev server ─────────────────────────────────────── + const copyDirs = getCopyDirs(this.opts.copy ?? []) this.#cpxWatchers = [ - cpxWatch(getCopyGlob(this.#src), this.#dest, { ignore: this.opts.ignore }), + cpxWatch(getCopyGlob(this.#src), this.#dest, { ignore: this.opts.ignore ?? [] }), ...copyDirs.map(copyDir => cpxWatch(copyDir, this.#dest)) ] - if (serve) { - const bs = browserSync.create() - this.#browserSyncServer = bs - bs.watch(basename(this.#dest), { ignoreInitial: true }).on('change', bs.reload) - bs.init({ - server: this.#dest, - }) - } - - this.#cpxWatchers.forEach(w => { - w.on('watch-ready', () => { - console.log('Copy watcher ready') - w.on('copy', (/** @type{{ srcPath: string, dstPath: string }} */e) => { - console.log(`Copy ${e.srcPath} to ${e.dstPath}`) - }) + const copyWatchersReady = this.#cpxWatchers.map(async w => { + w.on('copy', (/** @type{{ srcPath: string, dstPath: string }} */e) => { + this.#logger.info(`Copy ${e.srcPath} to ${e.dstPath}`) + }) - w.on('remove', (/** @type{{ path: string }} */e) => { - console.log(`Remove ${e.path}`) - }) + w.on('remove', (/** @type{{ path: string }} */e) => { + this.#logger.info(`Remove ${e.path}`) + }) - w.on('watch-error', (/** @type{Error} */err) => { - console.log(`Copy error: ${err.message}`) - }) + w.on('watch-error', (/** @type{Error} */err) => { + this.#logger.error(`Copy error: ${err.message}`) }) + + await once(w, 'watch-ready') + this.#logger.info('Copy watcher ready') }) // ── Chokidar watcher ───────────────────────────────────────────────── @@ -257,10 +241,23 @@ export class DomStack { this.#watcher = watcher - await once(watcher, 'ready') + await Promise.all([ + ...copyWatchersReady, + once(watcher, 'ready'), + ]) + + await onInitialBuild?.(report) + + if (serve) { + this.#syncServer = await createServer({ + server: this.#dest, + files: basename(this.#dest), + logger: this.#logger.child({ component: 'sync', logPrefix: '[domstack-sync]' }), + }) + } const enqueue = (/** @type {() => Promise} */ fn) => { - this.#buildLock = this.#buildLock.then(() => fn().catch(errorLogger)) + this.#buildLock = this.#buildLock.then(() => fn().catch(err => errorLogger(err, this.#logger))) } watcher.on('add', path => { @@ -274,7 +271,7 @@ export class DomStack { watcher.on('unlink', path => { enqueue(() => this.#handleAddUnlink(path, 'removed')) }) - watcher.on('error', errorLogger) + watcher.on('error', err => errorLogger(err, this.#logger)) return report } @@ -284,7 +281,7 @@ export class DomStack { * Used for structural changes (add/unlink), global.vars.*, esbuild.settings.*. */ async #fullRebuild () { - console.log('Triggering full rebuild...') + this.#logger.info('Triggering full rebuild...') // Dispose the old esbuild context if (this.#esbuildContext) { await this.#esbuildContext.dispose() @@ -294,8 +291,8 @@ export class DomStack { const siteData = await identifyPages(this.#src, this.opts) if (siteData.errors.length > 0) { - console.error('identifyPages errors:') - for (const err of siteData.errors) console.error(' ', err.message) + this.#logger.error(`identifyPages errors: +${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) return } @@ -332,13 +329,12 @@ export class DomStack { ) if (isEsbuildEntry) { - console.log(`"${changedBasename}" ${event}, restarting esbuild...`) + this.#logger.info(`"${changedBasename}" ${event}, restarting esbuild...`) // Re-identify pages to discover the new/removed entry point const siteData = await identifyPages(this.#src, this.opts) if (siteData.errors.length > 0) { - console.error('identifyPages errors:') - for (const err of siteData.errors) console.error(' ', err.message) + this.#logger.error(`identifyPages errors:\n${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) return } @@ -358,7 +354,7 @@ export class DomStack { if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) { // Global asset: rebuild all pages - logRebuildTree(changedBasename, new Set(siteData.pages)) + logRebuildTree(changedBasename, this.#logger, new Set(siteData.pages)) await this.#runPageBuild(siteData) } else if (layoutClientSuffixs.some(s => changedBasename.endsWith(s)) || changedBasename.endsWith(layoutStyleSuffix)) { // Layout asset: rebuild pages using that layout @@ -370,7 +366,7 @@ export class DomStack { await this.#rebuildMaps(siteData) const affectedPages = this.#layoutPageMap.get(layoutName) if (affectedPages && affectedPages.size > 0) { - logRebuildTree(changedBasename, affectedPages) + logRebuildTree(changedBasename, this.#logger, affectedPages) const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) await this.#runPageBuild(siteData, pageFilterPaths, []) return @@ -382,7 +378,7 @@ export class DomStack { // Page-level asset (client.*, style.css, *.worker.*): rebuild only that page const affectedPage = siteData.pages.find(p => p.path === changedDir) if (affectedPage) { - logRebuildTree(changedBasename, new Set([affectedPage])) + logRebuildTree(changedBasename, this.#logger, new Set([affectedPage])) await this.#runPageBuild(siteData, [affectedPage.pageFile.filepath], []) } else { // Page not found (maybe it was removed) — rebuild all pages @@ -393,7 +389,7 @@ export class DomStack { await this.#rebuildMaps(siteData) } else { // Non-esbuild file: structural change (page, layout, template, config, etc.) - console.log(`"${changedBasename}" ${event}, triggering full rebuild...`) + this.#logger.info(`"${changedBasename}" ${event}, triggering full rebuild...`) return this.#fullRebuild() } } @@ -416,10 +412,11 @@ export class DomStack { const isFiltered = pageFilterPaths !== null || templateFilterPaths !== null buildLogger( isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults }, + this.#logger, isFiltered ? this.#dest : undefined ) } catch (err) { - errorLogger(err) + errorLogger(err, this.#logger) } } @@ -557,26 +554,26 @@ export class DomStack { // 2. global.vars.* → full rebuild (esbuild restart + all pages) if (globalVarsNames.some(n => changedBasename === n)) { - console.log(`"${changedBasename}" changed, triggering full rebuild...`) + this.#logger.info(`"${changedBasename}" changed, triggering full rebuild...`) return this.#fullRebuild() } // 3. global.data.* → full page rebuild (no esbuild restart) if (globalDataNames.some(n => changedBasename === n)) { - console.log(`"${changedBasename}" changed, rebuilding all pages...`) + this.#logger.info(`"${changedBasename}" changed, rebuilding all pages...`) return this.#runPageBuild(siteData) } // 4. esbuild.settings.* → full rebuild if (esbuildSettingsNames.some(n => changedBasename === n)) { - console.log(`"${changedBasename}" changed, triggering full rebuild...`) + this.#logger.info(`"${changedBasename}" changed, triggering full rebuild...`) return this.#fullRebuild() } // 5. markdown-it.settings.* → rebuild all md pages only if (markdownItSettingsNames.some(n => changedBasename === n)) { const mdPages = new Set(siteData.pages.filter(p => p.type === 'md')) - logRebuildTree(changedBasename, mdPages) + logRebuildTree(changedBasename, this.#logger, mdPages) return this.#runPageBuild(siteData, Array.from(mdPages).map(p => p.pageFile.filepath), []) } @@ -584,7 +581,7 @@ export class DomStack { // esbuild's own watcher handles these. Stable filenames mean page HTML doesn't // change, so no page rebuild is needed. if (this.#esbuildEntryPoints.has(changedPath)) { - console.log(`"${changedBasename}" changed, esbuild will handle rebundling.`) + this.#logger.info(`"${changedBasename}" changed, esbuild will handle rebundling.`) return } @@ -594,11 +591,11 @@ export class DomStack { if (layoutName) { const affectedPages = this.#layoutPageMap.get(layoutName) if (affectedPages && affectedPages.size > 0) { - logRebuildTree(changedBasename, affectedPages) + logRebuildTree(changedBasename, this.#logger, affectedPages) const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) return this.#runPageBuild(siteData, pageFilterPaths, []) } - console.log(`"${changedBasename}" changed but no pages use layout "${layoutName}", skipping.`) + this.#logger.info(`"${changedBasename}" changed but no pages use layout "${layoutName}", skipping.`) return } // Not a registered layout — fall through to dep checks @@ -613,7 +610,7 @@ export class DomStack { if (pages) for (const p of pages) affectedPages.add(p) } if (affectedPages.size > 0) { - logRebuildTree(changedBasename, affectedPages) + logRebuildTree(changedBasename, this.#logger, affectedPages) const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) return this.#runPageBuild(siteData, pageFilterPaths, []) } @@ -623,7 +620,7 @@ export class DomStack { if (this.#pageFileMap.has(changedPath)) { const affectedPage = this.#pageFileMap.get(changedPath) if (affectedPage) { - logRebuildTree(changedBasename, new Set([affectedPage])) + logRebuildTree(changedBasename, this.#logger, new Set([affectedPage])) return this.#runPageBuild(siteData, [affectedPage.pageFile.filepath], []) } } @@ -632,7 +629,7 @@ export class DomStack { if (templateSuffixs.some(s => changedBasename.endsWith(s))) { const templateInfo = siteData.templates.find(t => t.templateFile.filepath === changedPath) if (templateInfo) { - logRebuildTree(changedBasename, undefined, new Set([templateInfo])) + logRebuildTree(changedBasename, this.#logger, undefined, new Set([templateInfo])) return this.#runPageBuild(siteData, [], [templateInfo.templateFile.filepath]) } } @@ -641,7 +638,7 @@ export class DomStack { if (this.#pageDepMap.has(changedPath)) { const affectedPages = this.#pageDepMap.get(changedPath) ?? new Set() if (affectedPages.size > 0) { - logRebuildTree(changedBasename, affectedPages) + logRebuildTree(changedBasename, this.#logger, affectedPages) const pageFilterPaths = Array.from(affectedPages).map(p => p.pageFile.filepath) return this.#runPageBuild(siteData, pageFilterPaths, []) } @@ -651,14 +648,14 @@ export class DomStack { if (this.#templateDepMap.has(changedPath)) { const affectedTemplates = this.#templateDepMap.get(changedPath) ?? new Set() if (affectedTemplates.size > 0) { - logRebuildTree(changedBasename, undefined, affectedTemplates) + logRebuildTree(changedBasename, this.#logger, undefined, affectedTemplates) const templateFilterPaths = Array.from(affectedTemplates).map(t => t.templateFile.filepath) return this.#runPageBuild(siteData, [], templateFilterPaths) } } // 13. No matching rule — skip. - console.log(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`) + this.#logger.info(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`) } async stopWatching () { @@ -673,8 +670,8 @@ export class DomStack { await this.#esbuildContext.dispose() this.#esbuildContext = null } - this.#browserSyncServer?.exit() // This will kill the process - this.#browserSyncServer = null + await this.#syncServer?.exit() + this.#syncServer = null } /** @@ -716,13 +713,36 @@ function relname (root, name) { return root === name ? basename(name) : relative(root, name) } +/** + * @param {DomStackOpts} opts + * @param {string} dest + * @returns {DomStackOpts} + */ +function normalizeDomStackOpts (opts, dest) { + const buildOpts = { ...opts } + delete buildOpts.logger + const copyDirs = (buildOpts.copy ?? []).map(dir => resolve(dir)) + + return { + ...buildOpts, + copy: copyDirs, + ignore: [ + ...DEFAULT_IGNORES, + basename(dest), + ...copyDirs.map(dir => basename(dir)), + ...makeArray(buildOpts.ignore), + ], + } +} + /** * Log a rebuild tree showing what triggered a rebuild and what will be rebuilt. * @param {string} trigger - The changed file (display name) + * @param {PinoLogger} logger * @param {Set} [pages] * @param {Set} [templates] */ -function logRebuildTree (trigger, pages, templates) { +function logRebuildTree (trigger, logger, pages, templates) { const lines = [`"${trigger}" changed:`] for (const p of pages ?? []) { lines.push(` → ${p.outputRelname}`) @@ -730,63 +750,61 @@ function logRebuildTree (trigger, pages, templates) { for (const t of templates ?? []) { lines.push(` → ${t.outputName} (template)`) } - console.log(lines.join('\n')) + logger.info(lines.join('\n')) } /** * An error logger * @param {Error | AggregateError | any } err The error to log + * @param {PinoLogger} logger */ -function errorLogger (err) { +function errorLogger (err, logger) { if (!(err instanceof Error || err instanceof AggregateError)) throw new Error('Non-error thrown', { cause: err }) if ('results' in err) delete err.results - console.error(inspect(err, { depth: 999, colors: true })) - - console.log('\nBuild Failed!\n\n') - console.error(err) + logger.error(inspect(err, { depth: 999, colors: true })) + logger.error('\nBuild Failed!\n\n') } /** * Log build results. * @param {Partial | WorkerBuildStepResult} results + * @param {PinoLogger} logger * @param {string} [dest] - dest path for relativizing output paths in filtered builds */ -function buildLogger (results, dest) { +function buildLogger (results, logger, dest) { if ((results?.warnings?.length ?? 0) > 0) { - console.log( - '\nThere were build warnings:\n' - ) + logger.warn('\nThere were build warnings:\n') } for (const warning of results?.warnings ?? []) { if ('message' in warning) { - console.log(` ${warning.message}`) + logger.warn(` ${warning.message}`) } else { - console.warn(warning) + logger.warn(inspect(warning, { depth: 999, colors: true })) } } if ('siteData' in results && results.siteData) { // Full build: show site totals const layoutCount = Object.keys(results.siteData.layouts).length - console.log(`Pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) + logger.info(`Pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) const report = results.pageBuildResults?.report if (report) { - console.log(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + logger.info(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) } } else if ('report' in results && results.report) { // Filtered build: show what was actually built const report = results.report if (dest) { for (const p of report.pages) { - console.log(` Built ${relative(dest, p.pageFilePath)}`) + logger.info(` Built ${relative(dest, p.pageFilePath)}`) } for (const t of report.templates) { - for (const output of t.outputs ?? []) { - console.log(` Built ${output}`) + for (const outputFile of t.outputs ?? []) { + logger.info(` Built ${outputFile}`) } } } - console.log(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + logger.info(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) } - console.log('\nBuild Success!\n\n') + logger.info('\nBuild Success!\n\n') } diff --git a/lib/builder.js b/lib/builder.js index d6ea843f..76afd1d3 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -1,5 +1,6 @@ /** * @import {Message as EsbuildMessage} from 'esbuild' + * @import { Logger as PinoLogger } from 'pino' * @import { DomStackWarning } from './helpers/domstack-warning.js' * @import { EsBuildStepResults } from './build-esbuild/index.js' * @import { PageBuildStepResult } from './build-pages/index.js' @@ -51,6 +52,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @property {string[]|undefined} [target=[]] - Esbuild target values used for JavaScript and CSS bundling. * @property {boolean|undefined} [buildDrafts=false] - Build files marked with the `published: false` variable. * @property {string[]|undefined} [copy=[]] - Paths to copy into the dest directory. Relative paths are resolved to absolute paths from the current working directory by the DomStack constructor, matching the CLI `--copy` behavior. + * @property {PinoLogger|undefined} [logger] - Logger used for watch output and embedded sync output. */ /** diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 00000000..c961d9c6 --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,80 @@ +/** + * @import { LevelWithSilentOrString, Logger as PinoLogger } from 'pino' + * @import { PrettyOptions } from 'pino-pretty' + */ +import pino from 'pino' +import pretty from 'pino-pretty' + +/** + * @param {LevelWithSilentOrString} [level] + * @returns {PinoLogger} + */ +export function createDomStackLogger (level = 'info') { + const isTTY = Boolean(process.stdout.isTTY) + const stream = pretty({ + colorize: isTTY, + colorizeObjects: isTTY, + customColors: 'debug:gray,info:cyan,warn:yellow,error:red,fatal:bgRed', + hideObject: false, + levelFirst: true, + singleLine: true, + ignore: 'pid,hostname,time,logPrefix,component,req,reqId,res,responseTime', + messageFormat: formatPrettyMessage, + sync: true, + }) + + return pino({ + level, + base: null, + timestamp: false, + }, stream) +} + +/** @type {Exclude, string | false>} */ +function formatPrettyMessage (log, messageKey) { + const rawMessage = log[messageKey] + const message = typeof rawMessage === 'string' ? rawMessage : String(rawMessage ?? '') + const formatted = log['component'] === 'fastify' + ? formatFastifyMessage(log, message) + : message + + if (typeof log['logPrefix'] === 'string') return `${log['logPrefix']} ${formatted}` + return formatted +} + +/** + * @param {Record} log + * @param {string} fallback + */ +function formatFastifyMessage (log, fallback) { + const req = getRecord(log['req']) + const res = getRecord(log['res']) + const method = typeof req?.['method'] === 'string' ? req['method'] : null + const url = typeof req?.['url'] === 'string' ? req['url'] : null + + if (res) { + const statusCode = typeof res['statusCode'] === 'number' ? res['statusCode'] : null + const responseTime = typeof log['responseTime'] === 'number' ? `${Math.round(log['responseTime'])}ms` : null + return [ + 'HTTP response:', + method, + url, + statusCode ? `-> ${statusCode}` : null, + responseTime ? `(${responseTime})` : null, + ].filter(Boolean).join(' ') + } + + if (req) { + return ['HTTP request:', method, url].filter(Boolean).join(' ') + } + + return fallback +} + +/** + * @param {unknown} value + * @returns {Record | null} + */ +function getRecord (value) { + return value && typeof value === 'object' ? /** @type {Record} */ (value) : null +} diff --git a/package.json b/package.json index 23232121..a31f0ac5 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ }, "dependencies": { "@11ty/dependency-tree-typescript": "^1.0.0", + "@domstack/sync": "^0.0.5", "argsclopts": "^1.0.4", "async-folder-walker": "^3.0.5", - "browser-sync": "^3.0.2", "chokidar": "^5.0.0", "clean-deep": "^3.4.0", "cpx2": "^9.0.0", @@ -55,13 +55,14 @@ "p-map": "^7.0.2", "package-directory": "^8.1.0", "package-json": "^10.0.0", + "pino": "^9.14.0", + "pino-pretty": "^13.1.3", "pretty": "^2.0.0", "pretty-tree": "^1.0.0", "read-pkg": "^10.0.0", "write-package": "^7.0.1" }, "devDependencies": { - "@types/browser-sync": "^2.29.0", "@types/markdown-it": "^14.1.1", "@types/markdown-it-footnote": "^3.0.4", "@types/node": "^25.3.0", diff --git a/plans/progressive-rebuild.md b/plans/progressive-rebuild.md index cc2f3ec5..0fbb39dc 100644 --- a/plans/progressive-rebuild.md +++ b/plans/progressive-rebuild.md @@ -32,7 +32,7 @@ Two separate, parallel watch loops: - `outputMap` only needs to be computed once at startup — stable across rebuilds - A `domstack-on-end` esbuild plugin logs errors after each bundle rebuild - Since watch mode uses stable filenames, page HTML never changes when bundles rebuild — no - page rebuild triggered. Browser-sync reloads the browser directly. + page rebuild triggered. `@domstack/sync` reloads the browser directly. - esbuild's `context()` API does NOT support modifying entry points after creation. Adding or removing an esbuild entry point requires `dispose()` + recreating the context. @@ -305,7 +305,7 @@ Uses `t.after()` hooks for cleanup (stopWatching, mock restore, temp dir removal - **global.data.js trigger rule**: Always full page rebuild. Since `global.data.js` output is stamped onto every page's vars, there's no safe subset to rebuild. No esbuild restart needed. - **esbuild onEnd**: No page rebuild triggered. Watch mode uses stable filenames so page HTML - never references changed bundle paths. Browser-sync reloads the browser directly. + never references changed bundle paths. `@domstack/sync` reloads the browser directly. - **esbuild entry point detection**: Uses a concrete `Set` built from siteData properties, checked BEFORE dep map rules in the decision tree. This prevents dep maps (which may contain esbuild entry files as transitive dependencies) from triggering diff --git a/test-cases/constructor-copy-paths/index.test.js b/test-cases/constructor-copy-paths/index.test.js index b43af321..cea92167 100644 --- a/test-cases/constructor-copy-paths/index.test.js +++ b/test-cases/constructor-copy-paths/index.test.js @@ -13,8 +13,9 @@ test.describe('DomStack constructor - copy path resolution', () => { copy: ['some-relative-copy-dir'], }) - assert.strictEqual(ds.opts.copy.length, 1, 'one copy entry') - const copyPath = /** @type {string} */ (ds.opts.copy[0]) + const copy = ds.opts.copy ?? [] + assert.strictEqual(copy.length, 1, 'one copy entry') + const copyPath = /** @type {string} */ (copy[0]) assert.ok(isAbsolute(copyPath), `copy path should be absolute, got: "${copyPath}"`) }) @@ -24,7 +25,8 @@ test.describe('DomStack constructor - copy path resolution', () => { copy: [absPath], }) - assert.strictEqual(ds.opts.copy[0], resolve(absPath), 'absolute path is preserved and normalized') + const copy = ds.opts.copy ?? [] + assert.strictEqual(copy[0], resolve(absPath), 'absolute path is preserved and normalized') }) test('resolves multiple mixed copy paths', () => { @@ -33,8 +35,9 @@ test.describe('DomStack constructor - copy path resolution', () => { copy: ['relative-dir', absPath], }) - assert.strictEqual(ds.opts.copy.length, 2, 'two copy entries') - for (const p of ds.opts.copy) { + const copy = ds.opts.copy ?? [] + assert.strictEqual(copy.length, 2, 'two copy entries') + for (const p of copy) { assert.ok(isAbsolute(p), `each copy path should be absolute, got: "${p}"`) } }) diff --git a/test-cases/watch/index.test.js b/test-cases/watch/index.test.js index 3b3e98d5..98df368e 100644 --- a/test-cases/watch/index.test.js +++ b/test-cases/watch/index.test.js @@ -21,21 +21,50 @@ async function setupTempSite () { /** * Wait for chokidar to detect a change and the rebuild to settle. - * @param {DomStack} siteUp + * @param {DomStack} domStack * @param {number} [ms=800] */ -async function settle (siteUp, ms = 800) { +async function settle (domStack, ms = 800) { await new Promise(resolve => setTimeout(resolve, ms)) - await siteUp.settled() + await domStack.settled() } /** - * Collect all console.log call arguments into a flat string array. + * Collect all console.log call arguments and logger chunks into a flat string array. * @param {ReturnType} mockLog + * @param {string[]} loggerLogs * @returns {string[]} */ -function getLogLines (mockLog) { - return mockLog.mock.calls.map(c => c.arguments.map(String).join(' ')) +function getLogLines (mockLog, loggerLogs) { + const consoleLines = mockLog.mock.calls.map(c => c.arguments.map(String).join(' ')) + return [...consoleLines, ...loggerLogs] +} + +/** + * @param {string[]} logs + */ +function createTestLogger (logs) { + /** @param {unknown[]} args */ + const write = (args) => { + const first = args[0] + const messageArgs = first && typeof first === 'object' + ? args.slice(1) + : args + logs.push(messageArgs.map(String).join(' ')) + } + + const logger = { + level: 'info', + /** @param {...unknown} args */ + info (...args) { write(args) }, + /** @param {...unknown} args */ + warn (...args) { write(args) }, + /** @param {...unknown} args */ + error (...args) { write(args) }, + child () { return logger }, + } + + return /** @type {import('pino').Logger} */ (/** @type {unknown} */ (logger)) } test.describe('watch', () => { @@ -46,16 +75,18 @@ test.describe('watch', () => { await rm(tmp, { recursive: true, force: true }) }) - const siteUp = new DomStack(src, dest) const mockLog = mock.method(console, 'log') + const loggerLogs = /** @type {string[]} */ ([]) + const logger = createTestLogger(loggerLogs) + const domStack = new DomStack(src, dest, { logger }) t.after(async () => { - if (siteUp.watching) await siteUp.stopWatching() + if (domStack.watching) await domStack.stopWatching() mockLog.mock.restore() }) // ── Initial build ──────────────────────────────────────────────── - const results = await siteUp.watch({ serve: false }) + const results = await domStack.watch({ serve: false }) assert.ok(results, 'watch() returned initial build results') assert.ok(results.siteData, 'results include siteData') @@ -78,14 +109,15 @@ test.describe('watch', () => { // ── Page file change → only that page rebuilds ─────────────────── await t.test('page file change rebuilds only that page', async () => { mockLog.mock.resetCalls() + loggerLogs.length = 0 const pageFile = path.join(src, 'js-page/page.js') const original = await readFile(pageFile, 'utf8') await writeFile(pageFile, original.replace('jus some html', 'UPDATED html')) - await settle(siteUp) + await settle(domStack) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('"page.js" changed:')), 'log shows page.js triggered a rebuild' @@ -106,14 +138,14 @@ test.describe('watch', () => { // ── Layout change → pages using that layout rebuild ────────────── await t.test('layout change rebuilds only pages using that layout', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const layoutFile = path.join(src, 'layouts/root.layout.js') const original = await readFile(layoutFile, 'utf8') await writeFile(layoutFile, original.replace('safe-area-inset', 'safe-area-inset layout-touched')) - await settle(siteUp) + await settle(domStack) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('"root.layout.js" changed:')), 'log shows root.layout.js triggered a rebuild' @@ -132,14 +164,14 @@ test.describe('watch', () => { // ── esbuild entry point change → no page rebuild ───────────────── await t.test('esbuild entry point change does not rebuild pages', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const clientFile = path.join(src, 'js-page/client.js') const original = await readFile(clientFile, 'utf8') await writeFile(clientFile, original + '\n// touch') - await settle(siteUp) + await settle(domStack) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('esbuild will handle rebundling')), 'log confirms esbuild handles the change' @@ -153,14 +185,14 @@ test.describe('watch', () => { // ── esbuild dep change → esbuild rebuilds, no page rebuild ───── await t.test('changing a client.js dependency triggers esbuild rebuild only', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const helperFile = path.join(src, 'libs/client-helper.js') const original = await readFile(helperFile, 'utf8') await writeFile(helperFile, original.replace('hello from client-helper', 'UPDATED client-helper')) - await settle(siteUp) + await settle(domStack) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) // client-helper.js is NOT an esbuild entry point itself, but it IS imported by // client.js which IS an esbuild entry point. esbuild's own watcher tracks the // transitive imports of its entry points, so it should detect this and rebuild. @@ -174,14 +206,14 @@ test.describe('watch', () => { // ── page dependency change → only that page rebuilds ───────────── await t.test('changing a page.js dependency rebuilds only affected pages', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const helperFile = path.join(src, 'libs/page-helper.js') const original = await readFile(helperFile, 'utf8') await writeFile(helperFile, original.replace('page-helper-stamp', 'UPDATED-page-stamp')) - await settle(siteUp) + await settle(domStack) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('"page-helper.js" changed:')), 'log shows page-helper.js triggered a rebuild' @@ -202,14 +234,14 @@ test.describe('watch', () => { // ── layout dependency change → only pages using that layout rebuild await t.test('changing a layout dependency rebuilds only pages using that layout', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const helperFile = path.join(src, 'libs/layout-helper.js') const original = await readFile(helperFile, 'utf8') await writeFile(helperFile, original.replace('layout-helper-marker', 'UPDATED-layout-marker')) - await settle(siteUp) + await settle(domStack) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('"layout-helper.js" changed:')), 'log shows layout-helper.js triggered a rebuild' @@ -231,13 +263,13 @@ test.describe('watch', () => { // ── Add client.js to page dir → esbuild restart + targeted rebuild await t.test('adding client.js restarts esbuild and rebuilds only that page', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const newClient = path.join(src, 'js-page/js-no-style-client/client.js') await writeFile(newClient, 'console.log("new client")\n') - await settle(siteUp, 1200) + await settle(domStack, 1200) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('"client.js" added, restarting esbuild')), 'log shows esbuild restart on client.js add' @@ -255,13 +287,13 @@ test.describe('watch', () => { // ── Remove the client.js we just added → esbuild restart + targeted rebuild await t.test('removing client.js restarts esbuild and rebuilds only that page', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const clientToRemove = path.join(src, 'js-page/js-no-style-client/client.js') await unlink(clientToRemove) - await settle(siteUp, 1200) + await settle(domStack, 1200) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('"client.js" removed, restarting esbuild')), 'log shows esbuild restart on client.js removal' @@ -275,14 +307,14 @@ test.describe('watch', () => { // ── global.data.js change → all pages rebuild ──────────────────── await t.test('global.data.js change rebuilds all pages', async () => { mockLog.mock.resetCalls() - + loggerLogs.length = 0 const globalData = path.join(src, 'global.data.js') const original = await readFile(globalData, 'utf8') await writeFile(globalData, original + '\n// touch') - await settle(siteUp) + await settle(domStack) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( logs.some(l => l.includes('rebuilding all pages')), 'log shows all pages are being rebuilt' @@ -295,8 +327,8 @@ test.describe('watch', () => { // ── stopWatching cleans up ─────────────────────────────────────── await t.test('stopWatching completes without error', async () => { - await siteUp.stopWatching() - assert.ok(!siteUp.watching, 'watcher is stopped') + await domStack.stopWatching() + assert.ok(!domStack.watching, 'watcher is stopped') }) }) })