diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 152df60e..123842dd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,7 @@ -# Basic dependabot.yml file with -# minimum configuration for two package managers - version: 2 updates: - # Enable version updates for npm - package-ecosystem: "npm" + directory: "/" groups: typescript: patterns: @@ -13,150 +10,20 @@ updates: - "typescript" preact: patterns: + - "@preact/signals" - "preact" - "preact-render-to-string" - "htm" - directory: "/" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/basic" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/css-modules/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/default-layout/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/esbuild-settings" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/markdown-settings/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/nested-dest/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/preact-isomorphic/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/react/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" react: patterns: - "react" - "react-dom" - "@types/react" - "@types/react-dom" + schedule: interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/string-layouts/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/tailwind/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/type-stripping/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/uhtml-isomorphic/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/worker-examples/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - # Enable version updates for pnpm - # Enable updates to github actions + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.npmignore b/.npmignore deleted file mode 100644 index ad663373..00000000 --- a/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -sandbox.js -.nyc_output -package-lock.json -public -coverage -.tap -.nova diff --git a/README.md b/README.md index 1b04d23d..f081e7d9 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,15 @@ Usage: domstack [options] --dest, -d path to build destination directory (default: "public") --ignore, -i comma separated gitignore style ignore string --drafts Build draft pages with the `.draft.{md,js,ts,html}` page suffix. + --noEsbuildMeta skip writing the esbuild metafile to disk + --customDomstackManifestName + custom domstack manifest filename (default: domstack-manifest.json) + --noDomstackManifest disable writing the domstack manifest to disk --eject, -e eject the DOMStack default layout, style and client into the src flag directory --watch, -w build, watch and serve the site build --watch-only watch and build the src folder without serving + --serve build once and serve the destination directory without watching + --port port for --serve (default: 3000) --copy path to directories to copy into dist; can be used multiple times --help, -h show help --version, -v show version information @@ -55,6 +61,7 @@ domstack (v12.0.0) - 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 [`@domstack/sync`][domstack-sync]). +- Running `domstack --serve` will run a normal one-shot build and then serve the destination directory without watching or injecting live-reload snippets. This is useful for PWA testing because it writes `domstack-manifest.json` and serves bytes that match manifest revisions. Use `domstack --serve --port 3001` to choose a different local port. - 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. @@ -133,7 +140,9 @@ src % tree │ ├── global.vars.ts # site wide variables get defined in global.vars.ts │ ├── global.data.ts # optional file to derive and aggregate data from all pages before rendering │ ├── markdown-it.settings.ts # You can customize the markdown-it instance used to render markdown -│ └── esbuild.settings.ts # You can even customize the build settings passed to esbuild +│ ├── domstack-manifest.settings.ts # You can customize the domstack manifest +│ ├── esbuild.settings.ts # You can even customize the build settings passed to esbuild +│ └── service-worker.ts # a site service worker builds to /service-worker.js. ├── page.md # The top level page can also be a page.md (or README.md) file. ├── client.ts # the top level page can define a page scoped js client. ├── style.css # the top level page can define a page scoped css style. @@ -153,15 +162,14 @@ To run examples: ```bash $ git clone git@github.com:bcomnes/domstack.git $ cd domstack -# install the top level deps +# install the root package and all example workspaces $ npm i -$ cd example:{example-name} -# install the example deps -$ npm i -# start the example -$ npm start +# build one example workspace +$ npm --workspace @domstack/basic-example run build ``` +Each example is an npm workspace under `examples/*`, so a root `npm install` links the local `@domstack/static` package into example builds. Example packages keep `@domstack/static` declared as `file:../../.` so the local dependency target remains explicit. + ### Additional examples Here are some additional external examples of larger domstack projects. @@ -1069,6 +1077,228 @@ await pMap(allPosts, async (page) => { const html = renderCache.get(page.pageInfo.path) ?? '' ``` +## Domstack Manifest + +> [!WARNING] +> The domstack manifest, `domstack-manifest.settings.*`, first-class `service-worker.*` entries, and related browser `process.env.DOMSTACK_*` defines are an unstable preview feature. +> Their names, option shapes, manifest schema, generated output, and runtime semantics may change outside of a major version while the API is validated with real PWA use cases. +> Avoid depending on this preview contract for long-lived integrations without pinning `@domstack/static` to an exact version. + +Every programmatic build returns a `domstackManifest` object in the build results. The CLI also writes +`domstack-manifest.json` into the destination directory by default. Applications can use this manifest +as the source of truth for service-worker precaching, deployment metadata, or other static output +integrations. + +The manifest is a normalized list of files that domstack emitted: + +```js +/** + * @import { FromSchema } from 'json-schema-to-ts' + */ +import { DOMSTACK_MANIFEST_SCHEMA_ID, domstackManifestSchema } from '@domstack/static' + +/** + * @typedef {FromSchema} DomstackManifest + */ +``` + +Equivalent shape: + +```ts +type DomstackManifestShape = { + $schema: typeof DOMSTACK_MANIFEST_SCHEMA_ID + version: string + generatedAt: string + entries: DomstackManifestEntry[] +} + +type DomstackManifestEntry = { + url: string + outputRelname: string + kind: 'page' | 'template' | 'script' | 'style' | 'chunk' | + 'service-worker' | 'worker' | 'worker-manifest' | 'static' | + 'copy' | 'sourcemap' | 'metadata' + revision: string | null + bytes: number | null + sourceRelname?: string + entryPoint?: string + pagePath?: string + pageUrl?: string + templatePath?: string + page?: { + path: string + url: string + vars?: { + precache?: unknown + offline?: unknown + } + } +} +``` + +domstack exports `DOMSTACK_MANIFEST_SCHEMA_ID`, `DOMSTACK_MANIFEST_SCHEMA_PATH`, +`getDomstackManifestSchemaId(version)`, `domstackManifestSchema`, `domstackManifestEntrySchema`, +`domstackManifestEntryPageMetaSchema`, and `domstackManifestKindSchema` for tools that want the JSON Schema +contract directly. The public `DomstackManifest`, `DomstackManifestEntry`, `DomstackManifestEntryPageMeta`, +and `DomstackManifestKind` types are derived from those schemas. + +`version` is a sha256 hash of each sorted entry's cache-relevant fields: `url`, `revision`, `kind`, +and page-level `precache` / `offline` vars. It intentionally does not depend on `generatedAt` or +source metadata such as `sourceRelname`, so identical cache inputs keep the same version. + +The written manifest can be configured from the CLI: + +```console +domstack --customDomstackManifestName custom-domstack-manifest.json +domstack --noDomstackManifest +``` + +Or from the programmatic API: + +```js +const site = new DomStack('src', 'public', { + domstackManifest: { + filename: 'custom-domstack-manifest.json', + exclude: ['blog/**', '**/*.map'], + }, +}) + +const results = await site.build() +``` + +`domstackManifest: false` disables writing the JSON file, but `results.domstackManifest` is still returned. +The manifest file itself is never included in its own `entries`. + +You can also add a `domstack-manifest.settings.js` file anywhere under `src`: + +```js +export default { + filename: 'metadata/domstack-manifest.json', + exclude: ['admin/**'], + includeEntry (entry) { + return entry.kind !== 'sourcemap' && entry.kind !== 'metadata' + }, +} +``` + +`domstack-manifest.settings.*` supports `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, and `.cts` when Node's +TypeScript support is available. It can default export an object or a sync/async function that +returns an object: + +```js +export default async function domstackManifestSettings () { + return { + exclude: process.env.INCLUDE_BLOG_OFFLINE === '1' + ? ['**/*.map'] + : ['blog/**', '**/*.map'], + } +} +``` + +`filename` can be set in programmatic options, the CLI `--customDomstackManifestName` flag, or +`domstack-manifest.settings.*`; programmatic/CLI filenames take precedence over settings-file +filenames. `domstackManifest.exclude` from programmatic options and `domstack-manifest.settings.*` +`exclude` values are combined. Exclude patterns are ignore-style patterns checked against both +`entry.url` and `entry.outputRelname`; excludes run before `includeEntry(entry)`. The +`includeEntry(entry)` hook receives the public manifest entry shape, not local filesystem paths. + +domstack records page-level `precache` and `offline` vars on page entries when present. It does not +automatically apply those flags; service workers and deployment tools can use them when filtering. + +### Service workers + +Put one site service worker source file anywhere under `src` and domstack will build it to a stable +root `/service-worker.js` output: + +```txt +src/ + globals/ + service-worker.js +``` + +When Node's TypeScript support is available, the same convention also supports +`service-worker.ts`, `service-worker.mts`, and `service-worker.cts`. JavaScript projects can use +`service-worker.js`, `service-worker.mjs`, or `service-worker.cjs`. + +Only one site service worker source is allowed. If multiple `service-worker.*` sources are present, +domstack fails with `DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER`. Service workers are bundled by +esbuild, so imports work the same way they do for client bundles and page-scoped web workers. The +entry filename is intentionally not content-hashed because browser service-worker update checks need +a stable URL. + +Service workers do not need build-time access to the manifest. Domstack provides build facts to +browser-side bundles through esbuild `define` values: + +| Define | Value | +| --- | --- | +| `process.env.DOMSTACK_MANIFEST_URL` | Public URL of the written domstack manifest, usually `/domstack-manifest.json` | +| `process.env.DOMSTACK_MANIFEST_ENABLED` | `"true"` for one-shot builds that write the manifest, `"false"` when disabled or in watch mode | +| `process.env.DOMSTACK_SERVICE_WORKER_URL` | Public URL of the site service worker, usually `/service-worker.js`, or `""` when no service worker is present | +| `process.env.DOMSTACK_SERVICE_WORKER_SCOPE` | Registration scope for the site service worker, usually `/`, or `""` when no service worker is present | + +The built worker can fetch the domstack manifest during installation: + +```js +const DOMSTACK_MANIFEST_URL = process.env.DOMSTACK_MANIFEST_URL +const CACHE_PREFIX = 'domstack-precache-' + +self.addEventListener('install', (event) => { + event.waitUntil(precache()) +}) + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return + event.respondWith(cacheFirst(event.request)) +}) + +async function precache () { + if (process.env.DOMSTACK_MANIFEST_ENABLED !== 'true') return + + const response = await fetch(DOMSTACK_MANIFEST_URL, { cache: 'no-store' }) + const manifest = await response.json() + const cache = await caches.open(CACHE_PREFIX + manifest.version) + const urls = manifest.entries + .filter(entry => entry.revision) + .filter(entry => entry.kind !== 'sourcemap') + .filter(entry => entry.kind !== 'metadata') + .map(entry => entry.url) + + await cache.addAll(urls) +} + +async function cacheFirst (request) { + const cached = await caches.match(request) + return cached || fetch(request) +} +``` + +Register the built service worker from your site client code, usually `global.client.js`: + +```js +const serviceWorkerUrl = process.env.DOMSTACK_SERVICE_WORKER_URL +const serviceWorkerScope = process.env.DOMSTACK_SERVICE_WORKER_SCOPE + +if (serviceWorkerUrl && serviceWorkerScope && 'serviceWorker' in navigator) { + navigator.serviceWorker.register(serviceWorkerUrl, { scope: serviceWorkerScope }) +} +``` + +domstack does not inject this into the default layout. Registration timing, update prompts, +development opt-outs, and recovery behavior are application policy, so keep that logic in your +global client or an imported client module. + +This keeps domstack's build pipeline to one page/template pass and one manifest reconciliation. Use +`domstack-manifest.settings.*` `exclude` or `includeEntry(entry)` to keep entries such as source maps, admin +routes, or blog pages out of the written manifest before the service worker sees it. + +Watch mode builds and rebundles site service-worker entries, but it does not write +`domstack-manifest.json` or return `results.domstackManifest`. Use one-shot builds when testing +service-worker and PWA cache behavior. + +Domstack does not clean the destination directory before a build. During this preview, run clean +builds for service-worker lifecycle testing and deployments so removed or renamed preview outputs +(such as `/service-worker.js` or a custom manifest filename) cannot remain as stale files. + ## Global Assets There are a few important (and optional) global assets that live anywhere in the `src` directory. If duplicate named files that match the global asset file name pattern are found, a build error will occur until the duplicate file error is resolved. @@ -1097,6 +1327,9 @@ export const browser = { ``` The exported object is passed to esbuild's [`define`](https://esbuild.github.io/api/#define) options and is available to every js bundle. +Domstack also reserves `process.env.DOMSTACK_MANIFEST_URL`, +`process.env.DOMSTACK_MANIFEST_ENABLED`, `process.env.DOMSTACK_SERVICE_WORKER_URL`, and +`process.env.DOMSTACK_SERVICE_WORKER_SCOPE` for generated build facts. > [!WARNING] > Setting `define` in [`esbuild.settings.ts`](#esbuild-settingsts) while also using the `browser` export will throw an error. Use one or the other. @@ -1180,6 +1413,47 @@ Use `GlobalDataFunction` or `AsyncGlobalDataFunction` to type the function **`renderInnerPage()` is available.** `global.data.js` runs after page initialization has been attempted, and receives `PageData` instances (some may be uninitialized if they failed to initialize), so you can call `renderInnerPage()` here with the same care described above for `page.vars` and other page-dependent access. For examples and performance guidance, see [Accessing rendered page content](#accessing-rendered-page-content). +### `domstack-manifest.settings.ts` + +This is an optional file you can create anywhere. +It should export a default object or a default sync/async function that returns an object. +Use this to filter the domstack manifest before domstack writes `domstack-manifest.json` +or returns `results.domstackManifest`. + +```js +/** + * @import { DomstackManifestEntry } from '@domstack/static' + */ + +export default { + filename: 'metadata/domstack-manifest.json', + exclude: [ + 'admin/**', + '**/*.map', + ], + includeEntry, +} + +/** + * @param {DomstackManifestEntry} entry + */ +function includeEntry (entry) { + return entry.kind !== 'metadata' +} +``` + +The supported settings are: + +- `filename` - custom destination-relative manifest filename. Defaults to `domstack-manifest.json`. +- `exclude` - ignore-style patterns matched against `entry.url` and `entry.outputRelname`. +- `includeEntry(entry)` - a sync or async function that receives a public `DomstackManifestEntry` and returns `true` to keep it. + +The `domstackManifest.filename` option and `--customDomstackManifestName` CLI flag override a +settings-file `filename`. The `domstackManifest.exclude` option and `domstack-manifest.settings.*` +`exclude` values are combined. Excludes run before `includeEntry(entry)`. +Watch mode builds and rebundles service workers, but it does not write or return the domstack manifest, +so `domstack-manifest.settings.*` is only applied during one-shot builds. + ### `esbuild.settings.ts` This is an optional file you can create anywhere. @@ -1643,7 +1917,7 @@ The following diagram illustrates the DomStack build process: │ │ │ │ │ │ │ • Bundle JS/CSS │ │ • Copy static │ │ • Copy extra │ │ • Generate │ │ files │ │ directories │ -│ metafile │ │ (if enabled) │ │ from opts │ +│ records │ │ • Record files │ │ • Record files │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ └───────────────────┼───────────────────┘ @@ -1656,6 +1930,13 @@ The following diagram illustrates the DomStack build process: │ • Process MD │ │ • Process JS │ │ • Apply layouts │ + │ • Record outputs │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Reconcile │ + │ Output Manifest │ └────────┬─────────┘ │ ▼ @@ -1667,6 +1948,7 @@ The following diagram illustrates the DomStack build process: │ • staticResults │ │ • copyResults │ │ • pageResults │ + │ • domstackManifest │ │ • warnings │ └──────────────────┘ ``` @@ -1675,11 +1957,13 @@ The build process follows these key steps: 1. **Page identification** - Scans the source directory to identify all pages, layouts, templates, and global assets 2. **Destination preparation** - Ensures the destination directory is ready for the build output -3. **Parallel asset processing** - Three operations run concurrently: +3. **Parallel asset processing** - Three operations run concurrently and record their outputs: - JavaScript and CSS bundling via esbuild - Static file copying (when enabled) - Additional directory copying (from `--copy` options) -4. **Page building** - Processes all pages, applying layouts and generating final HTML +4. **Page building** - Processes pages and normal templates, applying layouts and recording outputs +5. **Manifest reconciliation** - Normalizes recorded outputs, hashes file contents, filters entries, and computes a stable manifest version +6. **Return results** - Writes the manifest when enabled and returns all build results This architecture allows for efficient parallel processing of independent tasks while maintaining the correct build order dependencies. @@ -1758,6 +2042,10 @@ When you run `domstack --watch` (or `domstack -w`), domstack performs an initial **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. +Domstack manifests are build-only artifacts. Watch mode builds and rebundles site service-worker +entries, but it does not write `domstack-manifest.json` or return `results.domstackManifest`. +Use `domstack --serve` when testing PWA cache lifecycle behavior locally: it runs a normal manifest-enabled build and serves the result without watch-mode filenames or live-reload HTML injection, so fetched files match the domstack manifest revisions. + #### What triggers what | Change | Rebuild scope | @@ -1771,7 +2059,8 @@ When you run `domstack --watch` (or `domstack -w`), domstack performs an initial | `markdown-it.settings.*` | All `.md` pages | | `global.data.*` | All pages and templates | | `global.vars.*` or `esbuild.settings.*` | Full rebuild (esbuild restart + all pages) | -| `client.js`, `style.css`, `*.layout.css`, `*.layout.client.*`, `global.client.*`, `global.css`, `*.worker.*` | esbuild handles it — no page rebuild | +| `domstack-manifest.settings.*` | No rebuild in watch mode; domstack manifests are only generated in one-shot builds | +| `client.js`, `style.css`, `*.layout.css`, `*.layout.client.*`, `global.client.*`, `global.css`, `*.worker.*`, `service-worker.*` | esbuild handles it — no page rebuild | | Adding or removing an esbuild entry point (e.g. creating a new `client.js`) | esbuild restart + only the affected page(s) | | Adding or removing any other file | Full rebuild | diff --git a/bin.js b/bin.js index e5cdbcfa..688ab151 100755 --- a/bin.js +++ b/bin.js @@ -4,10 +4,11 @@ * @import { BuildStepWarnings, DomStackOpts as DomStackOpts } from './lib/builder.js' * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts' * @import { Logger as PinoLogger } from 'pino' + * @import { BsInstance } from '@domstack/sync' */ import { readFile } from 'node:fs/promises' -import { resolve, join, relative } from 'node:path' +import { basename, resolve, join, relative } from 'node:path' import { parseArgs } from 'node:util' import { printHelpText } from 'argsclopts' import readline from 'node:readline' @@ -15,6 +16,7 @@ import process from 'process' // @ts-expect-error import tree from 'pretty-tree' import { inspect } from 'util' +import { createServer } from '@domstack/sync' import { packageDirectory } from 'package-directory' import { readPackage } from 'read-pkg' import { addPackageDependencies } from 'write-package' @@ -58,15 +60,18 @@ const options = { help: 'Build draft pages with the `.draft.{md,js,html}` page suffix.', default: false }, - target: { - type: 'string', - short: 't', - help: 'comma separated target strings for esbuild', - }, noEsbuildMeta: { type: 'boolean', help: 'skip writing the esbuild metafile to disk', }, + customDomstackManifestName: { + type: 'string', + help: 'custom domstack manifest filename (default: domstack-manifest.json)', + }, + noDomstackManifest: { + type: 'boolean', + help: 'disable writing the domstack manifest to disk', + }, eject: { type: 'boolean', short: 'e', @@ -81,6 +86,14 @@ const options = { type: 'boolean', help: 'watch and build the src folder without serving', }, + serve: { + type: 'boolean', + help: 'build once and serve the destination directory without watching', + }, + port: { + type: 'string', + help: 'port for --serve (default: 3000)', + }, copy: { type: 'string', help: 'path to directories to copy into dist; can be used multiple times', @@ -205,8 +218,17 @@ domstack eject actions: const opts = {} if (argv['ignore']) opts.ignore = String(argv['ignore']).split(',') - if (argv['target']) opts.target = String(argv['target']).split(',') if (argv['noEsbuildMeta']) opts.metafile = false + if (argv['noDomstackManifest'] && argv['customDomstackManifestName']) { + throw new Error('--customDomstackManifestName cannot be combined with --noDomstackManifest') + } + if (argv['noDomstackManifest']) opts.domstackManifest = false + if (argv['customDomstackManifestName']) { + opts.domstackManifest = { + ...(typeof opts.domstackManifest === 'object' ? opts.domstackManifest : {}), + filename: String(argv['customDomstackManifestName']), + } + } if (argv['drafts']) opts.buildDrafts = true if (argv['copy']) { const copyPaths = Array.isArray(argv['copy']) ? argv['copy'] : [argv['copy']] @@ -217,6 +239,16 @@ domstack eject actions: const logger = createDomStackLogger() opts.logger = logger const domStack = new DomStack(src, dest, opts) + /** @type {BsInstance | null} */ + let buildServer = null + + if (argv['serve'] && (argv['watch'] || argv['watch-only'])) { + throw new Error('--serve cannot be combined with --watch or --watch-only') + } + if (argv['port'] && !argv['serve']) { + throw new Error('--port can only be combined with --serve') + } + const servePort = argv['port'] ? parsePort(String(argv['port'])) : undefined process.once('SIGINT', quit) process.once('SIGTERM', quit) @@ -226,6 +258,11 @@ domstack eject actions: await domStack.stopWatching() logger.info('Watching stopped') } + if (buildServer) { + await buildServer.exit() + buildServer = null + logger.info('Server stopped') + } logger.info('Quitting cleanly') process.exit(0) } @@ -236,6 +273,16 @@ domstack eject actions: logger.info(tree(generateTreeData(cwd, src, dest, results))) logWarnings(logger, results?.warnings) logger.info('\nBuild Success!\n\n') + if (argv['serve']) { + buildServer = await createServer({ + server: dest, + files: basename(dest), + logger: logger.child({ component: 'sync', logPrefix: '[domstack-sync]' }), + ...(servePort ? { port: servePort } : {}), + snippet: false, + }) + logger.info(`Serving ${relative(cwd, dest)} without watching. Press Ctrl-C to stop.`) + } } catch (err) { if (!(err instanceof Error || err instanceof AggregateError)) throw new Error('Non-error thrown', { cause: err }) if (err instanceof DomStackAggregateError) { @@ -259,6 +306,17 @@ domstack eject actions: } } +/** + * @param {string} value + */ +function parsePort (value) { + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('--port must be an integer between 1 and 65535') + } + return port +} + /** * @param {PinoLogger} logger * @param {BuildStepWarnings | undefined} warnings diff --git a/docs/v12-migration.md b/docs/v12-migration.md index 89f1f6aa..85744e1b 100644 --- a/docs/v12-migration.md +++ b/docs/v12-migration.md @@ -12,7 +12,8 @@ Then apply the v12 changes below. 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) +6. [CLI `--target` moved to `esbuild.settings.*`](#6-cli---target-moved-to-esbuildsettings) +7. [Migration Checklist](#7-migration-checklist) --- @@ -123,7 +124,28 @@ export default async function esbuildSettingsOverride (esbuildSettings) { --- -## 6. Migration Checklist +## 6. CLI `--target` moved to `esbuild.settings.*` + +The `domstack --target` / `domstack -t` CLI flag has been removed in v12. Configure esbuild targets in +`esbuild.settings.*` instead. + +```js +// src/esbuild.settings.js +export default function esbuildSettings (opts) { + return { + ...opts, + target: ['es2022', 'chrome120', 'firefox121', 'safari17'], + } +} +``` + +Domstack does not set a rolling “modern browser” target by default. If your project needs specific +syntax lowering, set explicit esbuild targets in this settings file. See +[esbuild's target docs](https://esbuild.github.io/api/#target) for accepted values. + +--- + +## 7. 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`. @@ -132,4 +154,5 @@ export default async function esbuildSettingsOverride (esbuildSettings) { - [ ] 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`. - [ ] If you want your ejected server-side layout to match the v12 default, migrate its templates to `fragtml` and install `fragtml`. - [ ] If you use `.jsx` or `.tsx` browser clients, add an `esbuild.settings` file that configures your JSX runtime. +- [ ] If you use `domstack --target` or `domstack -t`, move that target list to `esbuild.settings.*`. - [ ] If you use Preact browser clients, keep `preact` in your project dependencies. diff --git a/examples/basic/package.json b/examples/basic/package.json index 0c33dec8..9f112a17 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -1,5 +1,5 @@ { - "name": "basic", + "name": "@domstack/basic-example", "version": "0.0.0", "description": "", "type": "module", @@ -23,5 +23,8 @@ "fragtml": "^0.0.9", "mine.css": "^9.0.1", "highlight.js": "^11.9.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/blog/package.json b/examples/blog/package.json index 468ec337..f5af9422 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -1,5 +1,5 @@ { - "name": "blog", + "name": "@domstack/blog-example", "version": "0.0.0", "description": "A blog example for domstack demonstrating global.data.ts, nested layouts, and feeds.", "type": "module", @@ -21,5 +21,8 @@ "@domstack/static": "file:../../.", "fragtml": "^0.0.9", "mine.css": "^10.0.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/css-modules/package.json b/examples/css-modules/package.json index 7a9b8ebb..9df02e35 100644 --- a/examples/css-modules/package.json +++ b/examples/css-modules/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/css-modules-example", "version": "0.0.0", "type": "module", "scripts": { @@ -21,5 +21,8 @@ }, "devDependencies": { "npm-run-all2": "^6.0.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/default-layout/package.json b/examples/default-layout/package.json index 8933b848..920ac3c5 100644 --- a/examples/default-layout/package.json +++ b/examples/default-layout/package.json @@ -1,5 +1,5 @@ { - "name": "default-layout", + "name": "@domstack/default-layout-example", "version": "0.0.0", "description": "", "main": "index.js", @@ -14,5 +14,8 @@ }, "keywords": [], "author": "Bret Comnes (https://bret.io/)", - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^22.0.0 || >=24.0.0" + } } diff --git a/examples/esbuild-settings/package.json b/examples/esbuild-settings/package.json index eb9ec3cc..daadfe10 100644 --- a/examples/esbuild-settings/package.json +++ b/examples/esbuild-settings/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/esbuild-settings-example", "version": "0.0.0", "type": "module", "scripts": { @@ -16,5 +16,8 @@ "devDependencies": { "esbuild-plugin-polyfill-node": "^0.3.0", "npm-run-all2": "^6.0.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/markdown-settings/package.json b/examples/markdown-settings/package.json index 563782ab..1e0192ba 100644 --- a/examples/markdown-settings/package.json +++ b/examples/markdown-settings/package.json @@ -1,5 +1,5 @@ { - "name": "markdown-settings-example", + "name": "@domstack/markdown-settings-example", "version": "1.0.0", "description": "Example demonstrating markdown-it.settings.js usage", "type": "module", @@ -14,5 +14,8 @@ "markdown-it-container": "^4.0.0", "markdown-it-admonition": "1.0.4", "highlight.js": "^11.9.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/markdown-settings/src/markdown-it.settings.js b/examples/markdown-settings/src/markdown-it.settings.js index 542f4df1..15855c85 100644 --- a/examples/markdown-settings/src/markdown-it.settings.js +++ b/examples/markdown-settings/src/markdown-it.settings.js @@ -1,4 +1,6 @@ /** + * @import MarkdownIt from 'markdown-it' + * * Custom Markdown-it Configuration * * This file demonstrates how to extend DOMStack's markdown rendering @@ -44,8 +46,8 @@ function createContainer (name, defaultTitle, cssClass) { /** * Customize the markdown-it instance with additional plugins and renderers * - * @param {import('markdown-it')} md - The markdown-it instance - * @returns {import('markdown-it')} - The modified markdown-it instance + * @param {MarkdownIt} md - The markdown-it instance + * @returns {Promise} - The modified markdown-it instance */ export default async function markdownItSettingsOverride (md) { // ===================================================== diff --git a/examples/nested-dest/package.json b/examples/nested-dest/package.json index 5b42a6ae..93118e01 100644 --- a/examples/nested-dest/package.json +++ b/examples/nested-dest/package.json @@ -1,5 +1,5 @@ { - "name": "nested-dest", + "name": "@domstack/nested-dest-example", "version": "0.0.0", "description": "", "type": "module", @@ -19,5 +19,8 @@ }, "devDependencies": { "npm-run-all2": "^6.0.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/preact-isomorphic/package.json b/examples/preact-isomorphic/package.json index f5ce3582..84f520d8 100644 --- a/examples/preact-isomorphic/package.json +++ b/examples/preact-isomorphic/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/preact-isomorphic-example", "version": "0.0.0", "type": "module", "scripts": { @@ -23,5 +23,8 @@ "@voxpelli/tsconfig": "^15.0.0", "npm-run-all2": "^6.0.0", "typescript": "~5.8.2" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/react/package.json b/examples/react/package.json index a4d06664..706a0dba 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -25,5 +25,8 @@ "react-dom": "^19.1.1", "typescript": "~5.8.2", "highlight.js": "^11.9.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/string-layouts/package.json b/examples/string-layouts/package.json index f0fe0cb5..28d6f6d1 100644 --- a/examples/string-layouts/package.json +++ b/examples/string-layouts/package.json @@ -1,5 +1,5 @@ { - "name": "string-layouts", + "name": "@domstack/string-layouts-example", "version": "0.0.0", "type": "module", "scripts": { @@ -12,5 +12,8 @@ "license": "MIT", "dependencies": { "@domstack/static": "file:../../." + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/tailwind/package.json b/examples/tailwind/package.json index 8438b5c2..44903f29 100644 --- a/examples/tailwind/package.json +++ b/examples/tailwind/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/tailwind-example", "version": "0.0.0", "type": "module", "scripts": { @@ -21,5 +21,8 @@ "npm-run-all2": "^6.0.0", "preact": "^10.24.0", "preact-render-to-string": "^6.5.11" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/tailwind/src/globals/esbuild.settings.js b/examples/tailwind/src/globals/esbuild.settings.js index 171da22a..2aaee97e 100644 --- a/examples/tailwind/src/globals/esbuild.settings.js +++ b/examples/tailwind/src/globals/esbuild.settings.js @@ -1,4 +1,6 @@ /** + * @import { BuildOptions } from 'esbuild' + * * Tailwind CSS Integration for DOMStack * * This file configures ESBuild to process Tailwind CSS in your project. @@ -9,8 +11,8 @@ import tailwindPlugin from 'esbuild-plugin-tailwindcss' /** * Configure ESBuild settings to include Tailwind CSS processing * - * @param {import('esbuild').BuildOptions} esbuildSettings - The default ESBuild configuration - * @return {Promise} - The modified ESBuild configuration + * @param {BuildOptions} esbuildSettings - The default ESBuild configuration + * @return {Promise} - The modified ESBuild configuration */ export default async function esbuildSettingsOverride (esbuildSettings) { // Add the Tailwind plugin to the ESBuild configuration diff --git a/examples/tailwind/src/layouts/root.layout.js b/examples/tailwind/src/layouts/root.layout.js index 76167838..6514b8a1 100644 --- a/examples/tailwind/src/layouts/root.layout.js +++ b/examples/tailwind/src/layouts/root.layout.js @@ -1,12 +1,10 @@ +/** + * @import { LayoutFunction } from '@domstack/static' + */ // @ts-ignore import { html } from 'htm/preact' import { render } from 'preact-render-to-string' -/** - * @template {Record} T - * @typedef {import('../build-pages/resolve-layout.js').LayoutFunction} LayoutFunction - */ - /** * Global layout with Tailwind container styles * diff --git a/examples/type-stripping/package.json b/examples/type-stripping/package.json index f5ce3582..cee07ab7 100644 --- a/examples/type-stripping/package.json +++ b/examples/type-stripping/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/type-stripping-example", "version": "0.0.0", "type": "module", "scripts": { @@ -23,5 +23,8 @@ "@voxpelli/tsconfig": "^15.0.0", "npm-run-all2": "^6.0.0", "typescript": "~5.8.2" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/uhtml-isomorphic/package.json b/examples/uhtml-isomorphic/package.json index 51826e5f..42416dfa 100644 --- a/examples/uhtml-isomorphic/package.json +++ b/examples/uhtml-isomorphic/package.json @@ -14,9 +14,12 @@ "highlight.js": "^11.9.0", "mine.css": "^9.0.1", "uhtml-isomorphic": "^2.1.0", - "@domstack/static": "../../." + "@domstack/static": "file:../../." }, "devDependencies": { "npm-run-all2": "^6.0.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/uhtml-isomorphic/src/layouts/root.layout.js b/examples/uhtml-isomorphic/src/layouts/root.layout.js index 72cf92a3..2b15aec3 100644 --- a/examples/uhtml-isomorphic/src/layouts/root.layout.js +++ b/examples/uhtml-isomorphic/src/layouts/root.layout.js @@ -1,9 +1,7 @@ -import { html, render } from 'uhtml-isomorphic' - /** - * @template {Record} T - * @typedef {import('../build-pages/resolve-layout.js').LayoutFunction} LayoutFunction + * @import { LayoutFunction } from '@domstack/static' */ +import { html, render } from 'uhtml-isomorphic' /** * Build all of the bundles using esbuild. diff --git a/examples/worker-example/package.json b/examples/worker-example/package.json index 44d8b27f..8eee00eb 100644 --- a/examples/worker-example/package.json +++ b/examples/worker-example/package.json @@ -9,7 +9,11 @@ "clean": "rm -rf public && mkdir -p public", "watch": "npm run clean && dom --watch" }, - "keywords": ["domstack", "web-workers", "static-site-generator"], + "keywords": [ + "domstack", + "web-workers", + "static-site-generator" + ], "author": "", "license": "MIT", "dependencies": { @@ -18,5 +22,8 @@ }, "devDependencies": { "npm-run-all2": "^6.0.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/index.js b/index.js index 0325027f..8d50d4a9 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ * @import { TestBuildResult } from './types.js' * @import { BsInstance } from '@domstack/sync' * @import { Logger as PinoLogger } from 'pino' + * @import { DomstackManifestRecord } from './lib/domstack-manifest/index.js' */ import { once } from 'events' import assert from 'node:assert' @@ -37,12 +38,14 @@ import { globalDataNames, esbuildSettingsNames, markdownItSettingsNames, + domstackManifestSettingsNames, pageClientNames, layoutClientSuffixs, globalClientNames, globalStyleNames, pageStyleName, pageWorkerSuffixs, + serviceWorkerNames, } from './lib/identify-pages.js' import { resolveVars } from './lib/build-pages/resolve-vars.js' import { ensureDest } from './lib/helpers/ensure-dest.js' @@ -50,6 +53,16 @@ import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.j import { createDomStackLogger } from './lib/logger.js' export { PageData } from './lib/build-pages/page-data.js' +export { + DOMSTACK_MANIFEST_SCHEMA_ID, + DOMSTACK_MANIFEST_SCHEMA_PATH, + domstackManifestEntryPageMetaSchema, + domstackManifestEntrySchema, + domstackManifestKindSchema, + buildDomstackManifest, + domstackManifestSchema, + getDomstackManifestSchemaId, +} from './lib/domstack-manifest/index.js' const DEFAULT_IGNORES = /** @type {const} */ ([ '.*', @@ -171,7 +184,15 @@ export class DomStack { // Build pages (initial full build) let report try { - const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, this.opts) + const pageBuildResults = await buildPages(this.#src, this.#dest, siteData, { + ...this.opts, + }) + if (pageBuildResults.errors.length > 0) { + throw new DomStackAggregateError(pageBuildResults.errors, 'Page build finished but there were errors.', { + siteData, + pageBuildResults, + }) + } report = { warnings: [...siteData.warnings, ...pageBuildResults.warnings], siteData, @@ -256,8 +277,8 @@ export class DomStack { }) } - const enqueue = (/** @type {() => Promise} */ fn) => { - this.#buildLock = this.#buildLock.then(() => fn().catch(err => errorLogger(err, this.#logger))) + const enqueue = (/** @type {() => Promise} */ fn) => { + this.#enqueueBuild(fn) } watcher.on('add', path => { @@ -316,6 +337,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) */ async #handleAddUnlink (changedPath, event) { const changedBasename = basename(changedPath) + const changedDir = relative(this.#src, dirname(changedPath)) // Check if this is an esbuild entry point by basename pattern const isEsbuildEntry = ( @@ -323,6 +345,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) layoutClientSuffixs.some(s => changedBasename.endsWith(s)) || changedBasename.endsWith(layoutStyleSuffix) || pageWorkerSuffixs.some(s => changedBasename.endsWith(s)) || + serviceWorkerNames.includes(changedBasename) || globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename) || changedBasename === pageStyleName @@ -350,9 +373,10 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) this.#siteData = siteData // Determine which pages are affected by this entry point change - const changedDir = relative(this.#src, dirname(changedPath)) - - if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) { + if (serviceWorkerNames.includes(changedBasename)) { + // Service workers are site-level esbuild entries and do not affect page HTML. + this.#logger.info(`"${changedBasename}" ${event}, no page rebuild needed.`) + } else if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) { // Global asset: rebuild all pages logRebuildTree(changedBasename, this.#logger, new Set(siteData.pages)) await this.#runPageBuild(siteData) @@ -409,17 +433,37 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) ...(pageFilterPaths ? { pageFilterPaths } : {}), ...(templateFilterPaths ? { templateFilterPaths } : {}), }) + if (pageBuildResults.errors.length > 0) { + throw new DomStackAggregateError(pageBuildResults.errors, 'Page build finished but there were errors.', { + siteData, + pageBuildResults, + }) + } const isFiltered = pageFilterPaths !== null || templateFilterPaths !== null buildLogger( isFiltered ? pageBuildResults : { warnings: pageBuildResults.warnings, siteData, pageBuildResults }, this.#logger, isFiltered ? this.#dest : undefined ) + return pageBuildResults } catch (err) { errorLogger(err, this.#logger) } } + /** + * @param {() => Promise} fn + */ + #enqueueBuild (fn) { + this.#buildLock = this.#buildLock.then(async () => { + try { + await fn() + } catch (err) { + errorLogger(err, this.#logger) + } + }) + } + /** * Build and maintain the six watch maps from siteData. * `find()` returns CWD-relative paths; we resolve them to absolute for map keys. @@ -520,6 +564,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) const esbuildEntryPoints = /** @type {Set} */ (new Set()) if (siteData.globalClient) esbuildEntryPoints.add(resolve(siteData.globalClient.filepath)) if (siteData.globalStyle) esbuildEntryPoints.add(resolve(siteData.globalStyle.filepath)) + if (siteData.serviceWorker) esbuildEntryPoints.add(resolve(siteData.serviceWorker.filepath)) for (const page of siteData.pages) { if (page.clientBundle) esbuildEntryPoints.add(resolve(page.clientBundle.filepath)) if (page.pageStyle) esbuildEntryPoints.add(resolve(page.pageStyle.filepath)) @@ -577,6 +622,13 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) return this.#runPageBuild(siteData, Array.from(mdPages).map(p => p.pageFile.filepath), []) } + // domstack-manifest.settings.* only affects one-shot domstack manifest generation. + // Watch mode intentionally does not write or return a domstack manifest. + if (domstackManifestSettingsNames.some(n => changedBasename === n)) { + this.#logger.info(`"${changedBasename}" changed but domstack manifests are disabled in watch mode, skipping.`) + return + } + // 6. esbuild entry point (client.js, style.css, .layout.css, .layout.client.*, *.worker.*, global.client.*, global.css) // esbuild's own watcher handles these. Stable filenames mean page HTML doesn't // change, so no page rebuild is needed. @@ -787,24 +839,44 @@ function buildLogger (results, logger, dest) { // Full build: show site totals const layoutCount = Object.keys(results.siteData.layouts).length logger.info(`Pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) - const report = results.pageBuildResults?.report - if (report) { - logger.info(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + const outputs = results.pageBuildResults?.report?.outputs + if (outputs) { + const summary = summarizePageDomstackManifests(outputs) + logger.info(`Pages built: ${summary.pages} Templates built: ${summary.templates}`) } } else if ('report' in results && results.report) { // Filtered build: show what was actually built const report = results.report + const outputs = report.outputs ?? [] if (dest) { - for (const p of report.pages) { - logger.info(` Built ${relative(dest, p.pageFilePath)}`) - } - for (const t of report.templates) { - for (const outputFile of t.outputs ?? []) { - logger.info(` Built ${outputFile}`) + for (const output of outputs) { + if (output.kind === 'page' || output.kind === 'template') { + logger.info(` Built ${relative(dest, output.filepath)}`) } } } - logger.info(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + const summary = summarizePageDomstackManifests(outputs) + logger.info(`Pages built: ${summary.pages} Templates built: ${summary.templates}`) } logger.info('\nBuild Success!\n\n') } + +/** + * @param {DomstackManifestRecord[]} outputs + */ +function summarizePageDomstackManifests (outputs) { + const templateSources = new Set() + let pages = 0 + + for (const output of outputs) { + if (output.kind === 'page') pages += 1 + if (output.kind === 'template') { + templateSources.add(output.sourceRelname ?? output.templatePath ?? output.outputRelname) + } + } + + return { + pages, + templates: templateSources.size, + } +} diff --git a/lib/build-copy/index.js b/lib/build-copy/index.js index 693e8bbb..16a3a9b3 100644 --- a/lib/build-copy/index.js +++ b/lib/build-copy/index.js @@ -1,14 +1,16 @@ /** * @import { BuildStepResult, BuildStep } from '../builder.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' */ import { copy } from 'cpx2' import { join } from 'node:path' +import { createCopiedDomstackManifestRecords } from '../domstack-manifest/index.js' /** - * @typedef {BuildStepResult<'static', CopyBuilderReport>} CopyBuildStepResult - * @typedef {BuildStep<'static', CopyBuilderReport>} CopyBuildStep - * @typedef {Awaited>} CopyBuilderReport + * @typedef {BuildStepResult<'copy', CopyBuilderReport>} CopyBuildStepResult + * @typedef {BuildStep<'copy', CopyBuilderReport>} CopyBuildStep + * @typedef {{ outputs: DomstackManifestRecord[] }} CopyBuilderReport */ /** @@ -28,8 +30,8 @@ export function getCopyDirs (copy = []) { export async function buildCopy (_src, dest, _siteData, opts) { /** @type {CopyBuildStepResult} */ const results = { - type: 'static', - report: {}, + type: 'copy', + report: { outputs: [] }, errors: [], warnings: [], } @@ -42,14 +44,17 @@ export async function buildCopy (_src, dest, _siteData, opts) { const settled = await Promise.allSettled(copyTasks) - for (const [index, result] of Object.entries(settled)) { - // @ts-expect-error - const copyDir = copyDirs[index] + for (const result of settled) { if (result.status === 'rejected') { const buildError = new Error('Error copying copy folders', { cause: result.reason }) results.errors.push(buildError) } else { - /** @type {Record} */ (results.report)[copyDir] = result.value + results.report.outputs.push(...createCopiedDomstackManifestRecords({ + src: _src, + dest, + report: result.value, + kind: 'copy', + })) } } return results diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index 132cb5e6..7b5df642 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -1,28 +1,34 @@ /** * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' */ import { writeFile } from 'fs/promises' -import { join, relative, basename } from 'path' +import { join, relative, basename, resolve } from 'path' import esbuild from 'esbuild' import { resolveVars } from '../build-pages/resolve-vars.js' +import { + classifyEsbuildOutput, + createDomstackManifestRecord, + getDomstackManifestFilename, + shouldWriteDomstackManifest, + toPosix, +} from '../domstack-manifest/index.js' const __dirname = import.meta.dirname const DOM_STACK_DEFAULTS_PREFIX = 'domstack-defaults' +const SERVICE_WORKER_OUTPUT_RELNAME = 'service-worker.js' /** * @typedef {esbuild.Format} EsbuildFormat * @typedef {esbuild.LogLevel} EsbuildLogLevel * @typedef {{[relpath: string]: string}} OutputMap * @typedef {esbuild.BuildOptions} EsbuildBuildOptions - * @typedef {Awaited>} EsbuildBuildResults * @typedef {BuildStep< * 'esbuild', * { - * buildResults?: EsbuildBuildResults - * buildOpts?: EsbuildBuildOptions, - * outputMap?: OutputMap + * outputs: DomstackManifestRecord[] * } * >} EsBuildStep */ @@ -39,13 +45,13 @@ const DOM_STACK_DEFAULTS_PREFIX = 'domstack-defaults' * @param {string} dest * @returns {OutputMap} */ -function extractOutputMap (metafile, src, dest) { +export function extractOutputMap (metafile, src, dest) { /** @type {OutputMap} */ const outputMap = {} Object.keys(metafile.outputs).forEach(file => { const entryPoint = metafile.outputs[file]?.entryPoint if (entryPoint) { - outputMap[relative(src, entryPoint)] = relative(dest, file) + outputMap[toPosix(relative(src, entryPoint))] = toPosix(relative(dest, file)) } }) return outputMap @@ -102,6 +108,14 @@ function updateSiteDataOutputPaths (outputMap, siteData) { } } + if (siteData.serviceWorker) { + const outputRelname = outputMap[siteData.serviceWorker.relname] + if (outputRelname) { + siteData.serviceWorker.outputRelname = outputRelname + siteData.serviceWorker.outputName = basename(outputRelname) + } + } + for (const layout of Object.values(siteData.layouts)) { if (layout.layoutStyle) { const outputRelname = outputMap[layout.layoutStyle.relname] @@ -143,6 +157,16 @@ async function assembleBuildOpts (src, dest, siteData, opts, modeOpts = {}) { const entryPoints = [] if (siteData.globalClient) entryPoints.push(join(src, siteData.globalClient.relname)) if (siteData.globalStyle) entryPoints.push(join(src, siteData.globalStyle.relname)) + if (modeOpts.watch && siteData.serviceWorker) { + // The source may live anywhere under src, but the site service worker emits + // at /service-worker.js so it gets root scope without Service-Worker-Allowed + // headers. Production uses a separate stable-name build below because normal + // production entry names are content-hashed; watch keeps it in the live context. + entryPoints.push({ + in: join(src, siteData.serviceWorker.relname), + out: 'service-worker', + }) + } if (siteData.defaultLayout) { entryPoints.push( { in: join(__dirname, '../defaults/default.style.css'), out: join(DOM_STACK_DEFAULTS_PREFIX, 'default.style.css') }, @@ -171,18 +195,22 @@ async function assembleBuildOpts (src, dest, siteData, opts, modeOpts = {}) { key: 'browser', }) - /** @type {{ [varName: string]: any }} */ - const define = {} + const target = Array.isArray(opts?.target) ? opts.target : [] + + const watch = modeOpts.watch ?? false + /** @type {{ [varName: string]: string }} */ + const domstackDefines = createDomstackDefines({ opts, siteData, watch }) + /** @type {{ [varName: string]: string }} */ + const define = { ...domstackDefines } if (browserVars) { for (const [k, v] of Object.entries(browserVars)) { + if (Object.hasOwn(define, k)) { + throw new Error(`Conflict: "${k}" is reserved by domstack.`) + } define[k] = JSON.stringify(v) } } - const target = Array.isArray(opts?.target) ? opts.target : [] - - const watch = modeOpts.watch ?? false - /** @type {esbuild.BuildOptions} */ const buildOpts = { entryPoints, @@ -233,7 +261,32 @@ async function assembleBuildOpts (src, dest, siteData, opts, modeOpts = {}) { ) } - return extendedBuildOpts + return { + ...extendedBuildOpts, + define: preserveDomstackDefines(extendedBuildOpts.define, domstackDefines), + } +} + +/** + * Keep domstack-owned browser build facts available even when esbuild.settings + * replaces the define object. User settings may add custom defines, but they may + * not override domstack's reserved DOMSTACK_* values. + * + * @param {esbuild.BuildOptions['define']} define + * @param {{ [varName: string]: string }} domstackDefines + * @returns {{ [varName: string]: string }} + */ +function preserveDomstackDefines (define, domstackDefines) { + const mergedDefine = { ...(define ?? {}) } + + for (const [key, value] of Object.entries(domstackDefines)) { + if (Object.hasOwn(mergedDefine, key) && mergedDefine[key] !== value) { + throw new Error(`Conflict: "${key}" is reserved by domstack.`) + } + mergedDefine[key] = value + } + + return mergedDefine } /** @@ -245,25 +298,33 @@ export async function buildEsbuild (src, dest, siteData, opts) { try { const extendedBuildOpts = await assembleBuildOpts(src, dest, siteData, opts, { watch: false }) - // @ts-ignore This actually works fine const buildResults = await esbuild.build(extendedBuildOpts) - - if (buildResults.metafile) { - await writeFile(join(dest, 'domstack-esbuild-meta.json'), JSON.stringify(buildResults.metafile, null, ' ')) + const serviceWorkerBuildOpts = createServiceWorkerBuildOpts({ buildOpts: extendedBuildOpts, src, siteData }) + const serviceWorkerBuildResults = serviceWorkerBuildOpts + ? await esbuild.build(serviceWorkerBuildOpts) + : undefined + const combinedBuildResults = mergeBuildResults(buildResults, serviceWorkerBuildResults) + + if (combinedBuildResults.metafile && opts?.metafile !== false) { + await writeFile(join(dest, 'domstack-esbuild-meta.json'), JSON.stringify(combinedBuildResults.metafile, null, ' ')) } - const outputMap = buildResults.metafile ? extractOutputMap(buildResults.metafile, src, dest) : {} + const outputMap = combinedBuildResults.metafile ? extractOutputMap(combinedBuildResults.metafile, src, dest) : {} updateSiteDataOutputPaths(outputMap, siteData) + const outputs = createEsbuildOutputRecords({ + src, + dest, + siteData, + buildResults: combinedBuildResults, + includeMetafileRecord: opts?.metafile !== false, + }) return { type: 'esbuild', - errors: buildResults.errors, - warnings: buildResults.warnings, + errors: combinedBuildResults.errors, + warnings: combinedBuildResults.warnings, report: { - buildResults, - outputMap, - // @ts-ignore This is fine - buildOpts: extendedBuildOpts, + outputs, }, } } catch (err) { @@ -273,11 +334,97 @@ export async function buildEsbuild (src, dest, siteData, opts) { new Error('Error building JS+CSS with esbuild', { cause: err }), ], warnings: [], - report: {}, + report: { + outputs: [], + }, } } } +/** + * Production entry filenames are content-hashed globally. Service workers need + * a stable root URL, so they get a tiny second build with a fixed entry name. + * Emitting at /service-worker.js also gives the worker root scope by default. + * + * @param {object} params + * @param {esbuild.BuildOptions} params.buildOpts + * @param {string} params.src + * @param {SiteData} params.siteData + * @returns {esbuild.BuildOptions | null} + */ +function createServiceWorkerBuildOpts ({ buildOpts, src, siteData }) { + if (!siteData.serviceWorker) return null + + return { + ...buildOpts, + entryPoints: [ + { + in: join(src, siteData.serviceWorker.relname), + out: 'service-worker', + }, + ], + entryNames: '[name]', + } +} + +/** + * Provide domstack-owned build facts to all browser-side bundles. + * + * @param {object} params + * @param {DomStackOpts | null} params.opts + * @param {SiteData} params.siteData + * @param {boolean} params.watch + */ +function createDomstackDefines ({ opts, siteData, watch }) { + const hasServiceWorker = Boolean(siteData.serviceWorker) + + return { + 'process.env.DOMSTACK_MANIFEST_URL': JSON.stringify(domstackManifestFilenameToUrl(getDomstackManifestFilename(opts ?? undefined))), + 'process.env.DOMSTACK_MANIFEST_ENABLED': JSON.stringify(String(!watch && shouldWriteDomstackManifest(opts ?? undefined))), + 'process.env.DOMSTACK_SERVICE_WORKER_URL': JSON.stringify(hasServiceWorker ? `/${SERVICE_WORKER_OUTPUT_RELNAME}` : ''), + 'process.env.DOMSTACK_SERVICE_WORKER_SCOPE': JSON.stringify(hasServiceWorker ? '/' : ''), + } +} + +/** + * Convert the configured domstack manifest filename into the public file URL. + * + * @param {string} filename + */ +function domstackManifestFilenameToUrl (filename) { + return `/${toPosix(filename).replace(/^\/+/, '')}` +} + +/** + * @param {...(esbuild.BuildResult | undefined)} results + * @returns {esbuild.BuildResult} + */ +function mergeBuildResults (...results) { + const buildResults = /** @type {esbuild.BuildResult[]} */ (results.filter(Boolean)) + const metafiles = /** @type {esbuild.Metafile[]} */ ( + buildResults.map(result => result.metafile).filter(Boolean) + ) + + return /** @type {esbuild.BuildResult} */ ({ + errors: buildResults.flatMap(result => result.errors), + warnings: buildResults.flatMap(result => result.warnings), + metafile: mergeMetafiles(...metafiles), + }) +} + +/** + * @param {...esbuild.Metafile} metafiles + * @returns {esbuild.Metafile | undefined} + */ +function mergeMetafiles (...metafiles) { + if (metafiles.length === 0) return undefined + + return { + inputs: Object.assign({}, ...metafiles.map(metafile => metafile.inputs)), + outputs: Object.assign({}, ...metafiles.map(metafile => metafile.outputs)), + } +} + /** * Create an esbuild watch context with stable (unhashed) output filenames. * Calls onEnd after each rebuild. Returns the context for disposal. @@ -287,18 +434,19 @@ export async function buildEsbuild (src, dest, siteData, opts) { * @param {SiteData} siteData * @param {DomStackOpts} opts * @param {{ onEnd?: (result: esbuild.BuildResult) => void }} [watchOpts] - * @returns {Promise<{ context: esbuild.BuildContext, outputMap: OutputMap }>} + * @returns {Promise<{ context: esbuild.BuildContext, outputMap: OutputMap, buildResults: esbuild.BuildResult, buildOpts: EsbuildBuildOptions }>} */ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = {}) { const extendedBuildOpts = await assembleBuildOpts(src, dest, siteData, opts, { watch: true }) + let startedWatching = false const plugins = extendedBuildOpts.plugins ?? [] /** @type {esbuild.Plugin} */ const onEndPlugin = { name: 'domstack-on-end', setup (build) { - build.onEnd(result => { + build.onEnd(async result => { if (result.errors.length > 0) { console.error('JS/CSS rebuild failed:') for (const err of result.errors) { @@ -307,7 +455,10 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = } else { console.log('JS/CSS rebuild complete.') } - if (watchOpts.onEnd) watchOpts.onEnd(result) + if (result.metafile && opts?.metafile !== false) { + await writeFile(join(dest, 'domstack-esbuild-meta.json'), JSON.stringify(result.metafile, null, ' ')) + } + if (startedWatching && watchOpts.onEnd) watchOpts.onEnd(result) }) } } @@ -320,7 +471,7 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = // Trigger initial build to get the metafile / outputMap const initialResult = await context.rebuild() - if (initialResult.metafile) { + if (initialResult.metafile && opts?.metafile !== false) { await writeFile(join(dest, 'domstack-esbuild-meta.json'), JSON.stringify(initialResult.metafile, null, ' ')) } @@ -329,6 +480,64 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = // Start watching — esbuild handles its own rebuild loop from here await context.watch() + startedWatching = true + + return { context, outputMap, buildResults: initialResult, buildOpts: extendedBuildOpts } +} + +/** + * @param {object} params + * @param {string} params.src + * @param {string} params.dest + * @param {SiteData} params.siteData + * @param {esbuild.BuildResult} params.buildResults + * @param {boolean} params.includeMetafileRecord + * @returns {DomstackManifestRecord[]} + */ +export function createEsbuildOutputRecords ({ src, dest, siteData, buildResults, includeMetafileRecord }) { + /** @type {DomstackManifestRecord[]} */ + const outputs = [] + const metafile = buildResults.metafile + if (!metafile) return outputs + + const workerOutputRelnames = new Set() + for (const page of siteData.pages) { + if (!page.workers) continue + for (const worker of Object.values(page.workers)) { + if (worker.outputRelname) workerOutputRelnames.add(toPosix(worker.outputRelname)) + } + } + const serviceWorkerOutputRelname = siteData.serviceWorker?.outputRelname + ? toPosix(siteData.serviceWorker.outputRelname) + : undefined + + for (const [outputPath, outputMeta] of Object.entries(metafile.outputs)) { + const filepath = resolve(outputPath) + const outputRelname = toPosix(relative(dest, filepath)) + const kind = classifyEsbuildOutput({ + outputRelname, + entryPoint: outputMeta.entryPoint, + workerOutputRelnames, + serviceWorkerOutputRelname, + }) + + outputs.push(createDomstackManifestRecord({ + dest, + filepath, + outputRelname, + kind, + entryPoint: outputMeta.entryPoint, + sourceRelname: outputMeta.entryPoint ? toPosix(relative(src, resolve(outputMeta.entryPoint))) : undefined, + })) + } + + if (includeMetafileRecord) { + outputs.push(createDomstackManifestRecord({ + dest, + outputRelname: 'domstack-esbuild-meta.json', + kind: 'metadata', + })) + } - return { context, outputMap } + return outputs } diff --git a/lib/build-pages/index.js b/lib/build-pages/index.js index 15498101..8e5d5da3 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -3,6 +3,7 @@ * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' * @import { PageInfo, TemplateInfo } from '../identify-pages.js' * @import { ResolvedLayout } from './page-data.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' */ import { Worker } from 'worker_threads' @@ -21,8 +22,7 @@ const __dirname = import.meta.dirname /** * @typedef {{ - * pages: Awaited>[] - * templates: Awaited>[] + * outputs: DomstackManifestRecord[] * }} PageBuilderReport */ @@ -171,19 +171,18 @@ export function buildPages (src, dest, siteData, opts) { * All layouts, variables and page builders need to resolve in here * so that it can be run more than once, after the source files change. * - * @param {string} src + * @param {string} _src * @param {string} dest * @param {SiteData} siteData * @param {DomStackOpts & BuildPagesOpts} [_opts] * @returns {Promise} */ -export async function buildPagesDirect (src, dest, siteData, _opts) { +export async function buildPagesDirect (_src, dest, siteData, _opts) { /** @type {WorkerBuildStepResult} */ const result = { type: 'page', report: { - pages: [], - templates: [], + outputs: [], }, errors: [], warnings: [], @@ -292,14 +291,13 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { await Promise.all([ pMap(pagesToRender, async (page) => { try { - const buildResult = await pageWriter({ - src, + const outputRecords = await pageWriter({ dest, page, pages, }) - result.report.pages.push(buildResult) + result.report.outputs.push(...outputRecords) } catch (err) { const buildError = new Error('Error building page', { cause: err }) // I can't put stuff on the error, the worker swallows it for some reason. @@ -308,14 +306,14 @@ export async function buildPagesDirect (src, dest, siteData, _opts) { }, { concurrency: dividedConcurrency[0] }), pMap(templatesToRender, async (template) => { try { - const buildResult = await templateBuilder({ + const outputRecords = await templateBuilder({ dest, globalVars: templateGlobalVars, template, pages, }) - result.report.templates.push(buildResult) + result.report.outputs.push(...outputRecords) } catch (err) { if (!(err instanceof Error)) throw new Error('Non-error thrown while building pages', { cause: err }) const buildError = new Error('Error building template', { cause: { message: err.message, stack: err.stack } }) diff --git a/lib/build-pages/page-builders/page-writer.js b/lib/build-pages/page-builders/page-writer.js index b286a712..387ff9be 100644 --- a/lib/build-pages/page-builders/page-writer.js +++ b/lib/build-pages/page-builders/page-writer.js @@ -1,10 +1,12 @@ /** * @import { PageInfo } from '../../identify-pages.js' * @import { PageData } from '../page-data.js' + * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js' */ import { join } from 'path' import { writeFile, mkdir } from 'fs/promises' +import { createDomstackManifestRecord } from '../../domstack-manifest/index.js' /** * @typedef {Object} BuilderOptions @@ -85,10 +87,10 @@ import { writeFile, mkdir } from 'fs/promises' * @template [U=any] U - The return type of the page function (defaults to any) * @template [V=string] V - The return type of the layout function (defaults to string) * @param {object} params - * @param {string} params.src - The src folder. * @param {string} params.dest - The dest folder. * @param {PageData} params.page - The PageInfo object of the current page * @param {PageData[]} params.pages - The PageInfo[] array of all pages + * @returns {Promise} */ export async function pageWriter ({ dest, @@ -103,6 +105,25 @@ export async function pageWriter ({ await mkdir(pageDir, { recursive: true }) await writeFile(pageFilePath, formattedPageOutput) + /** @type {DomstackManifestRecord[]} */ + const outputs = [ + createDomstackManifestRecord({ + dest, + filepath: pageFilePath, + outputRelname: page.pageInfo.outputRelname, + kind: 'page', + url: page.pageInfo.url, + sourceRelname: page.pageInfo.pageFile.relname, + pagePath: page.pageInfo.path, + pageUrl: page.pageInfo.url, + page: { + path: page.pageInfo.path, + url: page.pageInfo.url, + vars: extractPrecacheVars(page), + }, + }), + ] + // Generate meta.json with worker mappings if page has workers if (page.pageInfo?.workers) { /** @type { {[workerName: string]: string } } */ @@ -122,8 +143,28 @@ export async function pageWriter ({ const workersFilePath = join(pageDir, 'workers.json') const workersContent = JSON.stringify(workerMappings, null, 2) await writeFile(workersFilePath, workersContent) + outputs.push(createDomstackManifestRecord({ + dest, + filepath: workersFilePath, + outputRelname: join(page.pageInfo.path, 'workers.json'), + kind: 'worker-manifest', + pagePath: page.pageInfo.path, + pageUrl: page.pageInfo.url, + })) } } - return { pageFilePath } + return outputs +} + +/** + * @param {PageData} page + */ +function extractPrecacheVars (page) { + /** @type {{ precache?: unknown, offline?: unknown }} */ + const vars = {} + const source = /** @type {{ precache?: unknown, offline?: unknown }} */ (page.vars) + if (Object.hasOwn(source, 'precache')) vars.precache = source.precache + if (Object.hasOwn(source, 'offline')) vars.offline = source.offline + return vars } diff --git a/lib/build-pages/page-builders/template-builder.js b/lib/build-pages/page-builders/template-builder.js index 98ff90f6..9ebdd4bf 100644 --- a/lib/build-pages/page-builders/template-builder.js +++ b/lib/build-pages/page-builders/template-builder.js @@ -1,10 +1,13 @@ /** * @import { TemplateInfo } from '../../identify-pages.js' * @import { PageData } from '../page-data.js' + * @import { DomstackManifestRecord } from '../../domstack-manifest/index.js' */ import { join, resolve, dirname } from 'path' +import { relative, sep, isAbsolute } from 'node:path' import { writeFile, mkdir } from 'fs/promises' +import { createDomstackManifestRecord } from '../../domstack-manifest/index.js' /** @typedef {{ * outputName: string, @@ -26,10 +29,7 @@ import { writeFile, mkdir } from 'fs/promises' * * @template {Record} T - The type of variables for the template * @callback TemplateFunction - * @param {object} params - The parameters for the template. - * @param {T} params.vars - All of the site globalVars merged with global.data.js output. - * @param {TemplateInfo} params.template - Info about the current template - * @param {PageData[]} params.pages - An array of info about every page + * @param {TemplateFunctionParams} params - The parameters for the template. * @returns {Promise} * } - The results of a template build */ @@ -42,14 +42,6 @@ import { writeFile, mkdir } from 'fs/promises' * @returns {AsyncIterable} */ -/** - * @typedef TemplateReport - * - * @property {TemplateInfo} templateInfo - The input TemplateInfo object - * @property {string[]} outputs - Array of paths the template output to - * @property {'content'|'object'|'array'|'async-iterator'} [type] - The template return type - */ - /** * The template builder renders templates against the globalVars variables. * globalVars passed here already includes global.data.js output merged in. @@ -59,6 +51,7 @@ import { writeFile, mkdir } from 'fs/promises' * @param {T} params.globalVars - globalVars merged with global.data.js output. * @param {TemplateInfo} params.template - The TemplateInfo of the template. * @param {PageData[]} params.pages - The array of PageData object. + * @returns {Promise} */ export async function templateBuilder ({ dest, @@ -84,55 +77,62 @@ export async function templateBuilder ({ const templateResults = await renderTemplate(finalVars) const fileDir = join(dest, template.path) - const filePath = join(fileDir, template.outputName) - /** @type {TemplateReport} */ - const templateReport = { - templateInfo: template, - outputs: [], - type: 'content', - } + /** @type {DomstackManifestRecord[]} */ + const outputRecords = [] if (typeof templateResults === 'string') { - await mkdir(fileDir, { recursive: true }) - await writeFile(filePath, templateResults) - templateReport.outputs.push(template.outputName) - templateReport.type = 'content' + await writeTemplateOutput({ + dest, + fileDir, + outputName: template.outputName, + content: templateResults, + template, + outputRecords, + }) } else if ( Array.isArray(templateResults) && templateResults.every(item => 'outputName' in item && 'content' in item) ) { - templateReport.type = 'array' for (const templateResult of templateResults) { - const filePathOverride = resolve(fileDir, templateResult.outputName) - const filePathOverrideDirname = dirname(filePathOverride) - await mkdir(filePathOverrideDirname, { recursive: true }) - await writeFile(filePathOverride, templateResult.content) - templateReport.outputs.push(templateResult.outputName) + await writeTemplateOutput({ + dest, + fileDir, + outputName: templateResult.outputName, + content: templateResult.content, + template, + outputRecords, + }) } } else if ( + templateResults && typeof templateResults === 'object' && 'outputName' in templateResults && 'content' in templateResults ) { - templateReport.type = 'object' - const filePathOverride = resolve(fileDir, templateResults.outputName) - const filePathOverrideDirname = dirname(filePathOverride) - await mkdir(filePathOverrideDirname, { recursive: true }) - await writeFile(filePathOverride, templateResults.content) - templateReport.outputs.push(templateResults.outputName) + await writeTemplateOutput({ + dest, + fileDir, + outputName: templateResults.outputName, + content: templateResults.content, + template, + outputRecords, + }) } else if ( + templateResults && typeof templateResults === 'object' && !Array.isArray(templateResults) && typeof templateResults[Symbol.asyncIterator] === 'function') { - templateReport.type = 'async-iterator' for await (const templateResult of templateResults) { if ('outputName' in templateResult && 'content' in templateResult) { - const filePathOverride = resolve(fileDir, templateResult.outputName) - const filePathOverrideDirname = dirname(filePathOverride) - await mkdir(filePathOverrideDirname, { recursive: true }) - await writeFile(filePathOverride, templateResult.content) - templateReport.outputs.push(templateResult.outputName) + await writeTemplateOutput({ + dest, + fileDir, + outputName: templateResult.outputName, + content: templateResult.content, + template, + outputRecords, + }) } else { throw new Error(`Template file returned unknown return type: ${typeof templateResult}`) } @@ -141,5 +141,59 @@ export async function templateBuilder ({ throw new Error(`Template file returned unknown return type: ${typeof templateResults}`) } - return templateReport + return outputRecords +} + +/** + * @param {object} params + * @param {string} params.dest + * @param {string} params.fileDir + * @param {string} params.outputName + * @param {string} params.content + * @param {TemplateInfo} params.template + * @param {DomstackManifestRecord[]} params.outputRecords + */ +async function writeTemplateOutput ({ + dest, + fileDir, + outputName, + content, + template, + outputRecords, +}) { + const filepath = resolve(fileDir, outputName) + assertInsideDest(dest, filepath) + const filePathDirname = dirname(filepath) + await mkdir(filePathDirname, { recursive: true }) + await writeFile(filepath, content) + + const outputRelname = toPosix(relative(dest, filepath)) + outputRecords.push(createDomstackManifestRecord({ + dest, + filepath, + outputRelname, + kind: 'template', + sourceRelname: template.templateFile.relname, + templatePath: template.path, + })) +} + +/** + * @param {string} dest + * @param {string} filepath + */ +function assertInsideDest (dest, filepath) { + const absDest = resolve(dest) + const absFilepath = resolve(filepath) + const rel = relative(absDest, absFilepath) + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new Error(`Template output escapes dest: ${filepath}`) + } +} + +/** + * @param {string} value + */ +function toPosix (value) { + return value.split(sep).join('/') } diff --git a/lib/build-static/index.js b/lib/build-static/index.js index 08315db7..ef98eb1b 100644 --- a/lib/build-static/index.js +++ b/lib/build-static/index.js @@ -1,11 +1,13 @@ /** * @import { BuildStepResult } from '../builder.js' * @import { BuildStep } from '../builder.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' */ import { copy } from 'cpx2' +import { createCopiedDomstackManifestRecords } from '../domstack-manifest/index.js' /** - * @typedef {Awaited>} StaticBuilderReport + * @typedef {{ outputs: DomstackManifestRecord[] }} StaticBuilderReport */ /** @@ -34,14 +36,19 @@ export async function buildStatic (src, dest, _siteData, opts) { /** @type {StaticBuildStepResult} */ const results = { type: 'static', - report: {}, + report: /** @type {StaticBuilderReport} */ ({ outputs: [] }), errors: [], warnings: [], } try { const report = await copy(getCopyGlob(src), dest, ...(opts?.ignore ? [{ ignore: opts.ignore }] : [])) - results.report = report + results.report.outputs = createCopiedDomstackManifestRecords({ + src, + dest, + report, + kind: 'static', + }) } catch (err) { const buildError = new Error('Error copying static files', { cause: err }) results.errors.push(buildError) diff --git a/lib/builder.js b/lib/builder.js index 76afd1d3..04016044 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -6,6 +6,8 @@ * @import { PageBuildStepResult } from './build-pages/index.js' * @import { StaticBuildStepResult } from './build-static/index.js' * @import { CopyBuildStepResult } from './build-copy/index.js' + * @import { DomstackManifest } from './domstack-manifest/index.js' + * @import { DomstackManifestRecord } from './domstack-manifest/index.js' */ import { buildPages } from './build-pages/index.js' @@ -15,6 +17,12 @@ import { buildCopy } from './build-copy/index.js' import { buildEsbuild } from './build-esbuild/index.js' import { DomStackAggregateError } from './helpers/domstack-aggregate-error.js' import { ensureDest } from './helpers/ensure-dest.js' +import { + buildDomstackManifest, + resolveDomstackManifestOptions, + shouldWriteDomstackManifest, + writeDomstackManifest, +} from './domstack-manifest/index.js' /** * @typedef {Array} BuildStepErrors @@ -48,6 +56,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @typedef DomStackOpts * @property {boolean|undefined} [static=true] - Enable copying non-page, non-bundle static files from `src` into `dest`. * @property {boolean|undefined} [metafile=true] - Enable writing the esbuild metadata file. + * @property {boolean | { write?: boolean, filename?: string, exclude?: string[] } | undefined} [domstackManifest=true] - Configure writing the domstack manifest file. Programmatic builds always return it. * @property {string[]|undefined} [ignore=[]] - Ignore patterns applied while discovering and copying source files. * @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. @@ -67,6 +76,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @property {StaticBuildStepResult} [staticResults] * @property {CopyBuildStepResult} [copyResults] * @property {PageBuildStepResult} [pageBuildResults] + * @property {DomstackManifest} [domstackManifest] * @property {BuildStepWarnings} warnings */ @@ -110,15 +120,21 @@ export async function builder (src, dest, opts) { await ensureDest(dest, siteData) + const domstackManifestOptions = await resolveDomstackManifestOptions({ + domstackManifestSettingsPath: siteData?.domstackManifestSettings?.filepath, + opts, + }) + const esbuildOpts = withResolvedDomstackManifestFilename(opts, domstackManifestOptions.filename) + const [ esbuildResults, staticResults, copyResults, ] = await Promise.all([ - buildEsbuild(src, dest, siteData, opts), + buildEsbuild(src, dest, siteData, esbuildOpts), opts.static ? buildStatic(src, dest, siteData, opts) - : Promise.resolve(), + : Promise.resolve(null), buildCopy(src, dest, siteData, opts), ]) @@ -156,7 +172,54 @@ export async function builder (src, dest, opts) { if (errors.length > 0) { const buildError = new DomStackAggregateError(errors, 'Build finished but there were errors.', results) throw buildError - } else { - return results + } + + const baseOutputRecords = collectOutputRecords( + esbuildResults, + staticResults, + copyResults, + pageBuildResults + ) + + const domstackManifest = await buildDomstackManifest({ + dest, + records: baseOutputRecords, + options: domstackManifestOptions, + }) + + results.domstackManifest = domstackManifest + + if (shouldWriteDomstackManifest(opts)) { + await writeDomstackManifest(dest, domstackManifest, domstackManifestOptions.filename) + } + + return results +} + +/** + * @param {...{ report?: { outputs?: DomstackManifestRecord[] } } | null | undefined} results + * @returns {DomstackManifestRecord[]} + */ +function collectOutputRecords (...results) { + return results.flatMap(result => result?.report?.outputs ?? []) +} + +/** + * Let esbuild's DOMSTACK_MANIFEST_URL define observe a filename supplied by + * domstack-manifest.settings.* without changing the public build options shape. + * + * @param {DomStackOpts} opts + * @param {string | undefined} filename + * @returns {DomStackOpts} + */ +function withResolvedDomstackManifestFilename (opts, filename) { + if (opts.domstackManifest === false || !filename) return opts + + return { + ...opts, + domstackManifest: { + ...(typeof opts.domstackManifest === 'object' ? opts.domstackManifest : {}), + filename, + }, } } diff --git a/lib/domstack-manifest/index.js b/lib/domstack-manifest/index.js new file mode 100644 index 00000000..831ad88f --- /dev/null +++ b/lib/domstack-manifest/index.js @@ -0,0 +1,720 @@ +/** + * @import { DomStackOpts } from '../builder.js' + * @import { FromSchema, JSONSchema } from 'json-schema-to-ts' + */ + +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path' +import ignore from 'ignore' +import { resolveVars } from '../build-pages/resolve-vars.js' + +export const DEFAULT_DOMSTACK_MANIFEST_FILENAME = 'domstack-manifest.json' +export const DOMSTACK_MANIFEST_SCHEMA_PATH = 'lib/domstack-manifest/schema.json' +// The published JSON schema URL is versioned so manifest files keep pointing at the +// schema contract they were generated against after future domstack releases. +export const DOMSTACK_MANIFEST_SCHEMA_ID = getDomstackManifestSchemaId(readPackageVersion()) + +// Manifest schema and public types + +// These schema objects are the source of truth for both runtime manifest validation and +// the public TypeScript/JSDoc types derived below with json-schema-to-ts. +/** @satisfies {JSONSchema} */ +export const domstackManifestKindSchema = /** @type {const} */ ({ + description: 'Classifies the build pipeline step or artifact type that produced this output.', + enum: [ + 'page', + 'template', + 'script', + 'style', + 'chunk', + 'service-worker', + 'worker', + 'worker-manifest', + 'static', + 'copy', + 'sourcemap', + 'metadata', + ], +}) + +/** @satisfies {JSONSchema} */ +export const domstackManifestEntryPageMetaSchema = /** @type {const} */ ({ + description: 'Page-specific metadata recorded for manifest entries whose kind is "page".', + type: 'object', + properties: { + path: { + description: 'Source-relative page path used by domstack routing, without a leading slash.', + type: 'string', + }, + url: { + description: 'Canonical public URL for the page, such as "/" or "/docs/".', + type: 'string', + }, + vars: { + description: 'Selected page variables that can affect offline or precache policy.', + type: 'object', + properties: { + precache: { + description: 'Application-defined page precache policy metadata. Domstack records this value but does not interpret it.', + }, + offline: { + description: 'Application-defined page offline availability metadata. Domstack records this value but does not interpret it.', + }, + }, + additionalProperties: false, + }, + }, + required: ['path', 'url'], + additionalProperties: false, +}) + +/** @satisfies {JSONSchema} */ +export const domstackManifestEntrySchema = /** @type {const} */ ({ + description: 'One public output emitted by domstack and included in the reconciled domstack manifest.', + type: 'object', + properties: { + outputRelname: { + description: 'Destination-relative output path using POSIX separators, such as "index.html" or "chunks/js/chunk-ABC.js".', + type: 'string', + }, + kind: domstackManifestKindSchema, + url: { + description: 'Public same-origin URL for the output, normalized with a leading slash.', + type: 'string', + }, + revision: { + description: 'SHA-256 hex digest of the output file contents. Null is reserved for outputs without a content revision.', + type: ['string', 'null'], + }, + bytes: { + description: 'Output file size in bytes. Null is reserved for outputs whose size is unavailable.', + type: ['integer', 'null'], + }, + sourceRelname: { + description: 'Source-relative path that produced this output when a direct source file is known.', + type: 'string', + }, + entryPoint: { + description: 'esbuild entry point path for script, style, worker, and service-worker outputs when available.', + type: 'string', + }, + pagePath: { + description: 'Source-relative page path associated with this output when the output belongs to a page.', + type: 'string', + }, + pageUrl: { + description: 'Canonical public page URL associated with this output when the output belongs to a page.', + type: 'string', + }, + templatePath: { + description: 'Source-relative template path associated with this output when the output was emitted by a template.', + type: 'string', + }, + page: domstackManifestEntryPageMetaSchema, + }, + required: ['outputRelname', 'kind', 'url', 'revision', 'bytes'], + additionalProperties: false, +}) + +/** @satisfies {JSONSchema} */ +export const domstackManifestSchema = /** @type {const} */ ({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: DOMSTACK_MANIFEST_SCHEMA_ID, + title: 'Domstack manifest', + description: 'A normalized, revisioned manifest of public files emitted by a domstack build.', + type: 'object', + properties: { + $schema: { + description: 'Versioned URL of the JSON Schema that describes this manifest.', + const: DOMSTACK_MANIFEST_SCHEMA_ID, + }, + version: { + description: 'SHA-256 hex digest derived from the final cache-relevant manifest entries.', + type: 'string', + }, + generatedAt: { + description: 'ISO 8601 timestamp for when domstack generated this manifest.', + type: 'string', + format: 'date-time', + }, + entries: { + description: 'Sorted public output entries included in the manifest after excludes and filters are applied.', + type: 'array', + items: domstackManifestEntrySchema, + }, + }, + required: ['$schema', 'version', 'generatedAt', 'entries'], + additionalProperties: false, +}) + +/** + * This helper is exported for release tooling and consumers that need to reconstruct + * the versioned unpkg schema URL without duplicating domstack's package path. + * + * @param {string} version + */ +export function getDomstackManifestSchemaId (version) { + return `https://unpkg.com/@domstack/static@${version}/${DOMSTACK_MANIFEST_SCHEMA_PATH}` +} + +/** + * @typedef {FromSchema} DomstackManifestKind + */ + +/** + * @typedef {FromSchema} DomstackManifestEntryPageMeta + */ + +/** + * A build step writes these as it emits files. Reconciliation turns them into + * revisioned manifest entries. + * + * @typedef {object} DomstackManifestRecord + * @property {string} outputRelname + * @property {string} filepath + * @property {DomstackManifestKind} kind + * @property {string} [url] + * @property {string} [sourceRelname] + * @property {string} [entryPoint] + * @property {string} [pagePath] + * @property {string} [pageUrl] + * @property {string} [templatePath] + * @property {DomstackManifestEntryPageMeta} [page] + */ + +/** + * @typedef {FromSchema} DomstackManifestEntry + */ + +/** + * @typedef {FromSchema} DomstackManifest + */ + +/** + * @typedef {object} DomstackManifestOptions + * @property {string[]} [exclude] + * @property {(entry: DomstackManifestEntry) => boolean | Promise} [includeEntry] + * @property {string} [filename] + */ + +// TODO: If a concrete client needs Workbox integration, consider adding an +// optional derived artifact that projects manifest entries to Workbox's +// `{ url, revision }` precache shape. Keep the domstack manifest as the richer +// source of truth until that use case can validate the exact API. + +const KIND_PRIORITY = new Map([ + ['page', 100], + ['service-worker', 95], + ['template', 90], + ['worker-manifest', 80], + ['worker', 70], + ['script', 60], + ['style', 50], + ['chunk', 40], + ['static', 30], + ['copy', 20], + ['sourcemap', 10], + ['metadata', 0], +]) + +// Manifest reconciliation + +/** + * Build a normalized, revisioned manifest from records emitted by build steps. + * + * @param {object} params + * @param {string} params.dest + * @param {DomstackManifestRecord[]} [params.records] + * @param {DomstackManifestEntry[]} [params.entries] + * @param {DomstackManifestOptions} [params.options] + * @returns {Promise} + */ +export async function buildDomstackManifest ({ dest, records = [], entries: existingEntries = [], options = {} }) { + /** @type {Map} */ + const entryMap = new Map() + + for (const entry of existingEntries) { + setEntry(entryMap, normalizeExistingEntry({ dest, entry })) + } + + for (const record of records) { + const entry = await createEntry({ dest, record }) + setEntry(entryMap, entry) + } + + entryMap.delete(toPosix(options.filename ?? DEFAULT_DOMSTACK_MANIFEST_FILENAME)) + + let finalEntries = Array.from(entryMap.values()) + .filter(entry => entry.revision) + .sort((a, b) => a.url.localeCompare(b.url)) + + finalEntries = applyExclude(finalEntries, options.exclude ?? []) + + if (options.includeEntry) { + /** @type {Array} */ + const included = [] + for (const entry of finalEntries) { + if (await options.includeEntry(toPublicEntry(entry))) included.push(entry) + } + finalEntries = included + } + + const version = createHash('sha256') + for (const entry of finalEntries) { + updateManifestVersionHash(version, toPublicEntry(entry)) + } + + return { + $schema: DOMSTACK_MANIFEST_SCHEMA_ID, + version: version.digest('hex'), + generatedAt: new Date().toISOString(), + entries: finalEntries.map(toPublicEntry), + } +} + +// Public build-step helpers + +/** + * Create a normalized record for a file inside dest. + * + * @param {object} params + * @param {string} params.dest + * @param {string} [params.filepath] + * @param {string} [params.outputRelname] + * @param {DomstackManifestKind} params.kind + * @param {string} [params.url] + * @param {string} [params.sourceRelname] + * @param {string} [params.entryPoint] + * @param {string} [params.pagePath] + * @param {string} [params.pageUrl] + * @param {string} [params.templatePath] + * @param {DomstackManifestEntryPageMeta} [params.page] + * @returns {DomstackManifestRecord} + */ +export function createDomstackManifestRecord ({ + dest, + filepath, + outputRelname, + kind, + url, + sourceRelname, + entryPoint, + pagePath, + pageUrl, + templatePath, + page, +}) { + const resolvedFilepath = filepath + ? resolve(filepath) + : resolve(dest, outputRelname ?? '') + assertInsideDest(dest, resolvedFilepath) + + const normalizedOutputRelname = toPosix(outputRelname ?? relative(dest, resolvedFilepath)) + + return { + outputRelname: normalizedOutputRelname, + filepath: resolvedFilepath, + kind, + url: url ?? outputRelnameToUrl(normalizedOutputRelname), + ...(sourceRelname ? { sourceRelname: toPosix(sourceRelname) } : {}), + ...(entryPoint ? { entryPoint: normalizeEntryPoint(entryPoint) } : {}), + ...(pagePath ? { pagePath } : {}), + ...(pageUrl ? { pageUrl } : {}), + ...(templatePath ? { templatePath } : {}), + ...(page ? { page } : {}), + } +} + +/** + * Convert a cpx2 report into output records. + * + * @param {object} params + * @param {string} params.src + * @param {string} params.dest + * @param {unknown} params.report + * @param {'static'|'copy'} params.kind + * @returns {DomstackManifestRecord[]} + */ +export function createCopiedDomstackManifestRecords ({ src, dest, report, kind }) { + return extractCopiedFiles(report).map(copiedFile => { + const filepath = resolve(copiedFile.output) + return createDomstackManifestRecord({ + dest, + filepath, + kind, + sourceRelname: copiedFile.source ? toPosix(relative(src, resolve(copiedFile.source))) : undefined, + }) + }) +} + +/** + * Classify a dest-relative esbuild output. + * + * @param {object} params + * @param {string} params.outputRelname + * @param {string | undefined} params.entryPoint + * @param {Set} params.workerOutputRelnames + * @param {string | undefined} [params.serviceWorkerOutputRelname] + * @returns {DomstackManifestKind} + */ +export function classifyEsbuildOutput ({ outputRelname, entryPoint, workerOutputRelnames, serviceWorkerOutputRelname }) { + const ext = extname(outputRelname) + + if (ext === '.map') return 'sourcemap' + if (serviceWorkerOutputRelname && outputRelname === serviceWorkerOutputRelname) return 'service-worker' + if (workerOutputRelnames.has(outputRelname)) return 'worker' + if (ext === '.css') return 'style' + if (ext === '.js' && entryPoint) return 'script' + return 'chunk' +} + +// Manifest writing and options + +/** + * @param {string} dest + * @param {DomstackManifest} domstackManifest + * @param {string} [filename] + */ +export async function writeDomstackManifest (dest, domstackManifest, filename = DEFAULT_DOMSTACK_MANIFEST_FILENAME) { + const manifestPath = resolve(dest, filename) + assertInsideDest(dest, manifestPath) + await mkdir(dirname(manifestPath), { recursive: true }) + await writeFile(manifestPath, JSON.stringify(domstackManifest, null, 2)) +} + +/** + * @param {object} params + * @param {string | undefined} params.domstackManifestSettingsPath + * @param {DomStackOpts | undefined} params.opts + * @returns {Promise} + */ +export async function resolveDomstackManifestOptions ({ domstackManifestSettingsPath, opts }) { + const domstackManifestOpts = typeof opts?.domstackManifest === 'object' + ? opts.domstackManifest + : {} + const domstackManifestSettings = await resolveVars({ + varsPath: domstackManifestSettingsPath, + }) + const includeEntry = typeof /** @type {{ includeEntry?: unknown }} */ (domstackManifestSettings).includeEntry === 'function' + ? /** @type {(entry: DomstackManifestEntry) => boolean | Promise} */ (/** @type {{ includeEntry: unknown }} */ (domstackManifestSettings).includeEntry) + : undefined + const settingsFilename = typeof /** @type {{ filename?: unknown }} */ (domstackManifestSettings).filename === 'string' + ? /** @type {{ filename: string }} */ (domstackManifestSettings).filename + : undefined + + /** @type {DomstackManifestOptions} */ + const options = { + exclude: [ + ...((Array.isArray(domstackManifestOpts.exclude) ? domstackManifestOpts.exclude : [])), + ...((Array.isArray(/** @type {{ exclude?: unknown }} */ (domstackManifestSettings).exclude) ? /** @type {string[]} */ (/** @type {{ exclude: unknown }} */ (domstackManifestSettings).exclude) : [])), + ], + filename: getDomstackManifestFilename(opts, settingsFilename), + } + + if (includeEntry) options.includeEntry = includeEntry + + return options +} + +/** + * @param {DomStackOpts | undefined} opts + */ +export function shouldWriteDomstackManifest (opts) { + if (opts?.domstackManifest === false) return false + if (typeof opts?.domstackManifest === 'object' && opts.domstackManifest.write === false) return false + return true +} + +/** + * @param {DomStackOpts | undefined} opts + * @param {string} [fallbackFilename] + */ +export function getDomstackManifestFilename (opts, fallbackFilename = DEFAULT_DOMSTACK_MANIFEST_FILENAME) { + return typeof opts?.domstackManifest === 'object' && typeof opts.domstackManifest.filename === 'string' + ? opts.domstackManifest.filename + : fallbackFilename +} + +/** + * @param {string} relname + */ +export function outputRelnameToUrl (relname) { + const posixRelname = toPosix(relname) + return `/${posixRelname === 'index.html' ? '' : posixRelname.replace(/\/index\.html$/, '/')}` +} + +// Path helpers shared by build steps + +/** + * @param {string} value + */ +export function toPosix (value) { + return value.split(sep).join('/') +} + +/** + * @param {object} params + * @param {string} params.dest + * @param {DomstackManifestRecord} params.record + * @returns {Promise<(DomstackManifestRecord & { url: string, revision: string | null, bytes: number | null }) | null>} + */ +async function createEntry ({ dest, record }) { + const filepath = resolve(record.filepath) + assertInsideDest(dest, filepath) + if (!(await fileExists(filepath))) return null + + const [revision, fileStat] = await Promise.all([ + hashFile(filepath), + stat(filepath), + ]) + + return { + ...record, + outputRelname: toPosix(record.outputRelname), + filepath, + url: record.url ?? outputRelnameToUrl(record.outputRelname), + revision, + bytes: fileStat.size, + } +} + +/** + * @param {object} params + * @param {string} params.dest + * @param {DomstackManifestEntry} params.entry + * @returns {DomstackManifestRecord & { url: string, revision: string | null, bytes: number | null }} + */ +function normalizeExistingEntry ({ dest, entry }) { + const filepath = resolve(dest, entry.outputRelname) + assertInsideDest(dest, filepath) + + return { + ...entry, + outputRelname: toPosix(entry.outputRelname), + filepath, + url: entry.url ?? outputRelnameToUrl(entry.outputRelname), + } +} + +/** + * @param {Map} entries + * @param {(DomstackManifestRecord & { url: string, revision: string | null, bytes: number | null }) | null} entry + */ +function setEntry (entries, entry) { + if (!entry) return + const existing = entries.get(entry.outputRelname) + if (!existing || priority(entry.kind) >= priority(existing.kind)) { + entries.set(entry.outputRelname, entry) + } +} + +/** + * Project internal output records onto the serialized manifest contract. Keep this + * explicit so new internal fields cannot accidentally leak into public JSON. + * + * @param {DomstackManifestRecord & { url: string, revision: string | null, bytes: number | null }} entry + * @returns {DomstackManifestEntry} + */ +function toPublicEntry (entry) { + return { + outputRelname: entry.outputRelname, + kind: entry.kind, + url: entry.url, + revision: entry.revision, + bytes: entry.bytes, + ...(entry.sourceRelname ? { sourceRelname: entry.sourceRelname } : {}), + ...(entry.entryPoint ? { entryPoint: entry.entryPoint } : {}), + ...(entry.pagePath ? { pagePath: entry.pagePath } : {}), + ...(entry.pageUrl ? { pageUrl: entry.pageUrl } : {}), + ...(entry.templatePath ? { templatePath: entry.templatePath } : {}), + ...(entry.page ? { page: entry.page } : {}), + } +} + +/** + * Hash only the fields that affect static cache membership and content. Source + * metadata is intentionally ignored so debug/build-origin changes do not churn + * PWA cache names. + * + * @param {{ update: (value: string) => unknown }} hash + * @param {DomstackManifestEntry} entry + */ +function updateManifestVersionHash (hash, entry) { + hash.update(entry.url) + hash.update('\0') + hash.update(entry.revision ?? '') + hash.update('\0') + hash.update(entry.kind) + hash.update('\0') + updateManifestVersionPageVar(hash, entry.page?.vars, 'precache') + updateManifestVersionPageVar(hash, entry.page?.vars, 'offline') +} + +/** + * @param {{ update: (value: string) => unknown }} hash + * @param {{ precache?: unknown, offline?: unknown } | undefined} vars + * @param {'precache'|'offline'} key + */ +function updateManifestVersionPageVar (hash, vars, key) { + if (!vars || !Object.hasOwn(vars, key)) { + hash.update('\0') + return + } + + const serializedValue = stableJsonStringify(vars[key]) + hash.update(serializedValue ?? '') + hash.update('\0') +} + +/** + * Stable JSON serialization for cache policy values. It first applies JSON's own + * serialization rules, then sorts object keys before hashing. + * + * @param {unknown} value + * @returns {string | undefined} + */ +function stableJsonStringify (value) { + const serializedValue = JSON.stringify(value) + return serializedValue === undefined + ? undefined + : stableJsonStringifyValue(JSON.parse(serializedValue)) +} + +/** + * @param {unknown} value + * @returns {string | undefined} + */ +function stableJsonStringifyValue (value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + + if (Array.isArray(value)) { + return `[${value.map(item => stableJsonStringifyValue(item) ?? 'null').join(',')}]` + } + + const serializedProperties = Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .flatMap(([key, item]) => { + const serializedItem = stableJsonStringifyValue(item) + return serializedItem === undefined ? [] : `${JSON.stringify(key)}:${serializedItem}` + }) + + return `{${serializedProperties.join(',')}}` +} + +/** + * @param {Array} entries + * @param {string[]} exclude + */ +function applyExclude (entries, exclude) { + if (exclude.length === 0) return entries + + const ig = ignore().add(exclude.map(pattern => pattern.startsWith('/') ? pattern.slice(1) : pattern)) + + return entries.filter(entry => { + const urlPath = entry.url.startsWith('/') ? entry.url.slice(1) : entry.url + return !isIgnoredPath(ig, urlPath) && !isIgnoredPath(ig, entry.outputRelname) + }) +} + +/** + * The ignore package rejects empty paths. The root page URL normalizes to an + * empty path, and should only be excluded by filtering its output filename. + * + * @param {ReturnType} ig + * @param {string} path + */ +function isIgnoredPath (ig, path) { + return path !== '' && ig.ignores(path) +} + +// Copy-report helpers + +/** + * @param {unknown} report + * @returns {{ source?: string, output: string }[]} + */ +function extractCopiedFiles (report) { + /** @type {{ source?: string, output: string }[]} */ + const copied = [] + + const visit = (/** @type {unknown} */ value) => { + if (!value || typeof value !== 'object') return + if (Array.isArray(value)) { + for (const item of value) visit(item) + return + } + + const maybeCopied = /** @type {{ source?: unknown, output?: unknown, copied?: unknown }} */ (value) + if (typeof maybeCopied.output === 'string') { + copied.push({ + ...(typeof maybeCopied.source === 'string' ? { source: maybeCopied.source } : {}), + output: maybeCopied.output, + }) + } + + for (const child of Object.values(value)) visit(child) + } + + visit(report) + return copied +} + +// Filesystem helpers + +function readPackageVersion () { + const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) + const version = /** @type {{ version?: unknown }} */ (packageJson).version + if (typeof version !== 'string') throw new Error('Unable to resolve package version for domstack manifest schema') + return version +} + +/** + * @param {string} filepath + */ +async function hashFile (filepath) { + const contents = await readFile(filepath) + return createHash('sha256').update(contents).digest('hex') +} + +/** + * @param {string} filepath + */ +async function fileExists (filepath) { + try { + const fileStat = await stat(filepath) + return fileStat.isFile() + } catch { + return false + } +} + +/** + * @param {string} dest + * @param {string} filepath + */ +function assertInsideDest (dest, filepath) { + const absDest = resolve(dest) + const absFilepath = resolve(filepath) + const rel = relative(absDest, absFilepath) + if (rel !== '' && (rel.startsWith('..') || isAbsolute(rel))) { + throw new Error(`Output path escapes dest: ${filepath}`) + } +} + +/** + * @param {string} entryPoint + */ +function normalizeEntryPoint (entryPoint) { + return entryPoint.startsWith('file:') + ? new URL(entryPoint).pathname + : toPosix(entryPoint) +} + +/** + * @param {DomstackManifestKind} kind + */ +function priority (kind) { + return KIND_PRIORITY.get(kind) ?? -1 +} diff --git a/lib/domstack-manifest/schema.json b/lib/domstack-manifest/schema.json new file mode 100644 index 00000000..983d9d54 --- /dev/null +++ b/lib/domstack-manifest/schema.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://unpkg.com/@domstack/static@11.0.3/lib/domstack-manifest/schema.json", + "title": "Domstack manifest", + "description": "A normalized, revisioned manifest of public files emitted by a domstack build.", + "type": "object", + "properties": { + "$schema": { + "description": "Versioned URL of the JSON Schema that describes this manifest.", + "const": "https://unpkg.com/@domstack/static@11.0.3/lib/domstack-manifest/schema.json" + }, + "version": { + "description": "SHA-256 hex digest derived from the final cache-relevant manifest entries.", + "type": "string" + }, + "generatedAt": { + "description": "ISO 8601 timestamp for when domstack generated this manifest.", + "type": "string", + "format": "date-time" + }, + "entries": { + "description": "Sorted public output entries included in the manifest after excludes and filters are applied.", + "type": "array", + "items": { + "description": "One public output emitted by domstack and included in the reconciled domstack manifest.", + "type": "object", + "properties": { + "outputRelname": { + "description": "Destination-relative output path using POSIX separators, such as \"index.html\" or \"chunks/js/chunk-ABC.js\".", + "type": "string" + }, + "kind": { + "description": "Classifies the build pipeline step or artifact type that produced this output.", + "enum": [ + "page", + "template", + "script", + "style", + "chunk", + "service-worker", + "worker", + "worker-manifest", + "static", + "copy", + "sourcemap", + "metadata" + ] + }, + "url": { + "description": "Public same-origin URL for the output, normalized with a leading slash.", + "type": "string" + }, + "revision": { + "description": "SHA-256 hex digest of the output file contents. Null is reserved for outputs without a content revision.", + "type": [ + "string", + "null" + ] + }, + "bytes": { + "description": "Output file size in bytes. Null is reserved for outputs whose size is unavailable.", + "type": [ + "integer", + "null" + ] + }, + "sourceRelname": { + "description": "Source-relative path that produced this output when a direct source file is known.", + "type": "string" + }, + "entryPoint": { + "description": "esbuild entry point path for script, style, worker, and service-worker outputs when available.", + "type": "string" + }, + "pagePath": { + "description": "Source-relative page path associated with this output when the output belongs to a page.", + "type": "string" + }, + "pageUrl": { + "description": "Canonical public page URL associated with this output when the output belongs to a page.", + "type": "string" + }, + "templatePath": { + "description": "Source-relative template path associated with this output when the output was emitted by a template.", + "type": "string" + }, + "page": { + "description": "Page-specific metadata recorded for manifest entries whose kind is \"page\".", + "type": "object", + "properties": { + "path": { + "description": "Source-relative page path used by domstack routing, without a leading slash.", + "type": "string" + }, + "url": { + "description": "Canonical public URL for the page, such as \"/\" or \"/docs/\".", + "type": "string" + }, + "vars": { + "description": "Selected page variables that can affect offline or precache policy.", + "type": "object", + "properties": { + "precache": { + "description": "Application-defined page precache policy metadata. Domstack records this value but does not interpret it." + }, + "offline": { + "description": "Application-defined page offline availability metadata. Domstack records this value but does not interpret it." + } + }, + "additionalProperties": false + } + }, + "required": [ + "path", + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "outputRelname", + "kind", + "url", + "revision", + "bytes" + ], + "additionalProperties": false + } + } + }, + "required": [ + "$schema", + "version", + "generatedAt", + "entries" + ], + "additionalProperties": false +} diff --git a/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js index bb8a6d3b..65765252 100644 --- a/lib/helpers/domstack-error.js +++ b/lib/helpers/domstack-error.js @@ -1,4 +1,4 @@ -/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' } DomStackErrorCode */ +/** @typedef { 'DOM_STACK_ERROR_DUPLICATE_PAGE' | 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER' } DomStackErrorCode */ /** * Domstack Duplicate Page Error @@ -31,6 +31,33 @@ export class DomStackDuplicatePageError extends Error { } } +/** + * Domstack Duplicate Service Worker Error + * @extends {Error} + */ +export class DomStackDuplicateServiceWorkerError extends Error { + duplicates + + /** + * Constructs a new DomStackDuplicateServiceWorkerError instance. + * + * @param {string} message - The error message + * @param {{ files: string[] }} duplicates - Extra params + * @param {ErrorOptions} [opts] - The opts object from the Error class + */ + constructor (message, duplicates, opts) { + super(message, opts) + this.duplicates = duplicates + } + + /** + * @returns {DomStackErrorCode} + */ + get code () { + return 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER' + } +} + /** @typedef { 'DOM_STACK_WARNING_DUPLICATE_LAYOUT' } DomStackWarningCode */ /** diff --git a/lib/helpers/domstack-warning.js b/lib/helpers/domstack-warning.js index b911549e..28ffb19a 100644 --- a/lib/helpers/domstack-warning.js +++ b/lib/helpers/domstack-warning.js @@ -10,6 +10,7 @@ * 'DOM_STACK_WARNING_DUPLICATE_GLOBAL_CLIENT' | * 'DOM_STACK_WARNING_DUPLICATE_ESBUILD_SETTINGS' | * 'DOM_STACK_WARNING_DUPLICATE_MARKDOWN_IT_SETTINGS' | + * 'DOM_STACK_WARNING_DUPLICATE_DOMSTACK_MANIFEST_SETTINGS' | * 'DOM_STACK_WARNING_DUPLICATE_GLOBAL_VARS' | * 'DOM_STACK_WARNING_DUPLICATE_GLOBAL_DATA' | * 'DOM_STACK_WARNING_PAGE_MD_SHADOWS_README' diff --git a/lib/helpers/generate-tree-data.js b/lib/helpers/generate-tree-data.js index 12d4449b..f8f850f4 100644 --- a/lib/helpers/generate-tree-data.js +++ b/lib/helpers/generate-tree-data.js @@ -1,10 +1,21 @@ /** * @import { Results } from '../builder.js' + * @import { DomstackManifestRecord } from '../domstack-manifest/index.js' */ -import { join, basename, sep, relative } from 'path' +import { join, basename, posix, sep, relative } from 'path' import cleanDeep from 'clean-deep' +/** + * @typedef {{ + * label: string, + * nodes: TreeNode[], + * leaf: { + * [keyName: string]: string | undefined + * } + * }} TreeNode + */ + /** * Generates a printable tree of what domstack did * @param {string} cwd cwd of the build @@ -18,24 +29,17 @@ export function generateTreeData (cwd, src, dest, results) { const srcDir = basename(relative(cwd, src)) const destDir = basename(relative(cwd, dest)) - /** - * @typedef {{ - * label: string, - * nodes: TreeNode[], - * leaf: { - * [keyName: string]: string | undefined - * } - * }} TreeNode - */ /** @type {TreeNode} */ const treeStructure = { label: `${join(cwdDir, srcDir)} => ${join(cwdDir, destDir)}`, leaf: { globalStyle: results?.siteData?.globalStyle?.outputRelname, globalClient: results?.siteData?.globalClient?.outputRelname, + serviceWorker: results?.siteData?.serviceWorker?.outputRelname, globalVars: results?.siteData?.globalVars?.basename, esbuildSettings: results?.siteData?.esbuildSettings?.basename, markdownItSettings: results?.siteData?.markdownItSettings?.basename, + domstackManifestSettings: results?.siteData?.domstackManifestSettings?.basename, // rootLayout: results?.siteData?.layouts?.['root']?.basename }, nodes: [], @@ -77,32 +81,23 @@ export function generateTreeData (cwd, src, dest, results) { } } - if (results?.pageBuildResults?.report?.templates) { - for (const templateReport of results?.pageBuildResults?.report?.templates) { - const segments = templateReport.templateInfo.templateFile.relname.split(sep) - segments.pop() - - let nodes = treeStructure.nodes - let targetNode = treeStructure - - for (const segment of segments) { - const findResults = nodes.find(node => segment === node.label) - if (!findResults) { - targetNode = { label: segment, leaf: {}, nodes: [] } - nodes.push(targetNode) - } else { - targetNode = findResults - } - nodes = targetNode.nodes - } - if (templateReport.outputs.length > 0) { - templateReport.outputs.forEach((output, index) => { - targetNode.leaf[`${templateReport.templateInfo.templateFile.basename}${index > 0 ? `-${index}` : ''}`] = output - }) - } else { - targetNode.leaf[templateReport.templateInfo.templateFile.basename] = 'NO TEMPLATE OUTPUT' - } - } + const templateOutputsBySource = groupOutputsBySource( + getReportOutputs(results?.pageBuildResults).filter(output => output.kind === 'template') + ) + for (const [sourceRelname, outputs] of templateOutputsBySource) { + const targetNode = ensureOutputNode(treeStructure, sourceRelname) + const sourceBasename = posix.basename(sourceRelname) + + outputs.forEach((output, index) => { + targetNode.leaf[`${sourceBasename}${index > 0 ? `-${index}` : ''}`] = templateOutputName(output) + }) + } + + const staticOutputs = getReportOutputs(results?.staticResults).filter(output => output.kind === 'static') + for (const output of staticOutputs) { + const sourceRelname = output.sourceRelname ?? output.outputRelname + const targetNode = ensureOutputNode(treeStructure, sourceRelname) + targetNode.leaf[posix.basename(sourceRelname)] = output.outputRelname } for (const [layoutName, layoutInfo] of Object.entries(results?.siteData?.layouts)) { @@ -127,32 +122,73 @@ export function generateTreeData (cwd, src, dest, results) { if (layoutInfo.layoutStyle) targetNode.leaf[layoutInfo.layoutStyle.basename] = join(layoutInfo.parentName, layoutInfo.layoutStyle.outputName ?? layoutInfo.layoutStyle.basename) if (layoutInfo.layoutClient) targetNode.leaf[layoutInfo.layoutClient.basename] = join(layoutInfo.parentName, layoutInfo.layoutClient.outputName ?? layoutInfo.layoutClient.basename) } + // @ts-ignore + return cleanDeep(treeStructure) +} - const staticReport = /** @type {{ copied?: Array<{ source: string, output: string }> }} */ (results?.staticResults?.report ?? {}) - if (staticReport?.copied) { - for (const file of staticReport.copied) { - const srcFile = relative(srcDir, file.source) - const destFile = relative(destDir, file.output) - const segments = srcFile.split(sep) - segments.pop() - - let nodes = treeStructure.nodes - let targetNode = treeStructure - - for (const segment of segments) { - const findResults = nodes.find(node => segment === node.label) - if (!findResults) { - targetNode = { label: segment, leaf: {}, nodes: [] } - nodes.push(targetNode) - } else { - targetNode = findResults - } - nodes = targetNode.nodes - } +/** + * @param {{ report?: { outputs?: DomstackManifestRecord[] } } | null | undefined} result + * @returns {DomstackManifestRecord[]} + */ +function getReportOutputs (result) { + return result?.report?.outputs ?? [] +} - targetNode.leaf[basename(srcFile)] = destFile +/** + * @param {DomstackManifestRecord[]} outputs + * @returns {Map} + */ +function groupOutputsBySource (outputs) { + /** @type {Map} */ + const outputMap = new Map() + + for (const output of outputs) { + const key = output.sourceRelname ?? output.outputRelname + const existing = outputMap.get(key) ?? [] + existing.push(output) + outputMap.set(key, existing) + } + + return outputMap +} + +/** + * @param {DomstackManifestRecord} output + */ +function templateOutputName (output) { + if (!output.templatePath) return output.outputRelname + const templatePrefix = `${output.templatePath}/` + return output.outputRelname.startsWith(templatePrefix) + ? output.outputRelname.slice(templatePrefix.length) + : output.outputRelname +} + +/** + * @param {{ + * label: string, + * nodes: TreeNode[], + * leaf: { [keyName: string]: string | undefined } + * }} treeStructure + * @param {string} sourceRelname + * @returns {TreeNode} + */ +function ensureOutputNode (treeStructure, sourceRelname) { + const segments = sourceRelname.split('/') + segments.pop() + + let nodes = treeStructure.nodes + let targetNode = treeStructure + + for (const segment of segments) { + const findResults = nodes.find(node => segment === node.label) + if (!findResults) { + targetNode = { label: segment, leaf: {}, nodes: [] } + nodes.push(targetNode) + } else { + targetNode = findResults } + nodes = targetNode.nodes } - // @ts-ignore - return cleanDeep(treeStructure) + + return targetNode } diff --git a/lib/identify-pages.js b/lib/identify-pages.js index cec555ae..4677d125 100644 --- a/lib/identify-pages.js +++ b/lib/identify-pages.js @@ -6,7 +6,7 @@ import { asyncFolderWalker } from 'async-folder-walker' import assert from 'node:assert' import { resolve, relative, join, basename } from 'path' import { pageBuilders } from './build-pages/index.js' -import { DomStackDuplicatePageError } from './helpers/domstack-error.js' +import { DomStackDuplicatePageError, DomStackDuplicateServiceWorkerError } from './helpers/domstack-error.js' import { nodeHasTS } from './helpers/has-ts.js' import { computePageUrl } from './build-pages/compute-page-url.js' @@ -74,6 +74,13 @@ export const globalClientNames = [ 'global.client.mjs', 'global.client.cjs' ] +export const serviceWorkerNames = nodeHasTS + ? [ + 'service-worker.ts', 'service-worker.mts', 'service-worker.cts', + 'service-worker.js', 'service-worker.mjs', 'service-worker.cjs' + ] + : ['service-worker.js', 'service-worker.mjs', 'service-worker.cjs'] + export const globalVarsNames = nodeHasTS ? [ 'global.vars.ts', 'global.vars.mts', 'global.vars.cts', @@ -102,6 +109,13 @@ export const markdownItSettingsNames = nodeHasTS ] : ['markdown-it.settings.js', 'markdown-it.settings.mjs', 'markdown-it.settings.cjs'] +export const domstackManifestSettingsNames = nodeHasTS + ? [ + 'domstack-manifest.settings.ts', 'domstack-manifest.settings.mts', 'domstack-manifest.settings.cts', + 'domstack-manifest.settings.js', 'domstack-manifest.settings.mjs', 'domstack-manifest.settings.cjs' + ] + : ['domstack-manifest.settings.js', 'domstack-manifest.settings.mjs', 'domstack-manifest.settings.cjs'] + /** * Shape the file walker object * @@ -171,6 +185,10 @@ const shaper = ({ * @property {string} outputName - The derived output name of the template file. Might be overridden. */ +/** + * @typedef {PageFileAsset} ServiceWorkerInfo + */ + /** * Identifies the pages, layouts, templates, and other relevant data from a given source directory. * @@ -234,12 +252,36 @@ export async function identifyPages (src, opts = {}) { /** @type {PageFileAsset | undefined } */ let markdownItSettings + /** @type {PageFileAsset | undefined } */ + let domstackManifestSettings + /** @type {DomStackWarning[]} */ const warnings = [] /** @type {Error[]} */ const errors = [] + /** @type {PageFileAsset[]} */ + const serviceWorkerMatches = [] + for (const files of Object.values(dirs)) { + for (const serviceWorkerName of serviceWorkerNames) { + const file = files[serviceWorkerName] + if (file) serviceWorkerMatches.push(file) + } + } + + /** @type {PageFileAsset | undefined } */ + const serviceWorker = serviceWorkerMatches[0] + + if (serviceWorkerMatches.length > 1) { + errors.push(new DomStackDuplicateServiceWorkerError( + 'Conflicting service worker sources: Only one site service-worker file is supported.', + { + files: serviceWorkerMatches.map(file => file.relname), + } + )) + } + /** @type {string[]} */ // const nonPageFolders = [] @@ -492,11 +534,20 @@ export async function identifyPages (src, opts = {}) { markdownItSettings = fileInfo } } + + if (domstackManifestSettingsNames.some(name => basename(fileName) === name)) { + if (domstackManifestSettings) { + warnings.push({ + code: 'DOM_STACK_WARNING_DUPLICATE_DOMSTACK_MANIFEST_SETTINGS', + message: `Skipping ${fileInfo.relname}. Duplicate domstack manifest settings ${fileName} to ${domstackManifestSettings.filepath}`, + }) + } else { + domstackManifestSettings = fileInfo + } + } } } - // const rootFiles = dirs[''] ?? {} - let defaultLayout = false if (!layouts['root']) { @@ -534,10 +585,12 @@ export async function identifyPages (src, opts = {}) { const results = { globalStyle, globalClient, + serviceWorker, globalVars, globalData, esbuildSettings, markdownItSettings, + domstackManifestSettings, /** @type {string?} Path to a default style */ defaultStyle: null, /** @type {string?} Path to a default client */ diff --git a/lib/identify-pages.test.js b/lib/identify-pages.test.js index 3bfb45c7..f5bd94f8 100644 --- a/lib/identify-pages.test.js +++ b/lib/identify-pages.test.js @@ -1,8 +1,10 @@ import { test } from 'node:test' import assert from 'node:assert' -import { resolve } from 'path' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'path' -import { identifyPages } from './identify-pages.js' +import { identifyPages, domstackManifestSettingsNames, serviceWorkerNames } from './identify-pages.js' const __dirname = import.meta.dirname @@ -12,6 +14,7 @@ test.describe('identify-pages', () => { // console.log(results) assert.ok(results.globalStyle, 'Global style is found') assert.ok(results.globalVars, 'Global variabls are found') + assert.ok(results.domstackManifestSettings, 'Domstack manifest settings are found') assert.ok(results.layouts, 'Layouts are found') assert.ok(results.layouts['root'], 'A root layouts is found') @@ -26,5 +29,74 @@ test.describe('identify-pages', () => { assert.equal(results.pages.find(p => p.path === 'page-md-page')?.pageFile?.type, 'md', 'page-md-page is type md') assert.equal(results.pages.find(p => p.path === 'page-md-page')?.pageFile?.basename, 'page.md', 'page-md-page uses page.md') assert.equal(results.pages.find(p => p.path === 'page-md-precedence')?.pageFile?.basename, 'page.md', 'page.md takes precedence over README.md') + assert.equal(results.serviceWorker?.relname, 'globals/service-worker.mts', 'site service worker is found') + assert.equal(results.domstackManifestSettings?.relname, 'domstack-manifest.settings.js', 'domstack manifest settings are found') + }) + + test('identifies supported domstack manifest settings filenames', async (t) => { + assert.ok(domstackManifestSettingsNames.includes('domstack-manifest.settings.mjs'), 'mjs domstack manifest settings names are supported') + assert.ok(domstackManifestSettingsNames.includes('domstack-manifest.settings.cjs'), 'cjs domstack manifest settings names are supported') + assert.ok(domstackManifestSettingsNames.includes('domstack-manifest.settings.mts'), 'mts domstack manifest settings names are supported') + assert.ok(domstackManifestSettingsNames.includes('domstack-manifest.settings.cts'), 'cts domstack manifest settings names are supported') + + const tmp = await mkdtemp(join(tmpdir(), 'domstack-manifest-settings-')) + t.after(async () => { + await rm(tmp, { recursive: true, force: true }) + }) + + for (const domstackManifestSettingsName of domstackManifestSettingsNames) { + const src = join(tmp, domstackManifestSettingsName) + const assets = join(src, 'global-assets') + await rm(src, { recursive: true, force: true }) + await mkdir(assets, { recursive: true }) + await writeFile(join(assets, domstackManifestSettingsName), 'export default {}\n') + + const results = await identifyPages(src) + + assert.equal(results.domstackManifestSettings?.basename, domstackManifestSettingsName, `${domstackManifestSettingsName} is detected`) + assert.equal(results.domstackManifestSettings?.relname, `global-assets/${domstackManifestSettingsName}`, `${domstackManifestSettingsName} can live below src`) + } + }) + + test('identifies supported site service worker entry filenames', async (t) => { + assert.ok(serviceWorkerNames.includes('service-worker.mjs'), 'mjs service worker names are supported') + assert.ok(serviceWorkerNames.includes('service-worker.cjs'), 'cjs service worker names are supported') + assert.ok(serviceWorkerNames.includes('service-worker.mts'), 'mts service worker names are supported') + assert.ok(serviceWorkerNames.includes('service-worker.cts'), 'cts service worker names are supported') + + const tmp = await mkdtemp(join(tmpdir(), 'domstack-service-worker-')) + t.after(async () => { + await rm(tmp, { recursive: true, force: true }) + }) + + for (const serviceWorkerName of serviceWorkerNames) { + const src = join(tmp, serviceWorkerName) + const assets = join(src, 'global-assets') + await rm(src, { recursive: true, force: true }) + await mkdir(assets, { recursive: true }) + await writeFile(join(assets, serviceWorkerName), 'self.addEventListener("install", () => {})\n') + + const results = await identifyPages(src) + + assert.equal(results.serviceWorker?.basename, serviceWorkerName, `${serviceWorkerName} is detected`) + assert.equal(results.serviceWorker?.relname, `global-assets/${serviceWorkerName}`, `${serviceWorkerName} can live below src`) + assert.equal(results.errors.length, 0, `${serviceWorkerName} does not produce errors`) + } + }) + + test('errors on duplicate site service worker entries', async (t) => { + const src = await mkdtemp(join(tmpdir(), 'domstack-service-worker-dupe-')) + t.after(async () => { + await rm(src, { recursive: true, force: true }) + }) + + await mkdir(join(src, 'global-assets'), { recursive: true }) + await writeFile(join(src, 'global-assets/service-worker.mjs'), 'self.addEventListener("install", () => {})\n') + await writeFile(join(src, 'service-worker.cjs'), 'self.addEventListener("install", () => {})\n') + + const results = await identifyPages(src) + const error = results.errors.find(error => 'code' in error && error.code === 'DOM_STACK_ERROR_DUPLICATE_SERVICE_WORKER') + + assert.ok(error, 'duplicate site service worker sources produce a clear error') }) }) diff --git a/package.json b/package.json index 14bc9cea..746ddcc2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,30 @@ "domstack": "./bin.js", "dom": "./bin.js" }, + "files": [ + "CHANGELOG.md", + "CONTRIBUTING.md", + "bin.js", + "bin.d.ts", + "bin.d.ts.map", + "docs/**/*.md", + "global.css", + "index.js", + "index.d.ts", + "index.d.ts.map", + "lib/**/*.css", + "lib/**/*.d.ts", + "lib/**/*.d.ts.map", + "lib/**/*.js", + "lib/**/*.json", + "!lib/**/*.test.js", + "!lib/**/fixtures/**", + "page.vars.js", + "scripts/domstack-manifest-schema.js", + "style.css", + "types.d.ts", + "types.d.ts.map" + ], "author": "Bret Comnes (https://bret.io)", "bugs": { "url": "https://github.com/bcomnes/domstack/issues" @@ -36,6 +60,7 @@ "highlight.js": "^11.9.0", "ignore": "^7.0.0", "js-yaml": "^5.1.0", + "json-schema-to-ts": "^3.1.1", "make-array": "^1.0.5", "markdown-it": "^14.1.0", "markdown-it-abbr": "^2.0.0", @@ -102,13 +127,14 @@ "build": "npm run clean && run-p build:*", "build:domstack": "./bin.js --src . --ignore examples,test-cases,coverage,*.tsconfig.json,fonts", "build:declaration": "tsc -p declaration.tsconfig.json", + "build:schema": "node scripts/domstack-manifest-schema.js", "watch": "npm run clean && run-p watch:*", "watch:domstack": "npm run build:domstack -- --watch", - "example:basic": "cd examples/basic && npm i && npm run build", - "example:string-layouts": "cd examples/string-layouts && npm i --production && npm run build", - "example:default-layout": "cd examples/default-layout && npm i --production && npm run build", - "example:nested-dest": "cd examples/nested-dest && npm i --production && npm run build", - "example:uhtml-isomorphic": "cd examples/uhtml-isomorphic && npm i --production && npm run build", + "example:basic": "npm --workspace @domstack/basic-example run build", + "example:string-layouts": "npm --workspace @domstack/string-layouts-example run build", + "example:default-layout": "npm --workspace @domstack/default-layout-example run build", + "example:nested-dest": "npm --workspace @domstack/nested-dest-example run build", + "example:uhtml-isomorphic": "npm --workspace @domstack/uhtml-isomorphic-example run build", "start": "npm run watch" }, "funding": { @@ -117,5 +143,8 @@ }, "allowScripts": { "esbuild@0.28.1": true - } + }, + "workspaces": [ + "examples/*" + ] } diff --git a/plans/domstack-manifest.md b/plans/domstack-manifest.md new file mode 100644 index 00000000..97f85080 --- /dev/null +++ b/plans/domstack-manifest.md @@ -0,0 +1,242 @@ +# Build Output Manifest + +## Status: Implemented as unstable preview + +This feature is intentionally documented as an unstable preview. The option names, manifest schema, +generated output shape, browser defines, and service-worker integration semantics may change outside +of a major version while downstream PWA use cases validate the API. + +## Branch Coordination + +- Domstack implementation branch: `staic-client-cache`. +- Downstream validation branch: Breadcrum `pwa-cache-only`. +- Breadcrum's branch uses this Domstack branch through a local link while the + API is unreleased. After this branch lands and is released, Breadcrum should + replace the local link with the released `@domstack/static` version. + +## Goal + +Expose a complete, normalized manifest of files domstack emits so client sites can build service +workers, deploy manifests, cache policies, and audit tooling without re-scanning the output +directory or maintaining hand-written asset lists. + +The main target use case is a static PWA/MPA: + +- Build pages, bundles, copied files, and ordinary templates. +- Record each emitted output at the build step that wrote it. +- Reconcile those records into public URLs, content revisions, and a stable build version. +- Let a stable service worker fetch `domstack-manifest.json` at runtime and use it to drive + cache installation. + +## Design + +The implementation keeps domstack's pipeline simple: + +```txt +builder() + identifyPages() + ensureDest() + Promise.all( + buildEsbuild() -> report.outputs + buildStatic() -> report.outputs + buildCopy() -> report.outputs + ) + buildPages() -> report.outputs for pages + templates + buildDomstackManifest(records) + writeDomstackManifest() +``` + +`buildPages()` does not know about esbuild/static/copy reports and does not reconcile the manifest. +It only renders pages/templates and reports the files it wrote. + +## Output Record Model + +Each build step emits `DomstackManifestRecord` objects: + +```ts +type DomstackManifestKind = + | 'page' + | 'template' + | 'script' + | 'style' + | 'chunk' + | 'worker' + | 'worker-manifest' + | 'service-worker' + | 'static' + | 'copy' + | 'sourcemap' + | 'metadata' + +type DomstackManifestRecord = { + outputRelname: string + filepath: string + kind: DomstackManifestKind + url?: string + sourceRelname?: string + entryPoint?: string + pagePath?: string + pageUrl?: string + templatePath?: string + page?: { + path: string + url: string + vars?: { + precache?: unknown + offline?: unknown + } + } +} +``` + +The reconciler turns records into `DomstackManifestEntry` objects by validating destination paths, hashing +file contents, adding byte sizes, deduping by `outputRelname`, sorting by URL, and applying filters. + +## Manifest Object + +```ts +type DomstackManifest = { + $schema: typeof DOMSTACK_MANIFEST_SCHEMA_ID + version: string + generatedAt: string + entries: DomstackManifestEntry[] +} +``` + +`version` is a sha256 hash of sorted cache-relevant fields: `url`, `revision`, `kind`, and +page-level `precache` / `offline` vars. It does not depend on `generatedAt`. + +Programmatic builds always return `results.domstackManifest`. The CLI writes +`domstack-manifest.json` by default unless `--noDomstackManifest` or +`domstackManifest: false` is used. + +domstack also exports `DOMSTACK_MANIFEST_SCHEMA_ID`, `DOMSTACK_MANIFEST_SCHEMA_PATH`, +`getDomstackManifestSchemaId(version)`, `domstackManifestSchema`, and the schema-derived public +manifest types. + +## Breadcrum Usage + +Breadcrum should ship a first-class `service-worker.*` source under its domstack `src` tree so +domstack bundles it to the stable root `/service-worker.js` URL and records it as +`kind: 'service-worker'` in the generated manifest. That service worker should: + +1. Fetch `/domstack-manifest.json` with `cache: 'no-store'` during install. +2. Open a cache named from `manifest.version`. +3. Precache eligible `manifest.entries` selected by Breadcrum policy. +4. Activate only after required entries are cached. +5. Delete old `domstack-precache-*` caches during activate. +6. Use cache-first or navigation-aware fetch handling for static URLs. + +Docs/legal, login/register, password reset, and other static app/auth pages can remain in the +precache list. Runtime online/offline handling and data sync remain Breadcrum-side concerns. + +## Needed: Manifest Post-Processing API + +The current preview writes Domstack's native `domstack-manifest.json` format. That is enough for a +hand-rolled service worker, but Workbox and similar tools generally expect their own build-time +manifest shape. For example, Workbox's recommended runtime API is: + +```js +precacheAndRoute(self.__WB_MANIFEST) +``` + +where `self.__WB_MANIFEST` is replaced at build time with an array of `{ url, revision }` entries. +A Workbox integration can derive that array from `DomstackManifest.entries`, but Domstack needs a +first-class post-processing hook so applications do not need an ad-hoc script that edits generated +files after every build. + +A future API should let users take the finalized Domstack manifest and write one or more additional +artifacts in alternate formats. The hook should run after record reconciliation and manifest +filtering, but before build results are returned and before `--serve` begins serving `dest`. + +Possible shape: + +```ts +type DomstackManifestPostprocessContext = { + dest: string + manifestFilename: string + manifestPath: string +} + +type DomstackManifestPostprocessOutput = { + filename: string + contents: string | Uint8Array + kind?: 'metadata' | 'worker-manifest' +} + +type DomstackManifestPostprocess = ( + manifest: DomstackManifest, + context: DomstackManifestPostprocessContext +) => + | DomstackManifestPostprocessOutput + | DomstackManifestPostprocessOutput[] + | Promise +``` + +Example Workbox-oriented usage: + +```js +export default { + postprocess (manifest) { + const entries = manifest.entries.map(entry => ({ + revision: entry.revision, + url: entry.url, + })) + + return { + filename: 'workbox-manifest.json', + contents: JSON.stringify(entries), + kind: 'worker-manifest', + } + }, +} +``` + +Open design questions: + +- Should postprocessors live under `domstack-manifest.settings.*`, programmatic + `domstackManifest.postprocess`, or both? +- Should the hook only write additional files, or should it also be allowed to transform the native + Domstack manifest before it is written? +- Should multiple named postprocessors be supported so a project can write Workbox, deployment, and + audit manifests in one build? +- Should postprocessed outputs be included in `results.domstackManifest.entries`, excluded from the + native manifest, or reported separately to avoid changing the manifest version? +- Should Domstack provide helpers for common adapters such as Workbox `{ url, revision }` entries, + or leave adapters entirely in application code during the unstable preview? + +The safest initial API is additive and side-effect-minimal: accept the finalized manifest, return +extra output files for Domstack to validate and write inside `dest`, record those files as metadata, +and do not let the hook mutate the native manifest object in place. + +## Implemented Work + +- Added `lib/domstack-manifest/index.js`. +- Added `lib/domstack-manifest/schema.json`. +- Added output records to: + - `buildEsbuild()` + - `buildStatic()` + - `buildCopy()` + - `pageWriter()` + - `templateBuilder()` +- Simplified build reports so emitted files flow through `report.outputs`. +- Added destination escape protection for template output names. +- Added `results.domstackManifest`. +- Added manifest writing controls: + - `--customDomstackManifestName ` + - `--noDomstackManifest` + - `domstackManifest.filename` + - `domstackManifest.write` + - `domstackManifest.exclude` +- Added `domstack-manifest.settings.*` `filename`, `exclude`, and `includeEntry(entry)` hooks. +- Added first-class `service-worker.*` detection/build support with a stable `/service-worker.js` output. +- Fixed `metafile: false` so esbuild still produces internal metadata for output mapping but skips + writing `domstack-esbuild-meta.json`. +- Left domstack manifests unsupported in watch mode to keep the development pipeline simple. Watch + mode renders normal templates, but it does not write `domstack-manifest.json`. +- Documented the manifest API and service-worker runtime pattern in `README.md`. + +## Verification + +- `npm test` +- `npm pack --dry-run --json` confirms `lib/domstack-manifest/schema.json` is published. diff --git a/plans/first-class-service-workers.md b/plans/first-class-service-workers.md new file mode 100644 index 00000000..b27ebded --- /dev/null +++ b/plans/first-class-service-workers.md @@ -0,0 +1,157 @@ +# First-Class Service Workers + +## Status: Implemented as unstable preview in the stacked PR + +This feature is intentionally documented as an unstable preview. The service-worker source convention, +generated output shape, browser defines, manifest integration, and lifecycle semantics may change +outside of a major version while downstream PWA use cases validate the API. + +## Branch Coordination + +- Domstack implementation branch: `staic-client-cache`. +- Downstream validation branch: Breadcrum `pwa-cache-only`. +- Breadcrum's branch exercises the first-class service worker entry through + `packages/web/client/globals/service-worker.ts`. + +## Goal + +Add explicit service worker support to domstack so sites do not need to emit `/service-worker.js` +through a template or copy workaround. + +The existing `*.worker.{js,ts}` support is for page-scoped Web Workers. Service workers have +different requirements: + +- They need a stable URL, usually `/service-worker.js`, so browser update checks work correctly. +- They usually need root scope. +- They should not be content-hashed in the output filename. +- They should be included in the domstack manifest so PWA tooling can reason about them. +- They should be built by esbuild so TypeScript, ESM imports, and bundling work consistently. + +## Non-Goals + +- Do not add a post-build template phase. +- Do not generate a default service worker. +- Do not implement data sync, offline mutations, or app-specific cache policy. +- Do not make watch mode write `domstack-manifest.json`. + +## Source Conventions + +Domstack supports one site-level service worker entry point anywhere under `src`, matching domstack's other +global asset patterns: + +```txt +src/ + globals/ + service-worker.js + service-worker.ts +``` + +Supported JavaScript filenames are `service-worker.js`, `service-worker.mjs`, and +`service-worker.cjs`. When Node's TypeScript support is available, `service-worker.ts`, +`service-worker.mts`, and `service-worker.cts` are also supported. + +Only one service worker entry is allowed. If multiple forms are present anywhere in `src`, domstack +fails with a clear duplicate-entry error. + +The output is: + +```txt +public/service-worker.js +``` + +This is intentionally stable and un-hashed. Any imports/chunks produced by the service worker can use +the normal chunk naming rules. + +## Build Pipeline + +1. `identifyPages()` detects a site service worker entry and stores it on `siteData.serviceWorker`. +2. Production `buildEsbuild()` runs the normal hashed-entry build for page/global assets, then runs a + small second esbuild build for the service worker with `entryNames: '[name]'` so the output remains + `/service-worker.js`. +3. Watch mode adds the service worker as an object entry point in the esbuild watch context and uses + stable entry names for all watched entries. +4. `createEsbuildOutputRecords()` classifies the root output as `kind: 'service-worker'`. +5. `buildDomstackManifest()` includes the service worker entry like any other emitted output during + one-shot builds. + +The service worker itself can fetch `/domstack-manifest.json` at runtime with `cache: +'no-store'`, open a versioned cache using `manifest.version`, and precache selected manifest entries. + +## Esbuild Details + +The service worker entry uses a stable output name even when other production entry points use hashed +entry names. Production intentionally uses a separate service-worker build because esbuild's +`entryNames` pattern applies to every entry point in a build. Keeping the root service-worker URL +stable without de-hashing all other entries is simpler and less fragile as a second build. + +The service-worker build reuses the extended esbuild options, including user settings, loaders, +targets, plugins, and domstack's reserved browser defines. The metafiles from the normal build and +service-worker build are merged before output records are classified. + +## Manifest Schema Changes + +Add a new output kind: + +```ts +type DomstackManifestKind = + | ... + | 'service-worker' +``` + +The manifest entry should include: + +```ts +{ + kind: 'service-worker', + outputRelname: 'service-worker.js', + url: '/service-worker.js', + sourceRelname: 'service-worker.js' +} +``` + +Regenerate `lib/domstack-manifest/schema.json` with `npm run build:schema`. + +## Watch Mode + +Watch mode should build and rebundle the service worker as part of esbuild watch, but it should keep +the current manifest behavior: + +- no `results.domstackManifest` +- no `domstack-manifest.json` + +This means watch mode can validate service worker bundling, but full PWA cache lifecycle testing +still requires a one-shot build. + +## Registration + +domstack should not auto-register the service worker. Registration belongs in site client code: + +```js +if ('serviceWorker' in navigator) { + await navigator.serviceWorker.register('/service-worker.js', { type: 'module' }) +} +``` + +This keeps lifecycle UX, update prompts, and online/offline handling in the application. + +## Tests + +Fixture coverage includes: + +- `service-worker.*` detection anywhere in `src` and output at `/service-worker.js`. +- TypeScript service-worker detection when Node TypeScript support is enabled. +- duplicate service worker source files fail clearly. +- service worker imports are bundled. +- domstack manifest includes `kind: 'service-worker'`. +- watch mode builds the service worker but does not write `domstack-manifest.json`. + +## Breadcrum Usage + +Breadcrum can replace its service-worker template/static workaround with: + +```txt +packages/web/client/globals/service-worker.ts +``` + +That file should fetch `/domstack-manifest.json`, cache entries selected by Breadcrum policy, +apply update lifecycle handling, and leave data-model/offline mutation behavior for a later pass. diff --git a/scripts/domstack-manifest-schema.js b/scripts/domstack-manifest-schema.js new file mode 100644 index 00000000..c11ef855 --- /dev/null +++ b/scripts/domstack-manifest-schema.js @@ -0,0 +1,13 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { + DOMSTACK_MANIFEST_SCHEMA_PATH, + domstackManifestSchema, +} from '../lib/domstack-manifest/index.js' + +// Keep the checked-in schema.json generated from the JS schema source so the +// published $schema URL, runtime manifest shape, and exported types stay aligned. +const outputPath = resolve(import.meta.dirname, '..', DOMSTACK_MANIFEST_SCHEMA_PATH) + +await mkdir(dirname(outputPath), { recursive: true }) +await writeFile(outputPath, `${JSON.stringify(domstackManifestSchema, null, 2)}\n`) diff --git a/test-cases/general-features/index.test.js b/test-cases/general-features/index.test.js index 55d6dd9f..469d465b 100644 --- a/test-cases/general-features/index.test.js +++ b/test-cases/general-features/index.test.js @@ -1,8 +1,13 @@ +/** + * @import { DomstackManifestEntry } from '#types' + */ + import { test } from 'node:test' import assert from 'node:assert' -import { testBuild } from '../../index.js' +import { DOMSTACK_MANIFEST_SCHEMA_ID, DomStack, buildDomstackManifest, testBuild } from '../../index.js' import * as path from 'path' -import { stat, readFile } from 'fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' import * as cheerio from 'cheerio' import { allFiles } from 'async-folder-walker' @@ -19,6 +24,446 @@ test.describe('general-features', () => { }) assert.ok(results, 'DomStack built site and returned build results') + assert.ok(results.domstackManifest, 'build returned a domstack manifest') + assert.strictEqual( + results.domstackManifest.$schema, + DOMSTACK_MANIFEST_SCHEMA_ID, + 'domstack manifest includes its schema URL' + ) + + const domstackManifestPath = path.join(dest, 'domstack-manifest.json') + /** @type {{ $schema: string, version: string, entries: Record[] }} */ + const writtenDomstackManifest = JSON.parse(await readFile(domstackManifestPath, 'utf8')) + assert.strictEqual( + writtenDomstackManifest.$schema, + results.domstackManifest.$schema, + 'written domstack manifest schema URL matches returned domstack manifest' + ) + assert.strictEqual( + writtenDomstackManifest.version, + results.domstackManifest.version, + 'written domstack manifest matches returned domstack manifest' + ) + assert.ok( + writtenDomstackManifest.entries.every(entry => !('filepath' in entry)), + 'written domstack manifest does not expose absolute filesystem paths' + ) + + const manifestEntries = /** @type {DomstackManifestEntry[]} */ (results.domstackManifest.entries) + const manifestEntryByUrl = new Map(manifestEntries.map(entry => [entry.url, entry])) + + assert.ok(manifestEntryByUrl.has('/'), 'domstack manifest includes root page URL') + assert.ok(manifestEntryByUrl.has('/md-page/'), 'domstack manifest includes nested page URL') + assert.ok(manifestEntryByUrl.has('/md-page/loose-md.html'), 'domstack manifest includes loose markdown URL') + assert.ok(manifestEntryByUrl.has('/feeds/feed.json'), 'domstack manifest includes normal template output') + assert.ok(manifestEntryByUrl.has('/worker-page/workers.json'), 'domstack manifest includes worker manifest') + assert.ok( + !manifestEntryByUrl.has('/domstack-manifest.json'), + 'domstack manifest does not include itself' + ) + + const serviceWorkerEntry = manifestEntryByUrl.get('/service-worker.js') + assert.equal(serviceWorkerEntry?.kind, 'service-worker', 'domstack manifest classifies the site service worker') + assert.equal( + serviceWorkerEntry?.sourceRelname, + 'globals/service-worker.mts', + 'domstack manifest records the service worker source file' + ) + + assert.ok( + manifestEntries.some(entry => entry.kind === 'chunk' && entry.url.startsWith('/chunks/js/chunk-')), + 'domstack manifest classifies shared JS chunks' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'sourcemap' && entry.url.endsWith('.map')), + 'domstack manifest classifies source maps' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'worker' && entry.url.includes('/worker-page/counter.worker-')), + 'domstack manifest classifies worker bundles' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'copy' && entry.url === '/oldsite/client.js'), + 'domstack manifest includes copy directory outputs' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'static' && entry.url === '/static.json'), + 'domstack manifest includes static outputs' + ) + assert.deepStrictEqual( + manifestEntryByUrl.get('/js-page/')?.page?.vars, + { precache: false, offline: false }, + 'domstack manifest includes page-level precache/offline vars' + ) + + for (const entry of manifestEntries) { + assert.ok(entry.url.startsWith('/'), `${entry.outputRelname} has an absolute URL`) + assert.ok(entry.revision, `${entry.outputRelname} has a content revision`) + assert.ok(Number.isInteger(entry.bytes), `${entry.outputRelname} has byte size`) + } + + const serviceWorkerContent = await readFile(path.join(dest, 'service-worker.js'), 'utf8') + assert.ok(serviceWorkerContent.includes('/domstack-manifest.json'), 'service worker was bundled') + assert.ok(!serviceWorkerContent.includes('process.env.DOMSTACK_MANIFEST_URL'), 'service worker receives the domstack manifest URL define') + assert.ok(!serviceWorkerContent.includes('process.env.DOMSTACK_MANIFEST_ENABLED'), 'service worker receives the domstack manifest enabled define') + assert.ok(serviceWorkerContent.includes('cache: "no-store"'), 'service worker fetches the domstack manifest at runtime') + assert.ok(serviceWorkerContent.includes('caches.match(request)'), 'service worker has cache-first fetch handling') + + const metaContent = await readFile(path.join(dest, 'domstack-esbuild-meta.json'), 'utf8') + const metaData = JSON.parse(metaContent) + assert.ok( + Object.keys(metaData.outputs).some(outputPath => outputPath.endsWith('/service-worker.js')), + 'esbuild metafile includes the service worker output' + ) + + const stableSite = new DomStack(src, dest, { copy: [path.join(__dirname, './copyfolder')] }) + const stableResults = await stableSite.build() + + assert.strictEqual( + stableResults.domstackManifest?.version, + results.domstackManifest.version, + 'domstack manifest version is stable across identical builds' + ) + + await t.test('domstackManifest version includes cache-relevant metadata', async () => { + const basePage = { + path: '', + url: '/', + vars: { + precache: true, + offline: true, + }, + } + /** @type {DomstackManifestEntry} */ + const baseEntry = { + outputRelname: 'index.html', + kind: 'page', + url: '/', + revision: 'same-file-revision', + bytes: 42, + sourceRelname: 'pages/index.js', + page: basePage, + } + + const baseManifest = await buildDomstackManifest({ dest, entries: [baseEntry] }) + const sourceOnlyManifest = await buildDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + sourceRelname: 'pages/renamed-index.js', + }], + }) + const kindChangedManifest = await buildDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + kind: 'template', + }], + }) + const offlineChangedManifest = await buildDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + page: { + ...basePage, + vars: { + ...basePage.vars, + offline: false, + }, + }, + }], + }) + const objectPrecacheManifest = await buildDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + page: { + ...basePage, + vars: { + ...basePage.vars, + precache: { core: true, priority: 1 }, + }, + }, + }], + }) + const reorderedObjectPrecacheManifest = await buildDomstackManifest({ + dest, + entries: [{ + ...baseEntry, + page: { + ...basePage, + vars: { + ...basePage.vars, + precache: { priority: 1, core: true }, + }, + }, + }], + }) + + assert.strictEqual( + sourceOnlyManifest.version, + baseManifest.version, + 'source metadata does not affect domstack manifest version' + ) + assert.notStrictEqual( + kindChangedManifest.version, + baseManifest.version, + 'kind affects domstack manifest version' + ) + assert.notStrictEqual( + offlineChangedManifest.version, + baseManifest.version, + 'page offline policy affects domstack manifest version' + ) + assert.strictEqual( + reorderedObjectPrecacheManifest.version, + objectPrecacheManifest.version, + 'object-valued page cache policy uses stable key ordering' + ) + }) + + await t.test('domstackManifest exclude handles root page URL', async () => { + const excludeBuild = await testBuild(src, { + copy: [path.join(__dirname, './copyfolder')], + domstackManifest: { + exclude: ['oldsite/**'], + }, + }) + t.after(async () => { + await excludeBuild.cleanup() + }) + + const excludeEntries = /** @type {DomstackManifestEntry[]} */ (excludeBuild.results.domstackManifest?.entries ?? []) + + assert.ok( + excludeEntries.some(entry => entry.url === '/'), + 'root page URL survives non-root exclude filters' + ) + assert.ok( + !excludeEntries.some(entry => entry.url.startsWith('/oldsite/')), + 'exclude filters still remove matching output paths' + ) + }) + + await t.test('domstack-manifest.settings.js filters domstack manifest entries', async (t) => { + const settingsSrc = await mkdtemp(path.join(tmpdir(), 'domstack-manifest-settings-')) + const settingsDest = await mkdtemp(path.join(tmpdir(), 'domstack-manifest-settings-public-')) + t.after(async () => { + await rm(settingsSrc, { recursive: true, force: true }) + await rm(settingsDest, { recursive: true, force: true }) + }) + + await mkdir(path.join(settingsSrc, 'kept'), { recursive: true }) + await mkdir(path.join(settingsSrc, 'programmatic'), { recursive: true }) + await mkdir(path.join(settingsSrc, 'settings'), { recursive: true }) + await writeFile(path.join(settingsSrc, 'page.js'), 'export default () => "

