From 19d6abf3a38f12b4951e01f87662370b7f07d827 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 --- bin.js | 41 +++++----- examples/default-layout/package.json | 8 +- examples/string-layouts/package.json | 8 +- index.js | 107 +++++++++++++++------------ lib/builder.js | 1 + package.json | 3 +- test-cases/watch/index.test.js | 55 +++++++++----- 7 files changed, 125 insertions(+), 98 deletions(-) diff --git a/bin.js b/bin.js index 560ef5d..815e272 100755 --- a/bin.js +++ b/bin.js @@ -19,7 +19,7 @@ import { readPackage } from 'read-pkg' import { addPackageDependencies } from 'write-package' import { copyFile } from './lib/helpers/copy-file.js' -import { DomStack } from './index.js' +import { DomStack, createLogger } 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' @@ -201,6 +201,8 @@ domstack eject actions: /** @type {DomStackOpts} */ const opts = {} + const logger = createLogger('info') + opts.logger = logger if (argv['ignore']) opts.ignore = String(argv['ignore']).split(',') if (argv['target']) opts.target = String(argv['target']).split(',') @@ -219,11 +221,10 @@ 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) } @@ -258,22 +259,24 @@ domstack eject actions: process.exit(1) } } else { - const initialResults = await domStack.watch({ + await domStack.watch({ serve: !argv['watch-only'], + onInitialBuild: (initialResults) => { + 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) + } + } + }, }) - 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) - } - } } } diff --git a/examples/default-layout/package.json b/examples/default-layout/package.json index 01c9147..8933b84 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 9f8fc0d..f0fe0cb 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 06e6767..2a54625 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ * @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult, GlobalDataFunctionParams } from './lib/build-pages/index.js' * @import { BuildOptions, BuildContext } from 'esbuild' * @import { PageInfo, TemplateInfo } from './lib/identify-pages.js' + * @import { BsInstance } from '@domstack/sync' */ import { once } from 'events' import assert from 'node:assert' @@ -21,7 +22,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 { createLogger as createSyncLogger, createServer } from '@domstack/sync' import { find } from '@11ty/dependency-tree-typescript' import { getCopyGlob } from './lib/build-static/index.js' @@ -50,6 +51,13 @@ import { ensureDest } from './lib/helpers/ensure-dest.js' import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.js' export { PageData } from './lib/build-pages/page-data.js' +export { wrapPinoLogger } from '@domstack/sync' + +const LOG_PREFIX = '[domstack]' + +export function createLogger (level = 'info', streams = {}, options = {}) { + return createSyncLogger(level, streams, { prefix: LOG_PREFIX, ...options }) +} /** * @typedef {BuildOptions} BuildOptions @@ -154,9 +162,10 @@ export class DomStack { /** @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 {ReturnType} */ #logger // Watch maps (rebuilt after every full rebuild) /** @type {Map>} depFilepath → Set */ @@ -192,17 +201,19 @@ export class DomStack { this.#src = src this.#dest = dest - const copyDirs = opts?.copy ?? [] + const { logger, ...buildOpts } = opts + const copyDirs = buildOpts?.copy ?? [] - this.opts = { - ...opts, + this.opts = /** @type {Readonly} */ ({ + ...buildOpts, ignore: [ ...DEFAULT_IGNORES, basename(dest), ...copyDirs.map(dir => basename(dir)), - ...makeArray(opts.ignore), + ...makeArray(buildOpts.ignore), ], - } + }) + this.#logger = logger ?? createLogger('info') if (copyDirs && copyDirs.length > 0) { const absDest = resolve(this.#dest) @@ -229,10 +240,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, }) { @@ -268,7 +281,7 @@ export class DomStack { pageBuildResults, } buildLogger(report) - console.log('Initial JS, CSS and Page Build Complete') + this.#logger.info('Initial JS, CSS and page build complete') } catch (err) { errorLogger(err) if (!(err instanceof DomStackAggregateError)) throw new Error('Non-aggregate error thrown', { cause: err }) @@ -278,38 +291,29 @@ export class DomStack { // Build watch maps after initial build await this.#rebuildMaps(siteData) - // ── Copy watchers & browser-sync ───────────────────────────────────── + // ── Copy watchers & dev server ─────────────────────────────────────── const copyDirs = getCopyDirs(this.opts.copy) this.#cpxWatchers = [ 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({ srcPath: e.srcPath, dstPath: e.dstPath }, 'Copy file') + }) - w.on('remove', (/** @type{{ path: string }} */e) => { - console.log(`Remove ${e.path}`) - }) + w.on('remove', (/** @type{{ path: string }} */e) => { + this.#logger.info({ path: e.path }, 'Remove file') + }) - w.on('watch-error', (/** @type{Error} */err) => { - console.log(`Copy error: ${err.message}`) - }) + w.on('watch-error', (/** @type{Error} */err) => { + this.#logger.error({ err }, 'Copy error') }) + + await once(w, 'watch-ready') + this.#logger.info('Copy watcher ready') }) // ── Chokidar watcher ───────────────────────────────────────────────── @@ -340,7 +344,20 @@ 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' }, { prefix: '[domstack-sync]' }), + }) + } const enqueue = (/** @type {() => Promise} */ fn) => { this.#buildLock = this.#buildLock.then(() => fn().catch(errorLogger)) @@ -367,7 +384,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() @@ -377,8 +394,7 @@ 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({ errors: siteData.errors.map(err => err.message) }, 'identifyPages errors') return } @@ -415,13 +431,12 @@ export class DomStack { ) if (isEsbuildEntry) { - console.log(`"${changedBasename}" ${event}, restarting esbuild...`) + this.#logger.info({ file: 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({ errors: siteData.errors.map(err => err.message) }, 'identifyPages errors') return } @@ -476,7 +491,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({ file: changedBasename, event }, 'Triggering full rebuild') return this.#fullRebuild() } } @@ -640,19 +655,19 @@ 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({ file: changedBasename }, '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({ file: changedBasename }, '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({ file: changedBasename }, 'Triggering full rebuild') return this.#fullRebuild() } @@ -667,7 +682,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({ file: changedBasename }, 'Esbuild will handle rebundling') return } @@ -681,7 +696,7 @@ export class DomStack { 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({ file: changedBasename, layout: layoutName }, 'Layout changed but no pages use it; skipping') return } // Not a registered layout — fall through to dep checks @@ -741,7 +756,7 @@ export class DomStack { } // 13. No matching rule — skip. - console.log(`"${changedBasename}" changed but did not match any rebuild rule, skipping.`) + this.#logger.info({ file: changedBasename }, 'Changed file did not match any rebuild rule; skipping') } async stopWatching () { @@ -756,8 +771,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 } /** diff --git a/lib/builder.js b/lib/builder.js index 61489e3..ef49f17 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -51,6 +51,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @property {string[]|undefined} [target=[]] - Array of target strings to pass to esbuild * @property {boolean|undefined} [buildDrafts=false] - Build draft files with the published:false variable * @property {string[]|undefined} [copy=[]] - Array of paths to copy their contents into the dest directory + * @property {import('@domstack/sync').Logger|undefined} [logger] - Logger used for build/watch output */ /** diff --git a/package.json b/package.json index 7e5e10b..f0ee118 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ }, "dependencies": { "@11ty/dependency-tree-typescript": "^1.0.0", + "@domstack/sync": "^0.0.4", "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", @@ -57,7 +57,6 @@ "write-package": "^7.0.1" }, "devDependencies": { - "@types/browser-sync": "^2.29.0", "@types/js-yaml": "^4.0.9", "@types/markdown-it": "^14.1.1", "@types/markdown-it-footnote": "^3.0.4", diff --git a/test-cases/watch/index.test.js b/test-cases/watch/index.test.js index 3b3e98d..ca595e0 100644 --- a/test-cases/watch/index.test.js +++ b/test-cases/watch/index.test.js @@ -1,6 +1,6 @@ import { test, mock } from 'node:test' import assert from 'node:assert' -import { DomStack } from '../../index.js' +import { DomStack, createLogger } from '../../index.js' import { cp, rm, writeFile, readFile, unlink, mkdtemp, stat, readdir } from 'fs/promises' import * as path from 'path' @@ -32,10 +32,13 @@ async function settle (siteUp, ms = 800) { /** * Collect all console.log call arguments 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(' ')) + const streamLines = loggerLogs.flatMap(chunk => chunk.split(/\r?\n/).filter(Boolean)) + return [...consoleLines, ...streamLines] } test.describe('watch', () => { @@ -46,8 +49,17 @@ 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 loggerStream = { + write (/** @type {string | Uint8Array} */ chunk) { + loggerLogs.push(String(chunk)) + return true + } + } + const siteUp = new DomStack(src, dest, { + logger: createLogger('info', { stdout: loggerStream, stderr: loggerStream }) + }) t.after(async () => { if (siteUp.watching) await siteUp.stopWatching() @@ -78,6 +90,7 @@ 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') @@ -85,7 +98,7 @@ test.describe('watch', () => { await settle(siteUp) - 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,6 +119,7 @@ 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') @@ -113,7 +127,7 @@ test.describe('watch', () => { await settle(siteUp) - 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,6 +146,7 @@ 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') @@ -139,9 +154,9 @@ test.describe('watch', () => { await settle(siteUp) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( - logs.some(l => l.includes('esbuild will handle rebundling')), + logs.some(l => l.includes('Esbuild will handle rebundling')), 'log confirms esbuild handles the change' ) assert.ok( @@ -153,6 +168,7 @@ 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') @@ -160,7 +176,7 @@ test.describe('watch', () => { await settle(siteUp) - 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,6 +190,7 @@ 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') @@ -181,7 +198,7 @@ test.describe('watch', () => { await settle(siteUp) - 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,6 +219,7 @@ 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') @@ -209,7 +227,7 @@ test.describe('watch', () => { await settle(siteUp) - 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,15 +249,16 @@ 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) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( - logs.some(l => l.includes('"client.js" added, restarting esbuild')), + logs.some(l => l.includes('Restarting esbuild') && l.includes('client.js') && l.includes('added')), 'log shows esbuild restart on client.js add' ) assert.ok( @@ -255,15 +274,16 @@ 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) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( - logs.some(l => l.includes('"client.js" removed, restarting esbuild')), + logs.some(l => l.includes('Restarting esbuild') && l.includes('client.js') && l.includes('removed')), 'log shows esbuild restart on client.js removal' ) assert.ok( @@ -275,6 +295,7 @@ 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') @@ -282,9 +303,9 @@ test.describe('watch', () => { await settle(siteUp) - const logs = getLogLines(mockLog) + const logs = getLogLines(mockLog, loggerLogs) assert.ok( - logs.some(l => l.includes('rebuilding all pages')), + logs.some(l => l.includes('Rebuilding all pages')), 'log shows all pages are being rebuilt' ) assert.ok(