Domstack manifest settings

"\n') + await writeFile(path.join(settingsSrc, 'service-worker.js'), 'console.log(process.env.DOMSTACK_MANIFEST_URL)\n') + await writeFile(path.join(settingsSrc, 'kept/page.js'), 'export default () => "

Kept

"\n') + await writeFile(path.join(settingsSrc, 'programmatic/page.js'), 'export default () => "

Programmatic exclude

"\n') + await writeFile(path.join(settingsSrc, 'settings/page.js'), 'export default () => "

Settings exclude

"\n') + await writeFile(path.join(settingsSrc, 'domstack-manifest.settings.js'), ` +export default async function domstackManifestSettings () { + return { + filename: 'settings/domstack-manifest.json', + exclude: ['settings/**'], + includeEntry (entry) { + return entry.kind !== 'sourcemap' + }, + } +} +`) + + const settingsSite = new DomStack(settingsSrc, settingsDest, { + domstackManifest: { + exclude: ['programmatic/**'], + }, + }) + + const settingsResults = await settingsSite.build() + const settingsEntries = /** @type {DomstackManifestEntry[]} */ (settingsResults.domstackManifest?.entries ?? []) + + const settingsServiceWorkerContent = await readFile(path.join(settingsDest, 'service-worker.js'), 'utf8') + + await stat(path.join(settingsDest, 'settings/domstack-manifest.json')) + await assert.rejects( + () => stat(path.join(settingsDest, 'domstack-manifest.json')), + 'domstack-manifest.settings.js custom filename replaces the default manifest path' + ) + assert.ok( + settingsServiceWorkerContent.includes('/settings/domstack-manifest.json'), + 'service worker define receives domstack-manifest.settings.js custom filename' + ) + assert.ok( + settingsEntries.some(entry => entry.url === '/'), + 'root page survives domstack manifest settings filters' + ) + assert.ok( + settingsEntries.some(entry => entry.url === '/kept/'), + 'unfiltered page output remains in manifest' + ) + assert.ok( + !settingsEntries.some(entry => entry.url === '/programmatic/'), + 'programmatic domstackManifest exclude is applied' + ) + assert.ok( + !settingsEntries.some(entry => entry.url === '/settings/'), + 'domstack-manifest.settings.js exclude is applied' + ) + assert.ok( + !settingsEntries.some(entry => entry.kind === 'sourcemap'), + 'domstack-manifest.settings.js includeEntry is applied' + ) + }) + + await t.test('metafile false skips esbuild metadata without breaking domstack manifest', async () => { + const noMetaBuild = await testBuild(src, { + copy: [path.join(__dirname, './copyfolder')], + metafile: false, + }) + t.after(async () => { + await noMetaBuild.cleanup() + }) + + const noMetaDest = noMetaBuild.dest + const noMetaResults = noMetaBuild.results + const noMetaEntries = /** @type {DomstackManifestEntry[]} */ (noMetaResults.domstackManifest?.entries ?? []) + + assert.ok(noMetaResults.domstackManifest, 'build returned a domstack manifest with metafile disabled') + assert.ok( + noMetaEntries.some(entry => entry.kind === 'script'), + 'domstack manifest still includes esbuild script outputs' + ) + assert.ok( + noMetaEntries.some(entry => entry.kind === 'sourcemap'), + 'domstack manifest still includes esbuild sourcemap outputs' + ) + assert.ok( + !noMetaEntries.some(entry => entry.kind === 'metadata' && entry.url === '/domstack-esbuild-meta.json'), + 'domstack manifest does not include skipped esbuild metafile' + ) + await assert.rejects( + () => stat(path.join(noMetaDest, 'domstack-esbuild-meta.json')), + 'esbuild metafile was not written' + ) + }) + + await t.test('service worker define follows nested custom domstackManifest filename', async () => { + const customManifestBuild = await testBuild(src, { + domstackManifest: { + filename: 'metadata/domstack-manifest.json', + }, + }) + t.after(async () => { + await customManifestBuild.cleanup() + }) + + const customManifestDest = customManifestBuild.dest + const customServiceWorkerContent = await readFile(path.join(customManifestDest, 'service-worker.js'), 'utf8') + + await stat(path.join(customManifestDest, 'metadata/domstack-manifest.json')) + await assert.rejects( + () => stat(path.join(customManifestDest, 'domstack-manifest.json')), + 'default domstack manifest filename was not written' + ) + assert.ok( + customServiceWorkerContent.includes('/metadata/domstack-manifest.json'), + 'service worker receives the nested custom domstack manifest URL define' + ) + }) + + await t.test('esbuild settings cannot drop reserved DOMSTACK defines', async (t) => { + const defineSrc = await mkdtemp(path.join(tmpdir(), 'domstack-esbuild-defines-')) + t.after(async () => { + await rm(defineSrc, { recursive: true, force: true }) + }) + + await writeFile(path.join(defineSrc, 'page.js'), 'export default () => "

DOMSTACK defines

"\n') + await writeFile(path.join(defineSrc, 'global.client.js'), ` +console.log( + process.env.DOMSTACK_MANIFEST_URL, + process.env.DOMSTACK_SERVICE_WORKER_URL, + process.env.CUSTOM_DEFINE +) +`) + await writeFile(path.join(defineSrc, 'service-worker.js'), ` +console.log( + process.env.DOMSTACK_MANIFEST_URL, + process.env.DOMSTACK_SERVICE_WORKER_SCOPE, + process.env.CUSTOM_DEFINE +) +`) + await writeFile(path.join(defineSrc, 'esbuild.settings.js'), ` +export default function esbuildSettings (opts) { + return { + ...opts, + define: { + 'process.env.CUSTOM_DEFINE': JSON.stringify('from-settings'), + }, + } +} +`) + + const defineBuild = await testBuild(defineSrc) + t.after(async () => { + await defineBuild.cleanup() + }) + + const defineFiles = await allFiles(defineBuild.dest, { shaper: fwData => fwData }) + const globalClientFile = defineFiles.find(file => file.relname.match(/global\.client-.+\.js$/)) + assert.ok(globalClientFile, 'global client bundle was written') + + const globalClientContent = await readFile(path.join(defineBuild.dest, globalClientFile.relname), 'utf8') + const defineServiceWorkerContent = await readFile(path.join(defineBuild.dest, 'service-worker.js'), 'utf8') + + assert.ok(globalClientContent.includes('/domstack-manifest.json'), 'global client keeps domstack manifest URL define') + assert.ok(globalClientContent.includes('/service-worker.js'), 'global client keeps service worker URL define') + assert.ok(globalClientContent.includes('from-settings'), 'global client keeps user esbuild define') + assert.ok(!globalClientContent.includes('process.env.DOMSTACK_'), 'global client has no unreplaced DOMSTACK defines') + assert.ok(defineServiceWorkerContent.includes('/domstack-manifest.json'), 'service worker keeps domstack manifest URL define') + assert.match(defineServiceWorkerContent, /["']\/["']/, 'service worker keeps service worker scope define') + assert.ok(defineServiceWorkerContent.includes('from-settings'), 'service worker keeps user esbuild define') + assert.ok(!defineServiceWorkerContent.includes('process.env.DOMSTACK_'), 'service worker has no unreplaced DOMSTACK defines') + }) + + await t.test('esbuild settings cannot override reserved DOMSTACK defines', async (t) => { + const conflictSrc = await mkdtemp(path.join(tmpdir(), 'domstack-esbuild-define-conflict-')) + const conflictDest = await mkdtemp(path.join(tmpdir(), 'domstack-esbuild-define-conflict-public-')) + t.after(async () => { + await rm(conflictSrc, { recursive: true, force: true }) + await rm(conflictDest, { recursive: true, force: true }) + }) + + await writeFile(path.join(conflictSrc, 'page.js'), 'export default () => "

DOMSTACK define conflict

"\n') + await writeFile(path.join(conflictSrc, 'global.client.js'), 'console.log(process.env.DOMSTACK_MANIFEST_URL)\n') + await writeFile(path.join(conflictSrc, 'esbuild.settings.js'), ` +export default function esbuildSettings (opts) { + return { + ...opts, + define: { + ...opts.define, + 'process.env.DOMSTACK_MANIFEST_URL': JSON.stringify('/not-domstack-owned.json'), + }, + } +} +`) + + await assert.rejects( + () => new DomStack(conflictSrc, conflictDest).build(), + error => { + if (!(error instanceof Error)) return false + const buildError = /** @type {Error & { errors?: Array }} */ (error) + return error.message.includes('Prebuild finished but there were errors') && + buildError.errors?.some(err => { + const cause = err.cause + return err.message.includes('Error building JS+CSS with esbuild') && + cause instanceof Error && + cause.message.includes('process.env.DOMSTACK_MANIFEST_URL') && + cause.message.includes('reserved by domstack') + }) === true + }, + 'reserved DOMSTACK define conflicts fail clearly' + ) + }) const globalAssets = { globalStyle: true, diff --git a/test-cases/general-features/src/domstack-manifest.settings.js b/test-cases/general-features/src/domstack-manifest.settings.js new file mode 100644 index 00000000..b1c6ea43 --- /dev/null +++ b/test-cases/general-features/src/domstack-manifest.settings.js @@ -0,0 +1 @@ +export default {} diff --git a/test-cases/general-features/src/globals/service-worker.mts b/test-cases/general-features/src/globals/service-worker.mts new file mode 100644 index 00000000..1bb2de65 --- /dev/null +++ b/test-cases/general-features/src/globals/service-worker.mts @@ -0,0 +1,68 @@ +import { CACHE_PREFIX, DOMSTACK_MANIFEST_URL, loadManifest } from '../libs/service-worker-helper.js' + +type DomstackManifestEntry = { + revision?: unknown, + kind: string, + url: string +} + +type DomstackManifest = { + version: string, + entries: DomstackManifestEntry[] +} + +type ServiceWorkerExtendableEvent = Event & { + waitUntil (promise: Promise): void +} + +type ServiceWorkerFetchEvent = Event & { + request: Request, + respondWith (response: Promise): void +} + +self.addEventListener('install', event => { + const installEvent = event as ServiceWorkerExtendableEvent + installEvent.waitUntil(precache()) +}) + +self.addEventListener('activate', event => { + const activateEvent = event as ServiceWorkerExtendableEvent + activateEvent.waitUntil(cleanup()) +}) + +self.addEventListener('fetch', event => { + const fetchEvent = event as ServiceWorkerFetchEvent + if (fetchEvent.request.method !== 'GET') return + fetchEvent.respondWith(cacheFirst(fetchEvent.request)) +}) + +async function precache () { + const manifest = await loadDomstackManifest() + const cache = await caches.open(CACHE_PREFIX + manifest.version) + const urls = manifest.entries + .filter(entry => entry.revision) + .filter(entry => entry.kind !== 'sourcemap') + .filter(entry => entry.kind !== 'metadata') + .filter(entry => entry.url !== DOMSTACK_MANIFEST_URL) + .map(entry => entry.url) + + await cache.addAll(urls) +} + +async function cleanup () { + const manifest = await loadDomstackManifest() + const current = CACHE_PREFIX + manifest.version + const names = await caches.keys() + await Promise.all(names + .filter(name => name.startsWith(CACHE_PREFIX) && name !== current) + .map(name => caches.delete(name))) +} + +async function cacheFirst (request: Request) { + const cached = await caches.match(request) + return cached || fetch(request) +} + +async function loadDomstackManifest (): Promise { + return await loadManifest() as DomstackManifest +} diff --git a/test-cases/general-features/src/js-page/page.vars.js b/test-cases/general-features/src/js-page/page.vars.js index 7cccd01c..f061ca5d 100644 --- a/test-cases/general-features/src/js-page/page.vars.js +++ b/test-cases/general-features/src/js-page/page.vars.js @@ -1,3 +1,5 @@ export default { somePageVars: 'foo', + precache: false, + offline: false, } diff --git a/test-cases/general-features/src/libs/service-worker-helper.js b/test-cases/general-features/src/libs/service-worker-helper.js new file mode 100644 index 00000000..3ab8c7f9 --- /dev/null +++ b/test-cases/general-features/src/libs/service-worker-helper.js @@ -0,0 +1,12 @@ +export const DOMSTACK_MANIFEST_URL = process.env['DOMSTACK_MANIFEST_URL'] ?? '' +export const DOMSTACK_MANIFEST_ENABLED = process.env['DOMSTACK_MANIFEST_ENABLED'] ?? 'false' +export const CACHE_PREFIX = 'domstack-precache-' + +export async function loadManifest () { + if (DOMSTACK_MANIFEST_ENABLED !== 'true') { + throw new Error('Domstack manifest is not enabled') + } + const response = await fetch(DOMSTACK_MANIFEST_URL, { cache: 'no-store' }) + if (!response.ok) throw new Error('Unable to load domstack manifest') + return response.json() +} diff --git a/test-cases/template-output-escape/index.test.js b/test-cases/template-output-escape/index.test.js new file mode 100644 index 00000000..89064ba6 --- /dev/null +++ b/test-cases/template-output-escape/index.test.js @@ -0,0 +1,28 @@ +import { test } from 'node:test' +import assert from 'node:assert' +import { rm } from 'node:fs/promises' +import path from 'node:path' +import { DomStack } from '../../index.js' + +const __dirname = import.meta.dirname + +test('template outputs cannot escape dest', async (t) => { + const src = path.join(__dirname, './src') + const dest = path.join(__dirname, './public') + const siteUp = new DomStack(src, dest) + + t.after(async () => { + await rm(dest, { recursive: true, force: true }) + }) + + await rm(dest, { recursive: true, force: true }) + + await assert.rejects( + () => siteUp.build(), + error => { + assert.ok(error instanceof Error) + assert.match(error.message, /Build finished/) + return true + } + ) +}) diff --git a/test-cases/template-output-escape/src/README.md b/test-cases/template-output-escape/src/README.md new file mode 100644 index 00000000..e23a3d12 --- /dev/null +++ b/test-cases/template-output-escape/src/README.md @@ -0,0 +1 @@ +# Template output escape fixture diff --git a/test-cases/template-output-escape/src/escape.template.js b/test-cases/template-output-escape/src/escape.template.js new file mode 100644 index 00000000..0f6a0d7c --- /dev/null +++ b/test-cases/template-output-escape/src/escape.template.js @@ -0,0 +1,6 @@ +export default function escapeTemplate () { + return { + outputName: '../escape.txt', + content: 'bad output', + } +} diff --git a/test-cases/type-exports/index.test.ts b/test-cases/type-exports/index.test.ts index 28f7f3c2..410de98b 100644 --- a/test-cases/type-exports/index.test.ts +++ b/test-cases/type-exports/index.test.ts @@ -1,12 +1,27 @@ import { test } from 'node:test' import assert from 'node:assert' -import { PageData } from '../../index.js' +import { readFile } from 'node:fs/promises' +import { + DOMSTACK_MANIFEST_SCHEMA_ID, + DOMSTACK_MANIFEST_SCHEMA_PATH, + PageData, + domstackManifestEntryPageMetaSchema, + domstackManifestEntrySchema, + domstackManifestKindSchema, + domstackManifestSchema, + getDomstackManifestSchemaId, +} from '../../index.js' import type { AsyncGlobalDataFunction, AsyncLayoutFunction, AsyncPageFunction, BuildOptions, DomStackOpts, + DomstackManifest, + DomstackManifestEntry, + DomstackManifestEntryPageMeta, + DomstackManifestKind, + DomstackManifestRecord, GlobalDataFunction, GlobalDataFunctionParams, LayoutFunction, @@ -15,6 +30,7 @@ import type { PageFunctionParams, PageInfo, Results, + ServiceWorkerInfo, SiteData, TemplateAsyncIterator, TemplateFunction, @@ -48,6 +64,12 @@ const templateInfo: TemplateInfo = { outputName: 'feed.xml', } +const serviceWorkerInfo: ServiceWorkerInfo = { + ...walkerFile, + basename: 'service-worker.js', + relname: 'globals/service-worker.js', +} + const pageData = new PageData({ pageInfo, globalVars: {}, @@ -108,6 +130,33 @@ const domStackOpts: DomStackOpts = { const siteData: SiteData | null = null const results: Results | null = null const testBuildResult: TestBuildResult | null = null +const domstackManifestKind: DomstackManifestKind = 'page' +const domstackManifestEntryPageMeta: DomstackManifestEntryPageMeta = { + path: '', + url: '/', + vars: { + precache: true, + }, +} +const domstackManifestEntry: DomstackManifestEntry = { + outputRelname: 'index.html', + kind: domstackManifestKind, + url: '/', + revision: 'revision', + bytes: 42, + page: domstackManifestEntryPageMeta, +} +const domstackManifest: DomstackManifest = { + $schema: DOMSTACK_MANIFEST_SCHEMA_ID, + version: 'version', + generatedAt: new Date(0).toISOString(), + entries: [domstackManifestEntry], +} +const domstackManifestRecord: DomstackManifestRecord = { + outputRelname: 'index.html', + filepath: '/public/index.html', + kind: 'page', +} void layoutParams void pageParams @@ -124,10 +173,66 @@ void asyncGlobalDataFunction void templateOutputOverride void buildOptions void domStackOpts +void serviceWorkerInfo void siteData void results void testBuildResult +void domstackManifestKind +void domstackManifestEntryPageMeta +void domstackManifestEntry +void domstackManifest +void domstackManifestRecord test('PageData is importable from the package entry point', () => { assert.strictEqual(typeof PageData, 'function', 'PageData is a class') }) + +test('domstack manifest schemas are importable from the package entry point', async () => { + const [schemaJson, packageJson] = await Promise.all([ + readFile(new URL('../../lib/domstack-manifest/schema.json', import.meta.url), 'utf8'), + readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ]) + const schemaFile = JSON.parse(schemaJson) + const packageInfo = JSON.parse(packageJson) + + assert.deepStrictEqual( + schemaFile, + domstackManifestSchema, + 'packaged schema.json matches exported schema' + ) + assert.ok( + DOMSTACK_MANIFEST_SCHEMA_ID.includes(`@${packageInfo.version}/`), + 'manifest schema ID includes the package version' + ) + assert.strictEqual( + DOMSTACK_MANIFEST_SCHEMA_ID, + getDomstackManifestSchemaId(packageInfo.version), + 'schema ID helper builds the exported schema ID from the package version' + ) + assert.strictEqual( + DOMSTACK_MANIFEST_SCHEMA_PATH, + 'lib/domstack-manifest/schema.json', + 'schema path points at the packaged JSON schema' + ) + assert.strictEqual( + domstackManifestSchema.$id, + DOMSTACK_MANIFEST_SCHEMA_ID, + 'manifest schema uses exported schema ID' + ) + assert.strictEqual( + domstackManifestSchema.properties.$schema.const, + DOMSTACK_MANIFEST_SCHEMA_ID, + 'manifest instance schema field uses exported schema ID' + ) + assert.ok(domstackManifestKindSchema.enum.includes('page'), 'kind schema includes pages') + assert.strictEqual( + domstackManifestEntrySchema.properties.page, + domstackManifestEntryPageMetaSchema, + 'entry schema uses page metadata schema' + ) + assert.strictEqual( + domstackManifestSchema.properties.entries.items, + domstackManifestEntrySchema, + 'manifest schema uses entry schema' + ) +}) diff --git a/test-cases/watch/index.test.js b/test-cases/watch/index.test.js index 98df368e..b925b5de 100644 --- a/test-cases/watch/index.test.js +++ b/test-cases/watch/index.test.js @@ -89,10 +89,20 @@ test.describe('watch', () => { const results = await domStack.watch({ serve: false }) assert.ok(results, 'watch() returned initial build results') assert.ok(results.siteData, 'results include siteData') + assert.equal(results.domstackManifest, undefined, 'watch mode does not return a domstack manifest') const jsPageIndex = path.join(dest, 'js-page/index.html') const st = await stat(jsPageIndex) assert.ok(st.isFile(), 'js-page/index.html was built') + await assert.rejects( + () => stat(path.join(dest, 'domstack-manifest.json')), + 'watch mode does not write a domstack manifest' + ) + const serviceWorkerStat = await stat(path.join(dest, 'service-worker.js')) + assert.ok(serviceWorkerStat.isFile(), 'watch mode builds site service-worker entries') + const serviceWorkerContent = await readFile(path.join(dest, 'service-worker.js'), 'utf8') + assert.ok(!serviceWorkerContent.includes('process.env.DOMSTACK_MANIFEST_ENABLED'), 'watch service worker receives the domstack manifest enabled define') + assert.ok(serviceWorkerContent.includes('"false"'), 'watch service worker knows the domstack manifest is disabled') // ── Chunks have hashed names in watch mode ─────────────────────── // html-page/client.js, js-page/client.js, and md-page/client.js all import @@ -182,6 +192,28 @@ test.describe('watch', () => { ) }) + // ── service worker change → esbuild rebuilds, no page rebuild ─── + await t.test('service worker change does not rebuild pages', async () => { + mockLog.mock.resetCalls() + loggerLogs.length = 0 + + const serviceWorkerFile = path.join(src, 'globals/service-worker.mts') + const original = await readFile(serviceWorkerFile, 'utf8') + await writeFile(serviceWorkerFile, original + '\n// touch') + + await settle(domStack) + + const logs = getLogLines(mockLog, loggerLogs) + assert.ok( + logs.some(l => l.includes('esbuild will handle rebundling')), + 'domstack lets esbuild rebundle the service worker' + ) + assert.ok( + !logs.some(l => l.includes('Pages built')), + 'no page rebuild was triggered' + ) + }) + // ── esbuild dep change → esbuild rebuilds, no page rebuild ───── await t.test('changing a client.js dependency triggers esbuild rebuild only', async () => { mockLog.mock.resetCalls() diff --git a/types.ts b/types.ts index 37059fb7..098b2cad 100644 --- a/types.ts +++ b/types.ts @@ -27,7 +27,14 @@ export type { TemplateFunctionParams, TemplateOutputOverride, } from './lib/build-pages/page-builders/template-builder.js' -export type { PageInfo, TemplateInfo } from './lib/identify-pages.js' +export type { PageInfo, ServiceWorkerInfo, TemplateInfo } from './lib/identify-pages.js' +export type { + DomstackManifest, + DomstackManifestEntry, + DomstackManifestEntryPageMeta, + DomstackManifestKind, + DomstackManifestRecord, +} from './lib/domstack-manifest/index.js' export type TestBuildResult = { dest: string