diff --git a/.gitignore b/.gitignore index 1f2e3fe..62d4ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ lcov.info *.d.ts.map !types/**/*.d.ts !types/**/*.d.ts.map +pnpm-workspace.yaml +pnpm-lock.yaml diff --git a/.npmignore b/.npmignore deleted file mode 100644 index ad66337..0000000 --- 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 9495137..7845308 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ domstack v11 is a major release that renames the project from `top-bun` to `@dom - **CLI**: `top-bun`/`tb` → `domstack`/`dom` - **Programmatic API**: `TopBun` class → `DomStack`, all `TopBun*` types/errors/warnings renamed to `DomStack*` - **`postVars` removed**: migrate `postVars` exports from `page.vars.js` files to a single `global.data.js` with a default export -- **New reserved filenames**: `global.data.js`, `markdown-it.settings.js`, `page.md`, `*.worker.{js,ts}` are now special — rename any colliding files +- **New reserved filenames**: `global.data.js`, `manifest.settings.js`, `markdown-it.settings.js`, `page.md`, `service-worker.*`, `*.worker.{js,ts}` are now special — rename any colliding files - **Default layout**: switched from `uhtml-isomorphic` to `preact`; add `uhtml-isomorphic` to your own deps if you import it directly - **Output paths**: `top-bun-esbuild-meta.json` → `domstack-esbuild-meta.json`, `top-bun-defaults/` → `domstack-defaults/` - **Conflict now throws**: using both `browser` in `global.vars.js` and `define` in `esbuild.settings.js` is now a hard error @@ -55,6 +55,10 @@ 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. + --target, -t comma separated target strings for esbuild + --noEsbuildMeta skip writing the esbuild metafile to disk + --outputManifest write the domstack output manifest to this filename + --noOutputManifest skip writing the domstack output 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 @@ -124,7 +128,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 +│ ├── manifest.settings.ts # You can customize the build output 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. @@ -139,6 +145,10 @@ It ships with sane defaults so that you can point `domstack` at a standard [mark A collection of examples can be found in the [`./examples`](./examples) folder. +Notable examples: + +- [`examples/pwa`](./examples/pwa) - A static PWA with a site service worker, output-manifest filtering, update prompts, offline fallback, and cache recovery behavior. + To run examples: ```bash @@ -951,6 +961,207 @@ const feedsTemplate: TemplateAsyncIterator = async function * ({ export default feedsTemplate ``` +## Build Output Manifest + +Every programmatic build returns an `outputManifest` object in the build results. The CLI also writes +`domstack-output-manifest.json` into the destination directory by default. + +The manifest is a normalized list of files that domstack emitted: + +```ts +import type { FromSchema } from 'json-schema-to-ts' +import { BUILD_OUTPUT_MANIFEST_SCHEMA_ID, buildOutputManifestSchema } from '@domstack/static' + +type BuildOutputManifest = FromSchema + +// Equivalent shape: +type BuildOutputManifestShape = { + $schema: typeof BUILD_OUTPUT_MANIFEST_SCHEMA_ID + version: string + generatedAt: string + entries: BuildOutputEntry[] +} + +type BuildOutputEntry = { + 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 `BUILD_OUTPUT_MANIFEST_SCHEMA_ID`, `BUILD_OUTPUT_MANIFEST_SCHEMA_PATH`, +`getBuildOutputManifestSchemaId(version)`, `buildOutputManifestSchema`, `buildOutputEntrySchema`, +`buildOutputEntryPageMetaSchema`, and `buildOutputKindSchema` for tools that want the JSON Schema +contract directly. The public `BuildOutputManifest`, `BuildOutputEntry`, `BuildOutputEntryPageMeta`, +and `BuildOutputKind` 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 --outputManifest build-output.json +domstack --noOutputManifest +``` + +Or from the programmatic API: + +```js +const site = new DomStack('src', 'public', { + outputManifest: { + filename: 'build-output.json', + exclude: ['blog/**', '**/*.map'], + }, +}) + +const results = await site.build() +``` + +`outputManifest: false` disables writing the JSON file, but `results.outputManifest` is still returned. +The manifest file itself is never included in its own `entries`. + +You can also add a `manifest.settings.js` file anywhere under `src`: + +```js +export default { + exclude: ['admin/**'], + includeOutput (entry) { + return entry.kind !== 'sourcemap' && entry.kind !== 'metadata' + }, +} +``` + +`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 manifestSettings () { + return { + exclude: process.env.INCLUDE_BLOG_OFFLINE === '1' + ? ['**/*.map'] + : ['blog/**', '**/*.map'], + } +} +``` + +`outputManifest.exclude` from CLI/programmatic options and `manifest.settings.*` `exclude` values +are combined. Exclude patterns are ignore-style patterns checked against both `entry.url` and +`entry.outputRelname`; excludes run before `includeOutput(entry)`. The `includeOutput(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_OUTPUT_MANIFEST_URL` | Public URL of the written output manifest, usually `/domstack-output-manifest.json` | +| `process.env.DOMSTACK_OUTPUT_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 output manifest during installation: + +```js +const DOMSTACK_MANIFEST_URL = process.env.DOMSTACK_OUTPUT_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_OUTPUT_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 +`manifest.settings.*` `exclude` or `includeOutput(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-output-manifest.json` or return `results.outputManifest`. Use one-shot builds when testing +service-worker and PWA cache behavior. + ## 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. @@ -979,6 +1190,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_OUTPUT_MANIFEST_URL`, +`process.env.DOMSTACK_OUTPUT_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. @@ -1053,6 +1267,37 @@ The returned object is stamped onto every page's vars before rendering, so any p Use `GlobalDataFunction` or `AsyncGlobalDataFunction` to type the function where `T` is the shape of the object you return. +### `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 build output manifest before domstack writes `domstack-output-manifest.json` +or returns `results.outputManifest`. + +```typescript +import type { BuildOutputEntry } from '@domstack/static' + +export default { + exclude: [ + 'admin/**', + '**/*.map', + ], + includeOutput (entry: BuildOutputEntry) { + return entry.kind !== 'metadata' + }, +} +``` + +The supported settings are: + +- `exclude` - ignore-style patterns matched against `entry.url` and `entry.outputRelname`. +- `includeOutput(entry)` - a sync or async function that receives a public `BuildOutputEntry` and returns `true` to keep it. + +The `outputManifest.exclude` option and `manifest.settings.*` `exclude` values are combined. +Excludes run before `includeOutput(entry)`. +Watch mode builds and rebundles service workers, but it does not write or return the output manifest, +so `manifest.settings.*` is only applied during one-shot builds. + ### `esbuild.settings.ts` This is an optional file you can create anywhere. @@ -1386,7 +1631,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 │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ └───────────────────┼───────────────────┘ @@ -1399,6 +1644,13 @@ The following diagram illustrates the DomStack build process: │ • Process MD │ │ • Process JS │ │ • Apply layouts │ + │ • Record outputs │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Reconcile │ + │ Output Manifest │ └────────┬─────────┘ │ ▼ @@ -1410,6 +1662,7 @@ The following diagram illustrates the DomStack build process: │ • staticResults │ │ • copyResults │ │ • pageResults │ + │ • outputManifest │ │ • warnings │ └──────────────────┘ ``` @@ -1418,11 +1671,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. @@ -1501,6 +1756,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. +Output manifests are build-only artifacts. Watch mode builds and rebundles site service-worker +entries, but it does not write `domstack-output-manifest.json` or return `results.outputManifest`. +Use a normal build when testing PWA cache lifecycle behavior. + #### What triggers what | Change | Rebuild scope | @@ -1514,7 +1773,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 | +| `manifest.settings.*` | No rebuild in watch mode; output 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 560ef5d..88ee2cf 100755 --- a/bin.js +++ b/bin.js @@ -65,6 +65,14 @@ const options = { type: 'boolean', help: 'skip writing the esbuild metafile to disk', }, + outputManifest: { + type: 'string', + help: 'write the domstack output manifest to this filename', + }, + noOutputManifest: { + type: 'boolean', + help: 'skip writing the domstack output manifest to disk', + }, eject: { type: 'boolean', short: 'e', @@ -205,6 +213,13 @@ domstack eject actions: 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['noOutputManifest']) opts.outputManifest = false + if (argv['outputManifest']) { + opts.outputManifest = { + ...(typeof opts.outputManifest === 'object' ? opts.outputManifest : {}), + filename: String(argv['outputManifest']), + } + } if (argv['drafts']) opts.buildDrafts = true if (argv['copy']) { const copyPaths = Array.isArray(argv['copy']) ? argv['copy'] : [argv['copy']] diff --git a/docs/v11-migration.md b/docs/v11-migration.md index d3925c4..8056552 100644 --- a/docs/v11-migration.md +++ b/docs/v11-migration.md @@ -201,7 +201,7 @@ Key differences: ## 8. New Reserved Filenames -Two new filenames are now recognized and processed by domstack. If you have existing files with these names used for other purposes, they will now be treated as special files: +New filenames are now recognized and processed by domstack. If you have existing files with these names used for other purposes, they will now be treated as special files: ### `global.data.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`) @@ -218,7 +218,12 @@ export default function (md) { } ``` -If you have a file with either of these names that was serving another purpose, rename it. +### `manifest.settings.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`) + +Now treated as the build output manifest settings file. Its default export can return `exclude` +patterns and an `includeOutput(entry)` filter for `domstack-output-manifest.json`. + +If you have a file with any of these names that was serving another purpose, rename it. --- @@ -370,7 +375,7 @@ const page: PageFunction = async ({ vars }) => { ... } - [ ] Replace `TopBunAggregateError`/`TopBunDuplicatePageError` with `DomStack*` equivalents - [ ] Replace `TOP_BUN_*` error/warning codes with `DOM_STACK_*` - [ ] Migrate `postVars` exports from `page.vars.js` to a `global.data.js` default export -- [ ] Rename any files accidentally named `global.data.js`, `markdown-it.settings.js`, `page.md`, or `*.worker.js` that weren't intended for those purposes +- [ ] Rename any files accidentally named `global.data.js`, `manifest.settings.js`, `markdown-it.settings.js`, `page.md`, or `*.worker.js` that weren't intended for those purposes - [ ] If using both `browser` in `global.vars.js` and `define` in `esbuild.settings.js`, consolidate to one - [ ] If importing `uhtml-isomorphic` from layouts without it in your own `package.json`, add it explicitly - [ ] Update any CI/scripts referencing `top-bun-esbuild-meta.json` → `domstack-esbuild-meta.json` diff --git a/examples/markdown-settings/src/markdown-it.settings.js b/examples/markdown-settings/src/markdown-it.settings.js index 542f4df..15855c8 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/pwa/README.md b/examples/pwa/README.md new file mode 100644 index 0000000..fcaf154 --- /dev/null +++ b/examples/pwa/README.md @@ -0,0 +1,59 @@ +# DOMStack PWA Example + +This example shows a production-style static PWA using Domstack's output manifest and first-class service-worker build support. + +It demonstrates: + +- A `service-worker.js` source that Domstack bundles to `/service-worker.js`. +- A `manifest.settings.js` file that filters the generated output manifest. +- A global client runtime that registers the worker, handles update prompts, and disables sticky caches during local watch development. +- Offline precaching for app, docs, legal-style pages, static assets, shared chunks, and the web app manifest. +- Excluding `/blog/**`, `/admin/**`, source maps, metadata files, and pages with `precache: false` or `offline: false`. + +## Running + +```bash +cd examples/pwa +npm install +npm run build +npx browser-sync start --server public --files public +``` + +Service workers require a secure origin. `localhost` is allowed by browsers, but this example disables PWA behavior on local origins by default so watch-mode builds cannot leave stale caches behind. To intentionally test the production worker locally: + +```js +localStorage.setItem('domstack:pwa-dev', '1') +``` + +Reload after setting that flag. To clear all example workers and caches: + +```txt +/?reset-sw=1 +``` + +## Files + +```txt +src/ + global.client.js # Registers the service worker through pwa/runtime.js + global.css # Site styles + global.vars.js # Shared page variables + manifest.settings.js # Filters the written output manifest + manifest.webmanifest # Web app manifest copied as a static asset + service-worker.js # Site service worker entry + pwa/ + cache-policy.js # Shared output-manifest filtering and constants + runtime.js # Browser registration/update/recovery behavior + sw/ + *.js # Service-worker install, update, cache, and fetch helpers +``` + +## Production Pattern + +The worker fetches `/domstack-output-manifest.json` during installation and uses the revisioned URLs in that file as its cache plan. The manifest is generated after Domstack reconciles pages, bundles, chunks, worker output, copied static assets, and templates. Application policy stays in app code: + +- `manifest.settings.js` decides what can ever enter the manifest. +- `service-worker.js` decides how to install, activate, update, and serve cached responses. +- `global.client.js` decides when to register, prompt, apply updates, or recover from a bad cache. + +Watch mode does not write the output manifest, so use `npm run build` when testing the offline lifecycle. diff --git a/examples/pwa/package.json b/examples/pwa/package.json new file mode 100644 index 0000000..b51aed0 --- /dev/null +++ b/examples/pwa/package.json @@ -0,0 +1,21 @@ +{ + "name": "@domstack/pwa-example", + "version": "0.0.0", + "description": "DOMStack PWA offline cache example", + "type": "module", + "scripts": { + "start": "npm run watch", + "build": "npm run clean && domstack", + "clean": "rm -rf public && mkdir -p public", + "watch": "npm run clean && dom --watch" + }, + "keywords": ["domstack", "pwa", "service-worker", "offline"], + "author": "", + "license": "MIT", + "dependencies": { + "@domstack/static": "file:../../." + }, + "devDependencies": { + "npm-run-all2": "^9.0.0" + } +} diff --git a/examples/pwa/src/README.md b/examples/pwa/src/README.md new file mode 100644 index 0000000..bfc6043 --- /dev/null +++ b/examples/pwa/src/README.md @@ -0,0 +1,77 @@ +--- +title: App Shell +--- + +
+
+
+

Static PWA shell

+

This page is built as ordinary static HTML, then the service worker uses Domstack's output manifest to cache the static shell for offline launches.

+
+ Pages + Bundles + Static assets + No API cache +
+
+
+
+ Worker + Not registered +
+
+ Cache version + Waiting for build manifest +
+
+ Network + Checking +
+
+
+ +
    +
  • +

    Docs

    +

    Docs are part of the first offline bundle.

    + Open docs +
    Precached
    +
  • +
  • +

    Legal

    +

    Legal-style static pages use the same offline policy as docs.

    + Open legal +
    Precached
    +
  • +
  • +

    Login

    +

    Auth shells can load offline while submissions remain network-only.

    + Open login +
    Precached
    +
  • +
  • +

    Offline fallback

    +

    Excluded navigations fall back to a small static page.

    + Open fallback +
    Precached
    +
  • +
  • +

    Blog

    +

    Blog pages are intentionally left out to reduce first install cost.

    + Open blog +
    Excluded
    +
  • +
  • +

    Admin

    +

    Protected routes should stay network-only.

    + Open admin +
    Excluded
    +
  • +
  • +

    Opted-out

    +

    Page vars can opt a static route out of precaching.

    + Open page +
    Excluded
    +
  • +
+
diff --git a/examples/pwa/src/admin/README.md b/examples/pwa/src/admin/README.md new file mode 100644 index 0000000..d823bb9 --- /dev/null +++ b/examples/pwa/src/admin/README.md @@ -0,0 +1,9 @@ +--- +title: Admin +precache: false +offline: false +--- + +# Admin + +This static route stands in for server-protected admin pages. It is excluded from the PWA manifest and the service worker treats `/admin/**` as network-only. diff --git a/examples/pwa/src/blog/README.md b/examples/pwa/src/blog/README.md new file mode 100644 index 0000000..951fb80 --- /dev/null +++ b/examples/pwa/src/blog/README.md @@ -0,0 +1,13 @@ +--- +title: Blog +precache: false +offline: false +--- + +# Blog + +This route is present in the static site but filtered out of the first PWA cache by `manifest.settings.js`. + +Its page vars also set `precache: false` and `offline: false`, which lets the cache policy reject it without relying only on path names. + +- [First post](/blog/first-post/) diff --git a/examples/pwa/src/blog/first-post/README.md b/examples/pwa/src/blog/first-post/README.md new file mode 100644 index 0000000..d7df1b3 --- /dev/null +++ b/examples/pwa/src/blog/first-post/README.md @@ -0,0 +1,9 @@ +--- +title: First Post +precache: false +offline: false +--- + +# First Post + +Blog content is excluded in this example so the first offline install stays small and stable. diff --git a/examples/pwa/src/docs/README.md b/examples/pwa/src/docs/README.md new file mode 100644 index 0000000..0e5ca41 --- /dev/null +++ b/examples/pwa/src/docs/README.md @@ -0,0 +1,23 @@ +--- +title: Docs +--- + +# Docs + +This route is intentionally included in the PWA cache. It represents static documentation, legal pages, help pages, or other public content that should remain available after the first online visit. + +The service worker gets this page from the generated output manifest instead of a handwritten list. + +## Cache Inputs + +- Page HTML, including `/docs/` +- Global CSS and JavaScript bundles +- Shared chunks +- Static icons and the web app manifest + +## Cache Exclusions + +- `/api/**` requests +- `/admin/**` routes +- `/blog/**` routes +- Source maps and Domstack metadata diff --git a/examples/pwa/src/global.client.js b/examples/pwa/src/global.client.js new file mode 100644 index 0000000..f9ed038 --- /dev/null +++ b/examples/pwa/src/global.client.js @@ -0,0 +1,5 @@ +import { initializePwa } from './pwa/runtime.js' + +initializePwa().catch(err => { + console.error('Unable to initialize the PWA runtime', err) +}) diff --git a/examples/pwa/src/global.css b/examples/pwa/src/global.css new file mode 100644 index 0000000..5ec58ad --- /dev/null +++ b/examples/pwa/src/global.css @@ -0,0 +1,372 @@ +:root { + color-scheme: light; + --bg: #f7f8fb; + --panel: #ffffff; + --text: #172033; + --muted: #5b6577; + --line: #d8deea; + --accent: #185abc; + --accent-ink: #ffffff; + --ok: #16835c; + --warn: #a05a00; + --danger: #b42318; + --shadow: 0 16px 40px rgb(24 42 78 / 10%); +} + +* { + box-sizing: border-box; +} + +html { + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; +} + +body { + margin: 0; + min-width: 320px; +} + +a { + color: var(--accent); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.16em; +} + +img { + max-width: 100%; +} + +button, +.button { + align-items: center; + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 6px; + color: var(--accent-ink); + cursor: pointer; + display: inline-flex; + font: inherit; + font-weight: 700; + gap: 0.4rem; + justify-content: center; + min-height: 2.5rem; + padding: 0.55rem 0.85rem; + text-decoration: none; +} + +button.secondary, +.button.secondary { + background: var(--panel); + color: var(--accent); +} + +button:focus-visible, +a:focus-visible { + outline: 3px solid #8ab4f8; + outline-offset: 3px; +} + +.skip-link { + background: var(--panel); + left: 1rem; + padding: 0.65rem 0.8rem; + position: fixed; + top: 1rem; + transform: translateY(-140%); + z-index: 10; +} + +.skip-link:focus { + transform: translateY(0); +} + +.site-header { + background: rgb(255 255 255 / 92%); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 3; +} + +.site-header__inner { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + margin: 0 auto; + max-width: 1120px; + padding: 0.8rem 1.25rem; +} + +.brand { + align-items: center; + color: var(--text); + display: inline-flex; + flex: 0 0 auto; + font-weight: 800; + gap: 0.55rem; + text-decoration: none; +} + +.brand img { + border-radius: 7px; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.8rem; + justify-content: flex-end; +} + +.site-nav a { + color: var(--muted); + font-size: 0.94rem; + font-weight: 650; + text-decoration: none; +} + +.page-shell { + margin: 0 auto; + max-width: 1120px; + padding: 2rem 1.25rem 4rem; +} + +.app-layout { + display: grid; + gap: 1.25rem; +} + +.app-summary { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + display: grid; + gap: 1.5rem; + grid-template-columns: minmax(0, 1.4fr) minmax(17rem, 0.6fr); + padding: clamp(1rem, 3vw, 2rem); +} + +.app-summary h1 { + font-size: clamp(2rem, 5vw, 4.5rem); + line-height: 0.94; + margin: 0; +} + +.app-summary p { + color: var(--muted); + font-size: 1.08rem; + margin: 0.75rem 0 0; + max-width: 62ch; +} + +.status-list { + border: 1px solid var(--line); + border-radius: 8px; + display: grid; + overflow: hidden; +} + +.status-row { + align-items: start; + background: #fbfcff; + display: grid; + gap: 0.25rem; + padding: 0.9rem 1rem; +} + +.status-row + .status-row { + border-top: 1px solid var(--line); +} + +.status-row span { + color: var(--muted); + font-size: 0.8rem; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.status-row strong { + overflow-wrap: anywhere; +} + +.route-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + list-style: none; + margin: 0; + padding: 0; +} + +.route-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + min-height: 12rem; + padding: 1.2rem; +} + +.route-card h2, +.route-card h3 { + font-size: 1.05rem; + margin: 0; +} + +.route-card p { + color: var(--muted); + margin: 0.65rem 0 1rem; +} + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.85rem; +} + +.pill { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 0.78rem; + font-weight: 750; + padding: 0.25rem 0.55rem; +} + +.pill.ok { + border-color: rgb(22 131 92 / 28%); + color: var(--ok); +} + +.pill.warn { + border-color: rgb(160 90 0 / 28%); + color: var(--warn); +} + +.section { + margin-top: 2rem; +} + +.section h2 { + margin-bottom: 0.75rem; +} + +.copy { + max-width: 75ch; +} + +.copy p, +.copy li { + color: var(--muted); +} + +.zero-state { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + margin: 3rem auto; + max-width: 42rem; + padding: clamp(1.25rem, 4vw, 2.25rem); + text-align: center; +} + +.zero-state h1 { + margin-top: 0; +} + +.zero-state p { + color: var(--muted); +} + +.form-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + margin-top: 1.5rem; + max-width: 34rem; + padding: 1.2rem; +} + +.form-panel label { + display: grid; + font-weight: 700; + gap: 0.35rem; + margin-bottom: 0.9rem; +} + +.form-panel input { + border: 1px solid var(--line); + border-radius: 6px; + font: inherit; + min-height: 2.5rem; + padding: 0.55rem 0.7rem; +} + +.network-state { + color: var(--muted); + margin: 0 0 0.9rem; +} + +.pwa-update { + background: #172033; + border-radius: 8px; + bottom: 1rem; + box-shadow: 0 18px 60px rgb(0 0 0 / 28%); + color: white; + display: grid; + gap: 0.8rem; + left: 1rem; + max-width: min(28rem, calc(100vw - 2rem)); + padding: 1rem; + position: fixed; + z-index: 5; +} + +.pwa-update[hidden] { + display: none; +} + +.pwa-update p { + margin: 0; +} + +.pwa-update__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pwa-update button { + background: white; + border-color: white; + color: #172033; +} + +.pwa-update button.secondary { + background: transparent; + color: white; +} + +@media (max-width: 760px) { + .site-header__inner, + .app-summary { + grid-template-columns: 1fr; + } + + .site-header__inner { + align-items: start; + flex-direction: column; + } + + .site-nav { + justify-content: flex-start; + } +} diff --git a/examples/pwa/src/global.vars.js b/examples/pwa/src/global.vars.js new file mode 100644 index 0000000..7133632 --- /dev/null +++ b/examples/pwa/src/global.vars.js @@ -0,0 +1,6 @@ +export default { + siteName: 'Domstack PWA', + description: 'A static offline-first PWA example built with Domstack.', + defaultLayout: 'root', + themeColor: '#185abc', +} diff --git a/examples/pwa/src/layouts/root.layout.js b/examples/pwa/src/layouts/root.layout.js new file mode 100644 index 0000000..682e018 --- /dev/null +++ b/examples/pwa/src/layouts/root.layout.js @@ -0,0 +1,59 @@ +/** + * @param {object} args + * @param {Record} args.vars + * @param {string} args.children + * @param {string[]} [args.scripts] + * @param {string[]} [args.styles] + */ +export default function rootLayout ({ + vars: { + siteName, + title, + description, + themeColor, + }, + children, + scripts = [], + styles = [], +}) { + const documentTitle = title ? `${title} | ${siteName}` : siteName + + return ` + + + + + + + + + ${documentTitle} + ${styles.map(style => ``).join('\n ')} + + + + +
+ ${children} +
+ + ${scripts.map(script => ``).join('\n ')} + +` +} diff --git a/examples/pwa/src/legal/README.md b/examples/pwa/src/legal/README.md new file mode 100644 index 0000000..3988872 --- /dev/null +++ b/examples/pwa/src/legal/README.md @@ -0,0 +1,9 @@ +--- +title: Legal +--- + +# Legal + +This route represents public legal or policy content. It is included in the first offline bundle because it is static, public, and useful without API state. + +The same policy could cover terms, privacy, support, and help pages. diff --git a/examples/pwa/src/login/page.js b/examples/pwa/src/login/page.js new file mode 100644 index 0000000..efe2d2a --- /dev/null +++ b/examples/pwa/src/login/page.js @@ -0,0 +1,25 @@ +export const vars = { + title: 'Login', + precache: true, + offline: true, +} + +export default function loginPage () { + return `
+

Login

+

The static auth shell is precached. The form action points at a network route, so the client runtime disables submission while offline.

+ +
+

Checking network state

+ + + +
+
` +} diff --git a/examples/pwa/src/manifest.settings.js b/examples/pwa/src/manifest.settings.js new file mode 100644 index 0000000..f164e0d --- /dev/null +++ b/examples/pwa/src/manifest.settings.js @@ -0,0 +1,22 @@ +/** + * @import { BuildOutputEntry } from '@domstack/static' + */ + +import { pwaManifestExclude, shouldIncludePwaOutput } from './pwa/cache-policy.js' + +const origin = 'https://example.com' + +export default { + exclude: pwaManifestExclude, + includeOutput, +} + +/** + * Keep PWA cache policy close to the application while still letting Domstack + * emit a normal build-output manifest for the service worker to consume. + * + * @param {BuildOutputEntry} entry + */ +function includeOutput (entry) { + return shouldIncludePwaOutput(entry, origin) +} diff --git a/examples/pwa/src/manifest.webmanifest b/examples/pwa/src/manifest.webmanifest new file mode 100644 index 0000000..4f54ccb --- /dev/null +++ b/examples/pwa/src/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Domstack PWA Example", + "short_name": "Domstack PWA", + "description": "A static offline-first PWA example built with Domstack.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f7f8fb", + "theme_color": "#185abc", + "icons": [ + { + "src": "/static/app-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/examples/pwa/src/offline/page.js b/examples/pwa/src/offline/page.js new file mode 100644 index 0000000..52b6a54 --- /dev/null +++ b/examples/pwa/src/offline/page.js @@ -0,0 +1,13 @@ +export const vars = { + title: 'Offline', + precache: true, + offline: true, +} + +export default function offlinePage () { + return `
+

Offline

+

The static app shell is available, but this route was not in the offline bundle or the network is unavailable.

+

Return to app shell

+
` +} diff --git a/examples/pwa/src/private/page.js b/examples/pwa/src/private/page.js new file mode 100644 index 0000000..7fee32c --- /dev/null +++ b/examples/pwa/src/private/page.js @@ -0,0 +1,7 @@ +export default function optedOutPage () { + return `
+

Opted-out page

+

This page uses page vars to stay out of the PWA cache even though it is not under an excluded path.

+

Return to app shell

+
` +} diff --git a/examples/pwa/src/private/page.vars.js b/examples/pwa/src/private/page.vars.js new file mode 100644 index 0000000..6f827c1 --- /dev/null +++ b/examples/pwa/src/private/page.vars.js @@ -0,0 +1,5 @@ +export default { + title: 'Opted-out Page', + precache: false, + offline: false, +} diff --git a/examples/pwa/src/pwa/cache-policy.js b/examples/pwa/src/pwa/cache-policy.js new file mode 100644 index 0000000..e51bad9 --- /dev/null +++ b/examples/pwa/src/pwa/cache-policy.js @@ -0,0 +1,63 @@ +export const CACHE_PREFIX = 'domstack-pwa-example' +export const STATIC_CACHE_PREFIX = `${CACHE_PREFIX}-static-` +export const INSTALL_CACHE_PREFIX = `${CACHE_PREFIX}-install-` +export const META_CACHE = `${CACHE_PREFIX}-meta` +export const ACTIVE_VERSION_URL = '/__domstack-pwa-example/active-version' +export const PENDING_VERSION_URL = '/__domstack-pwa-example/pending-version' +export const OFFLINE_FALLBACK_URL = '/offline/' +export const DOMSTACK_MANIFEST_URL = process.env.DOMSTACK_OUTPUT_MANIFEST_URL ?? '/domstack-output-manifest.json' +export const DOMSTACK_MANIFEST_ENABLED = process.env.DOMSTACK_OUTPUT_MANIFEST_ENABLED === 'true' +export const DOMSTACK_SERVICE_WORKER_URL = process.env.DOMSTACK_SERVICE_WORKER_URL ?? '/service-worker.js' +export const DOMSTACK_SERVICE_WORKER_SCOPE = process.env.DOMSTACK_SERVICE_WORKER_SCOPE ?? '/' + +export const pwaManifestExclude = [ + 'admin/**', + 'blog/**', + '**/*.map', + 'domstack-esbuild-meta.json', + 'domstack-output-manifest.json', + 'service-worker.js', +] + +const excludedPrefixes = [ + '/admin/', + '/api/', + '/blog/', +] + +const excludedKinds = new Set([ + 'metadata', + 'sourcemap', + 'service-worker', +]) + +/** + * Shared application policy for deciding which Domstack output entries may be + * precached. Node runs this through manifest.settings.js; the service worker + * also runs it defensively when reading the emitted manifest. + * + * @param {{ url: string, revision?: string | null, kind?: string, page?: { vars?: { precache?: unknown, offline?: unknown } } }} entry + * @param {string | URL} origin + */ +export function shouldIncludePwaOutput (entry, origin) { + if (!entry.revision) return false + if (entry.kind && excludedKinds.has(entry.kind)) return false + if (entry.page?.vars?.precache === false || entry.page?.vars?.offline === false) return false + + const url = new URL(entry.url, origin) + if (url.origin !== new URL(origin).origin) return false + + return !excludedPrefixes.some(prefix => url.pathname.startsWith(prefix)) +} + +/** + * @param {Request} request + */ +export function shouldHandleRequest (request) { + if (request.method !== 'GET') return false + + const url = new URL(request.url) + if (url.origin !== location.origin) return false + + return !excludedPrefixes.some(prefix => url.pathname.startsWith(prefix)) +} diff --git a/examples/pwa/src/pwa/runtime.js b/examples/pwa/src/pwa/runtime.js new file mode 100644 index 0000000..f9d1669 --- /dev/null +++ b/examples/pwa/src/pwa/runtime.js @@ -0,0 +1,320 @@ +import { + CACHE_PREFIX, + DOMSTACK_MANIFEST_ENABLED, + DOMSTACK_SERVICE_WORKER_SCOPE, + DOMSTACK_SERVICE_WORKER_URL, +} from './cache-policy.js' + +const DEV_OPT_IN_KEY = 'domstack:pwa-dev' +const RESET_PARAM = 'reset-sw' +const UPDATE_CHECK_INTERVAL = 15 * 60 * 1000 + +let lastUpdateCheck = 0 +let applyingUpdate = false +let formIsDirty = false + +/** + * Register the site service worker, wire update prompts, and keep local watch + * builds from inheriting stale production caches unless a developer opts in. + */ +export async function initializePwa () { + trackOnlineState() + trackFormDirtyState() + trackNetworkForms() + + if (!('serviceWorker' in navigator)) { + setStatus('Service workers are unavailable') + return + } + + if (new URLSearchParams(location.search).has(RESET_PARAM)) { + await resetServiceWorkers() + location.replace(location.pathname || '/') + return + } + + if (!DOMSTACK_SERVICE_WORKER_URL || !DOMSTACK_SERVICE_WORKER_SCOPE || !DOMSTACK_MANIFEST_ENABLED) { + setStatus('PWA disabled for this build') + await resetLocalServiceWorkers() + return + } + + if (isLocalOrigin() && localStorage.getItem(DEV_OPT_IN_KEY) !== '1') { + setStatus('PWA disabled on local origin') + await resetLocalServiceWorkers() + return + } + + const registration = await navigator.serviceWorker.register(DOMSTACK_SERVICE_WORKER_URL, { + scope: DOMSTACK_SERVICE_WORKER_SCOPE, + updateViaCache: 'none', + }) + + setStatus(navigator.serviceWorker.controller ? 'Active' : 'Installing') + wireRegistration(registration) + wireControllerReload() + + if (registration.waiting) { + showUpdatePrompt(registration.waiting, 'SKIP_WAITING') + } + + await checkForUpdates(registration, { force: true }) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + checkForUpdates(registration).catch(reportUpdateFailure) + } + }) + + window.addEventListener('online', () => { + checkForUpdates(registration, { force: true }).catch(reportUpdateFailure) + }) + + window.addEventListener('pagehide', () => { + if (!formIsDirty && registration.waiting) { + registration.waiting.postMessage({ type: 'SKIP_WAITING' }) + } + }) +} + +/** + * Connect registration events to the small update notice rendered by this + * example's runtime. + * + * @param {ServiceWorkerRegistration} registration + */ +function wireRegistration (registration) { + registration.addEventListener('updatefound', () => { + const installing = registration.installing + if (!installing) return + + setStatus('Installing update') + installing.addEventListener('statechange', () => { + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + showUpdatePrompt(installing, 'SKIP_WAITING') + } else if (installing.state === 'activated') { + setStatus('Active') + } + }) + }) + + navigator.serviceWorker.addEventListener('message', event => { + const data = event.data + if (!data || typeof data !== 'object') return + + if (data.type === 'CACHE_UPDATE_READY') { + setVersion(data.version) + if (registration.waiting) { + showUpdatePrompt(registration.waiting, 'SKIP_WAITING') + } else if (registration.active) { + showUpdatePrompt(registration.active, 'APPLY_PENDING_CACHE') + } + } + + if (data.type === 'CACHE_UPDATE_CURRENT') { + setVersion(data.version) + } + + if (data.type === 'CACHE_UPDATE_APPLIED') { + setVersion(data.version) + window.location.reload() + } + + if (data.type === 'CACHE_UPDATE_FAILED') { + setStatus('Update check failed') + console.warn('PWA cache update failed', data.error) + } + }) +} + +/** + * Reload once after an accepted service-worker update takes control. + */ +function wireControllerReload () { + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (applyingUpdate) return + applyingUpdate = true + window.location.reload() + }) +} + +/** + * Ask both the browser and active worker to check for fresh static content. + * + * @param {ServiceWorkerRegistration} registration + * @param {{ force?: boolean }} [opts] + */ +async function checkForUpdates (registration, opts = {}) { + const now = Date.now() + if (!opts.force && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL) return + lastUpdateCheck = now + + await registration.update() + registration.active?.postMessage({ type: 'CHECK_FOR_UPDATES' }) +} + +/** + * Render the update prompt and post the matching activation message when the + * user accepts. + * + * @param {ServiceWorker} worker + * @param {'SKIP_WAITING'|'APPLY_PENDING_CACHE'} messageType + */ +function showUpdatePrompt (worker, messageType) { + const prompt = document.querySelector('[data-pwa-update]') + if (!prompt) return + + prompt.replaceChildren() + prompt.hidden = false + + const message = document.createElement('p') + message.textContent = 'A fresh static build is ready.' + + const actions = document.createElement('div') + actions.className = 'pwa-update__actions' + + const apply = document.createElement('button') + apply.type = 'button' + apply.textContent = 'Apply now' + apply.addEventListener('click', () => { + applyingUpdate = false + worker.postMessage({ type: messageType }) + }) + + const later = document.createElement('button') + later.type = 'button' + later.className = 'secondary' + later.textContent = 'Later' + later.addEventListener('click', () => { + prompt.hidden = true + }) + + actions.append(apply, later) + prompt.append(message, actions) +} + +/** + * Track form edits so pagehide auto-activation does not interrupt a dirty form. + */ +function trackFormDirtyState () { + document.addEventListener('input', event => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + formIsDirty = true + } + }) + + document.addEventListener('submit', () => { + formIsDirty = false + }) +} + +/** + * Keep the visible online/offline status in sync with the browser state. + */ +function trackOnlineState () { + const render = () => { + const node = document.querySelector('[data-online-state]') + if (node) node.textContent = navigator.onLine ? 'Online' : 'Offline' + } + + render() + window.addEventListener('online', render) + window.addEventListener('offline', render) +} + +/** + * Disable network-only form submission controls while offline. + */ +function trackNetworkForms () { + const forms = Array.from(document.querySelectorAll('[data-network-form]')) + if (forms.length === 0) return + + const render = () => { + for (const form of forms) { + const status = form.querySelector('[data-network-state]') + const submit = form.querySelector('[type="submit"]') + if (status) { + status.textContent = navigator.onLine + ? 'Online. Submissions will use the network.' + : 'Offline. Submission is paused until the network returns.' + } + if (submit instanceof HTMLButtonElement) { + submit.disabled = !navigator.onLine + } + } + } + + for (const form of forms) { + form.addEventListener('submit', event => { + if (!navigator.onLine) event.preventDefault() + }) + } + + render() + window.addEventListener('online', render) + window.addEventListener('offline', render) +} + +/** + * Remove service workers and Cache Storage entries owned by this example. + */ +async function resetServiceWorkers () { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map(registration => registration.unregister())) + await deleteExampleCaches() + setStatus('PWA reset complete') +} + +/** + * Clear local caches only when the active worker belongs to this example scope. + */ +async function resetLocalServiceWorkers () { + if (!isLocalOrigin()) return + const registrations = await navigator.serviceWorker.getRegistrations() + const scopedRegistrations = registrations.filter(registration => { + const scope = new URL(registration.scope) + return scope.origin === location.origin && scope.pathname === DOMSTACK_SERVICE_WORKER_SCOPE + }) + + await Promise.all(scopedRegistrations.map(registration => registration.unregister())) + await deleteExampleCaches() +} + +/** + * Delete Cache Storage entries created by this example. + */ +async function deleteExampleCaches () { + const names = await caches.keys() + await Promise.all( + names + .filter(name => name.startsWith(CACHE_PREFIX)) + .map(name => caches.delete(name)) + ) +} + +/** + * @param {string} value + */ +function setStatus (value) { + const node = document.querySelector('[data-pwa-status]') + if (node) node.textContent = value +} + +/** + * @param {unknown} value + */ +function setVersion (value) { + const node = document.querySelector('[data-pwa-version]') + if (node && typeof value === 'string') node.textContent = value.slice(0, 12) +} + +/** + * @param {unknown} err + */ +function reportUpdateFailure (err) { + console.warn('PWA update check failed', err) +} + +function isLocalOrigin () { + return ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname) +} diff --git a/examples/pwa/src/service-worker.js b/examples/pwa/src/service-worker.js new file mode 100644 index 0000000..eb3bd7c --- /dev/null +++ b/examples/pwa/src/service-worker.js @@ -0,0 +1 @@ +import './sw/events.js' diff --git a/examples/pwa/src/static/app-icon.svg b/examples/pwa/src/static/app-icon.svg new file mode 100644 index 0000000..e69daae --- /dev/null +++ b/examples/pwa/src/static/app-icon.svg @@ -0,0 +1,7 @@ + + Domstack PWA Example + A blue square app icon with a white layered window mark. + + + + diff --git a/examples/pwa/src/sw/cache.js b/examples/pwa/src/sw/cache.js new file mode 100644 index 0000000..71a534e --- /dev/null +++ b/examples/pwa/src/sw/cache.js @@ -0,0 +1,111 @@ +import { + ACTIVE_VERSION_URL, + INSTALL_CACHE_PREFIX, + META_CACHE, + PENDING_VERSION_URL, + STATIC_CACHE_PREFIX, +} from '../pwa/cache-policy.js' +import { serviceWorker } from './context.js' + +/** + * @param {string} version + */ +export function staticCacheName (version) { + return `${STATIC_CACHE_PREFIX}${version}` +} + +/** + * @param {string} version + */ +export function installCacheName (version) { + return `${INSTALL_CACHE_PREFIX}${version}` +} + +export async function getActiveVersion () { + return readMeta(ACTIVE_VERSION_URL) +} + +export async function getPendingVersion () { + return readMeta(PENDING_VERSION_URL) +} + +/** + * @param {string} version + */ +export async function setActiveVersion (version) { + await writeMeta(ACTIVE_VERSION_URL, version) +} + +/** + * @param {string} version + */ +export async function setPendingVersion (version) { + await writeMeta(PENDING_VERSION_URL, version) +} + +export async function clearPendingVersion () { + await deleteMeta(PENDING_VERSION_URL) +} + +/** + * Copy every request/response pair from an install cache into the final static + * cache. Cache Storage has no atomic rename operation, so this is the commit + * step after all responses have already been fetched and validated. + * + * @param {string} fromName + * @param {string} toName + */ +export async function copyCacheEntries (fromName, toName) { + const from = await serviceWorker.caches.open(fromName) + const to = await serviceWorker.caches.open(toName) + const requests = await from.keys() + + for (const request of requests) { + const response = await from.match(request) + if (response) await to.put(request, response) + } +} + +/** + * Remove old static and temporary install caches after a version is active. + * + * @param {string | null} keepVersion + */ +export async function cleanupStaticCaches (keepVersion) { + const names = await serviceWorker.caches.keys() + await Promise.all( + names + .filter(name => { + if (name === META_CACHE) return false + if (keepVersion && name === staticCacheName(keepVersion)) return false + return name.startsWith(STATIC_CACHE_PREFIX) || name.startsWith(INSTALL_CACHE_PREFIX) + }) + .map(name => serviceWorker.caches.delete(name)) + ) +} + +/** + * @param {string} url + */ +async function readMeta (url) { + const cache = await serviceWorker.caches.open(META_CACHE) + const response = await cache.match(new Request(url)) + return response ? response.text() : null +} + +/** + * @param {string} url + * @param {string} value + */ +async function writeMeta (url, value) { + const cache = await serviceWorker.caches.open(META_CACHE) + await cache.put(new Request(url), new Response(value)) +} + +/** + * @param {string} url + */ +async function deleteMeta (url) { + const cache = await serviceWorker.caches.open(META_CACHE) + await cache.delete(new Request(url)) +} diff --git a/examples/pwa/src/sw/clients.js b/examples/pwa/src/sw/clients.js new file mode 100644 index 0000000..c479baa --- /dev/null +++ b/examples/pwa/src/sw/clients.js @@ -0,0 +1,18 @@ +import { serviceWorker } from './context.js' + +/** + * Broadcast a structured message to every window client, including clients not + * yet controlled by the current worker. + * + * @param {Record} message + */ +export async function postToClients (message) { + const clients = await serviceWorker.clients.matchAll({ + includeUncontrolled: true, + type: 'window', + }) + + for (const client of clients) { + client.postMessage(message) + } +} diff --git a/examples/pwa/src/sw/context.js b/examples/pwa/src/sw/context.js new file mode 100644 index 0000000..0072caf --- /dev/null +++ b/examples/pwa/src/sw/context.js @@ -0,0 +1,3 @@ +export const serviceWorker = /** @type {ServiceWorkerGlobalScope & typeof globalThis} */ ( + /** @type {unknown} */ (globalThis) +) diff --git a/examples/pwa/src/sw/events.js b/examples/pwa/src/sw/events.js new file mode 100644 index 0000000..bf33d5f --- /dev/null +++ b/examples/pwa/src/sw/events.js @@ -0,0 +1,58 @@ +import { getActiveVersion } from './cache.js' +import { postToClients } from './clients.js' +import { serviceWorker } from './context.js' +import { handleFetch } from './fetch.js' +import { handleMessage } from './messages.js' +import { + activatePendingCache, + prepareStaticCache, +} from './precache.js' + +/** + * Install follows the Service Worker lifecycle: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/install_event + */ +serviceWorker.addEventListener('install', event => { + event.waitUntil(handleInstall()) +}) + +/** + * Activate commits a fully staged cache and removes old cache versions: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/activate_event + */ +serviceWorker.addEventListener('activate', event => { + event.waitUntil(handleActivate()) +}) + +/** + * Fetch keeps static navigations/assets cache-first and leaves excluded traffic + * on the network path: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/fetch_event + */ +serviceWorker.addEventListener('fetch', event => { + event.respondWith(handleFetch(event.request)) +}) + +/** + * Messages let the window runtime request update checks and user-approved + * activation: + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/message_event + */ +serviceWorker.addEventListener('message', event => { + event.waitUntil(handleMessage(event)) +}) + +async function handleInstall () { + const hadActiveCache = Boolean(await getActiveVersion()) + const result = await prepareStaticCache({ force: true }) + + if (!hadActiveCache && result.status === 'ready') { + await serviceWorker.skipWaiting() + } +} + +async function handleActivate () { + const version = await activatePendingCache() + await serviceWorker.clients.claim() + await postToClients({ type: 'CACHE_UPDATE_CURRENT', version }) +} diff --git a/examples/pwa/src/sw/fetch.js b/examples/pwa/src/sw/fetch.js new file mode 100644 index 0000000..034ef25 --- /dev/null +++ b/examples/pwa/src/sw/fetch.js @@ -0,0 +1,57 @@ +import { + OFFLINE_FALLBACK_URL, + shouldHandleRequest, +} from '../pwa/cache-policy.js' +import { + getActiveVersion, + staticCacheName, +} from './cache.js' +import { serviceWorker } from './context.js' + +/** + * Serve static manifest entries cache-first and keep excluded/data requests on + * the network path. + * + * @param {Request} request + */ +export async function handleFetch (request) { + if (!shouldHandleRequest(request)) { + return serviceWorker.fetch(request) + } + + const cached = await matchActiveCache(request) + if (cached) return cached + + try { + return await serviceWorker.fetch(request) + } catch (err) { + if (request.mode === 'navigate') { + const fallback = await matchActiveCache(new Request(OFFLINE_FALLBACK_URL)) + if (fallback) return fallback + } + + throw err + } +} + +/** + * @param {Request} request + */ +async function matchActiveCache (request) { + const activeVersion = await getActiveVersion() + if (!activeVersion) return null + + const cache = await serviceWorker.caches.open(staticCacheName(activeVersion)) + const directMatch = await cache.match(request, { ignoreSearch: true }) + if (directMatch) return directMatch + + if (request.mode !== 'navigate') return null + + const url = new URL(request.url) + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/` + } + url.search = '' + + return cache.match(url.href) +} diff --git a/examples/pwa/src/sw/manifest.js b/examples/pwa/src/sw/manifest.js new file mode 100644 index 0000000..cae45fa --- /dev/null +++ b/examples/pwa/src/sw/manifest.js @@ -0,0 +1,72 @@ +import { + DOMSTACK_MANIFEST_ENABLED, + DOMSTACK_MANIFEST_URL, + shouldIncludePwaOutput, +} from '../pwa/cache-policy.js' +import { serviceWorker } from './context.js' + +/** + * @typedef {object} ManifestEntry + * @property {string} url + * @property {string} revision + * @property {string} [kind] + */ + +/** + * @typedef {object} OutputManifest + * @property {string} version + * @property {ManifestEntry[]} entries + */ + +/** + * Fetch and validate Domstack's output manifest. Watch builds set + * DOMSTACK_OUTPUT_MANIFEST_ENABLED=false, so the worker exits early there. + * + * @returns {Promise} + */ +export async function fetchOutputManifest () { + if (!DOMSTACK_MANIFEST_ENABLED) return null + + const response = await serviceWorker.fetch(DOMSTACK_MANIFEST_URL, { + cache: 'no-store', + credentials: 'same-origin', + }) + + if (!response.ok) { + throw new Error(`Unable to fetch output manifest: ${response.status}`) + } + + const data = await response.json() + if (!isOutputManifest(data)) { + throw new Error('Output manifest has an unexpected shape') + } + + return { + version: data.version, + entries: data.entries.filter(entry => shouldIncludePwaOutput(entry, serviceWorker.location.origin)), + } +} + +/** + * @param {unknown} value + * @returns {value is OutputManifest} + */ +function isOutputManifest (value) { + if (!value || typeof value !== 'object') return false + const manifest = /** @type {{ version?: unknown, entries?: unknown }} */ (value) + + return typeof manifest.version === 'string' && + Array.isArray(manifest.entries) && + manifest.entries.every(isManifestEntry) +} + +/** + * @param {unknown} value + * @returns {value is ManifestEntry} + */ +function isManifestEntry (value) { + if (!value || typeof value !== 'object') return false + const entry = /** @type {{ url?: unknown, revision?: unknown }} */ (value) + + return typeof entry.url === 'string' && typeof entry.revision === 'string' +} diff --git a/examples/pwa/src/sw/messages.js b/examples/pwa/src/sw/messages.js new file mode 100644 index 0000000..2630a11 --- /dev/null +++ b/examples/pwa/src/sw/messages.js @@ -0,0 +1,44 @@ +import { postToClients } from './clients.js' +import { serviceWorker } from './context.js' +import { + activatePendingCache, + prepareStaticCache, +} from './precache.js' + +/** + * @param {ExtendableMessageEvent} event + */ +export async function handleMessage (event) { + const data = event.data + if (!data || typeof data !== 'object') return + + if (data.type === 'SKIP_WAITING') { + await serviceWorker.skipWaiting() + return + } + + if (data.type === 'CHECK_FOR_UPDATES') { + await checkForManifestUpdate() + return + } + + if (data.type === 'APPLY_PENDING_CACHE') { + const version = await activatePendingCache() + await postToClients({ type: 'CACHE_UPDATE_APPLIED', version }) + } +} + +async function checkForManifestUpdate () { + try { + const result = await prepareStaticCache() + await postToClients({ + type: result.status === 'ready' ? 'CACHE_UPDATE_READY' : 'CACHE_UPDATE_CURRENT', + version: result.version, + }) + } catch (err) { + await postToClients({ + type: 'CACHE_UPDATE_FAILED', + error: err instanceof Error ? err.message : String(err), + }) + } +} diff --git a/examples/pwa/src/sw/precache.js b/examples/pwa/src/sw/precache.js new file mode 100644 index 0000000..3b99029 --- /dev/null +++ b/examples/pwa/src/sw/precache.js @@ -0,0 +1,153 @@ +import { + OFFLINE_FALLBACK_URL, +} from '../pwa/cache-policy.js' +import { + clearPendingVersion, + cleanupStaticCaches, + copyCacheEntries, + getActiveVersion, + getPendingVersion, + installCacheName, + setActiveVersion, + setPendingVersion, + staticCacheName, +} from './cache.js' +import { serviceWorker } from './context.js' +import { fetchOutputManifest } from './manifest.js' + +const MAX_PARALLEL_FETCHES = 8 + +/** + * Fetch, validate, and stage a full static cache from the current output + * manifest. The active cache is not changed until activation is accepted. + * + * @param {{ force?: boolean }} [opts] + */ +export async function prepareStaticCache (opts = {}) { + const manifest = await fetchOutputManifest() + if (!manifest) return { status: 'disabled', version: null } + + const activeVersion = await getActiveVersion() + const pendingVersion = await getPendingVersion() + if (!opts.force && (manifest.version === activeVersion || manifest.version === pendingVersion)) { + return { status: 'current', version: manifest.version } + } + + const installName = installCacheName(manifest.version) + const finalName = staticCacheName(manifest.version) + + await serviceWorker.caches.delete(installName) + await serviceWorker.caches.delete(finalName) + + const cache = await serviceWorker.caches.open(installName) + await cacheManifestEntries(cache, manifest.entries) + await copyCacheEntries(installName, finalName) + await serviceWorker.caches.delete(installName) + await setPendingVersion(manifest.version) + + return { status: 'ready', version: manifest.version } +} + +/** + * Promote a staged static cache to the active version. + */ +export async function activatePendingCache () { + const pendingVersion = await getPendingVersion() + if (!pendingVersion) { + const activeVersion = await getActiveVersion() + await cleanupStaticCaches(activeVersion) + return activeVersion + } + + await setActiveVersion(pendingVersion) + await clearPendingVersion() + await cleanupStaticCaches(pendingVersion) + + return pendingVersion +} + +/** + * @param {Cache} cache + * @param {{ url: string, revision: string }[]} entries + */ +async function cacheManifestEntries (cache, entries) { + const queue = [...entries] + const workers = Array.from( + { length: Math.min(MAX_PARALLEL_FETCHES, queue.length) }, + () => cacheNextEntry(cache, queue) + ) + + await Promise.all(workers) +} + +/** + * @param {Cache} cache + * @param {{ url: string, revision: string }[]} queue + */ +async function cacheNextEntry (cache, queue) { + while (queue.length > 0) { + const entry = queue.shift() + if (!entry) return + await cacheEntry(cache, entry) + } +} + +/** + * @param {Cache} cache + * @param {{ url: string, revision: string }} entry + */ +async function cacheEntry (cache, entry) { + const request = new Request(entry.url, { + credentials: 'same-origin', + }) + const response = await serviceWorker.fetch(request, { + cache: 'reload', + credentials: 'same-origin', + }) + + assertCacheableResponse(entry.url, response) + + const revisionMatches = await responseMatchesRevision(response, entry.revision) + if (!revisionMatches) { + throw new Error(`Cached response revision mismatch for ${entry.url}`) + } + + await cache.put(request, response) +} + +/** + * @param {string} url + * @param {Response} response + */ +function assertCacheableResponse (url, response) { + if (!response.ok || response.redirected || response.type !== 'basic') { + throw new Error(`Refusing to cache ${url}: ${response.status} ${response.type}`) + } +} + +/** + * @param {Response} response + * @param {string} revision + */ +async function responseMatchesRevision (response, revision) { + const buffer = await response.clone().arrayBuffer() + const digest = await serviceWorker.crypto.subtle.digest('SHA-256', buffer) + return toHex(digest) === revision +} + +/** + * @param {ArrayBuffer} buffer + */ +function toHex (buffer) { + return [...new Uint8Array(buffer)] + .map(value => value.toString(16).padStart(2, '0')) + .join('') +} + +export async function ensureOfflineFallbackIsPresent () { + const activeVersion = await getActiveVersion() + if (!activeVersion) return false + + const cache = await serviceWorker.caches.open(staticCacheName(activeVersion)) + return Boolean(await cache.match(OFFLINE_FALLBACK_URL)) +} diff --git a/examples/tailwind/src/globals/esbuild.settings.js b/examples/tailwind/src/globals/esbuild.settings.js index 171da22..2aaee97 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 7616783..6514b8a 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/uhtml-isomorphic/src/layouts/root.layout.js b/examples/uhtml-isomorphic/src/layouts/root.layout.js index 72cf92a..2b15aec 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/index.js b/index.js index 06e6767..50f09e9 100644 --- a/index.js +++ b/index.js @@ -10,7 +10,12 @@ * @import { TemplateFunctionParams } from './lib/build-pages/page-builders/template-builder.js' * @import { GlobalDataFunction, AsyncGlobalDataFunction, WorkerBuildStepResult, GlobalDataFunctionParams } from './lib/build-pages/index.js' * @import { BuildOptions, BuildContext } from 'esbuild' - * @import { PageInfo, TemplateInfo } from './lib/identify-pages.js' + * @import { PageInfo, ServiceWorkerInfo, TemplateInfo } from './lib/identify-pages.js' + * @import { BuildOutputManifest } from './lib/build-output-manifest/index.js' + * @import { BuildOutputEntry } from './lib/build-output-manifest/index.js' + * @import { BuildOutputEntryPageMeta } from './lib/build-output-manifest/index.js' + * @import { BuildOutputKind } from './lib/build-output-manifest/index.js' + * @import { BuildOutputRecord } from './lib/build-output-manifest/index.js' */ import { once } from 'events' import assert from 'node:assert' @@ -38,18 +43,30 @@ import { globalDataNames, esbuildSettingsNames, markdownItSettingsNames, + manifestSettingsNames, 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' import { DomStackAggregateError } from './lib/helpers/domstack-aggregate-error.js' export { PageData } from './lib/build-pages/page-data.js' +export { + BUILD_OUTPUT_MANIFEST_SCHEMA_ID, + BUILD_OUTPUT_MANIFEST_SCHEMA_PATH, + buildOutputEntryPageMetaSchema, + buildOutputEntrySchema, + buildOutputKindSchema, + buildOutputManifest, + buildOutputManifestSchema, + getBuildOutputManifestSchemaId, +} from './lib/build-output-manifest/index.js' /** * @typedef {BuildOptions} BuildOptions @@ -113,6 +130,26 @@ export { PageData } from './lib/build-pages/page-data.js' * @typedef {TemplateInfo} TemplateInfo */ +/** + * @typedef {ServiceWorkerInfo} ServiceWorkerInfo + */ + +/** + * @typedef {BuildOutputManifest} BuildOutputManifest + */ + +/** + * @typedef {BuildOutputEntry} BuildOutputEntry + */ + +/** + * @typedef {BuildOutputEntryPageMeta} BuildOutputEntryPageMeta + */ + +/** + * @typedef {BuildOutputKind} BuildOutputKind + */ + /** * @template {Record} Vars - The type of variables passed to the layout function * @template [PageReturn=any] PageReturn - The return type of the page function @@ -261,7 +298,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, @@ -342,8 +387,8 @@ export class DomStack { await once(watcher, 'ready') - const enqueue = (/** @type {() => Promise} */ fn) => { - this.#buildLock = this.#buildLock.then(() => fn().catch(errorLogger)) + const enqueue = (/** @type {() => Promise} */ fn) => { + this.#enqueueBuild(fn) } watcher.on('add', path => { @@ -402,6 +447,7 @@ export class DomStack { */ 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 = ( @@ -409,6 +455,7 @@ export class DomStack { 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 @@ -437,9 +484,10 @@ export class DomStack { 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. + console.log(`"${changedBasename}" ${event}, no page rebuild needed.`) + } else if (globalClientNames.includes(changedBasename) || globalStyleNames.includes(changedBasename)) { // Global asset: rebuild all pages logRebuildTree(changedBasename, new Set(siteData.pages)) await this.#runPageBuild(siteData) @@ -496,16 +544,36 @@ export class DomStack { ...(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 }, isFiltered ? this.#dest : undefined ) + return pageBuildResults } catch (err) { errorLogger(err) } } + /** + * @param {() => Promise} fn + */ + #enqueueBuild (fn) { + this.#buildLock = this.#buildLock.then(async () => { + try { + await fn() + } catch (err) { + errorLogger(err) + } + }) + } + /** * Build and maintain the six watch maps from siteData. * `find()` returns CWD-relative paths; we resolve them to absolute for map keys. @@ -606,6 +674,7 @@ export class DomStack { 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)) @@ -663,6 +732,13 @@ export class DomStack { return this.#runPageBuild(siteData, Array.from(mdPages).map(p => p.pageFile.filepath), []) } + // manifest.settings.* only affects one-shot output manifest generation. + // Watch mode intentionally does not write or return an output manifest. + if (manifestSettingsNames.some(n => changedBasename === n)) { + console.log(`"${changedBasename}" changed but output 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. @@ -832,24 +908,44 @@ function buildLogger (results, dest) { // Full build: show site totals const layoutCount = Object.keys(results.siteData.layouts).length console.log(`Pages: ${results.siteData.pages.length} Layouts: ${layoutCount} Templates: ${results.siteData.templates.length}`) - const report = results.pageBuildResults?.report - if (report) { - console.log(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + const outputs = results.pageBuildResults?.report?.outputs + if (outputs) { + const summary = summarizePageBuildOutputs(outputs) + console.log(`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) { - console.log(` Built ${relative(dest, p.pageFilePath)}`) - } - for (const t of report.templates) { - for (const output of t.outputs ?? []) { - console.log(` Built ${output}`) + for (const output of outputs) { + if (output.kind === 'page' || output.kind === 'template') { + console.log(` Built ${relative(dest, output.filepath)}`) } } } - console.log(`Pages built: ${report.pages.length} Templates built: ${report.templates.length}`) + const summary = summarizePageBuildOutputs(outputs) + console.log(`Pages built: ${summary.pages} Templates built: ${summary.templates}`) } console.log('\nBuild Success!\n\n') } + +/** + * @param {BuildOutputRecord[]} outputs + */ +function summarizePageBuildOutputs (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 693e8bb..44bbe13 100644 --- a/lib/build-copy/index.js +++ b/lib/build-copy/index.js @@ -1,14 +1,16 @@ /** * @import { BuildStepResult, BuildStep } from '../builder.js' + * @import { BuildOutputRecord } from '../build-output-manifest/index.js' */ import { copy } from 'cpx2' import { join } from 'node:path' +import { createCopiedOutputRecords } from '../build-output-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: BuildOutputRecord[] }} 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(...createCopiedOutputRecords({ + 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 e46f868..ccea02c 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -1,28 +1,35 @@ /** * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' + * @import { BuildOutputRecord } from '../build-output-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, + createOutputRecord, + getOutputManifestFilename, + shouldWriteOutputManifest, + toPosix, +} from '../build-output-manifest/index.js' const __dirname = import.meta.dirname const DOM_STACK_DEFAULTS_PREFIX = 'domstack-defaults' +const SERVICE_WORKER_ENTRY_NAME = 'service-worker' +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: BuildOutputRecord[] * } * >} EsBuildStep */ @@ -39,13 +46,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 +109,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 +158,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 +196,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, @@ -213,6 +242,12 @@ async function assembleBuildOpts (src, dest, siteData, opts, modeOpts = {}) { const extendedBuildOpts = await esbuildSettingsExtends(buildOpts) + for (const [k, v] of Object.entries(domstackDefines)) { + if (extendedBuildOpts.define?.[k] !== v) { + throw new Error(`Conflict: "${k}" is reserved by domstack.`) + } + } + if (browserVars && Object.keys(browserVars).length > 0 && extendedBuildOpts.define !== buildOpts.define) { throw new Error( 'Conflict: both the "browser" export in global.vars and "define" in esbuild.settings are set. ' + @@ -232,25 +267,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) { @@ -260,11 +303,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_ENTRY_NAME, + }, + ], + 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_OUTPUT_MANIFEST_URL': JSON.stringify(outputManifestFilenameToUrl(getOutputManifestFilename(opts ?? undefined))), + 'process.env.DOMSTACK_OUTPUT_MANIFEST_ENABLED': JSON.stringify(String(!watch && shouldWriteOutputManifest(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 output manifest filename into the public file URL. + * + * @param {string} filename + */ +function outputManifestFilenameToUrl (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. @@ -274,18 +403,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) { @@ -294,7 +424,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) }) } } @@ -307,7 +440,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, ' ')) } @@ -316,6 +449,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 {BuildOutputRecord[]} + */ +export function createEsbuildOutputRecords ({ src, dest, siteData, buildResults, includeMetafileRecord }) { + /** @type {BuildOutputRecord[]} */ + 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(createOutputRecord({ + dest, + filepath, + outputRelname, + kind, + entryPoint: outputMeta.entryPoint, + sourceRelname: outputMeta.entryPoint ? toPosix(relative(src, resolve(outputMeta.entryPoint))) : undefined, + })) + } + + if (includeMetafileRecord) { + outputs.push(createOutputRecord({ + dest, + outputRelname: 'domstack-esbuild-meta.json', + kind: 'metadata', + })) + } - return { context, outputMap } + return outputs } diff --git a/lib/build-output-manifest/index.js b/lib/build-output-manifest/index.js new file mode 100644 index 0000000..13fe344 --- /dev/null +++ b/lib/build-output-manifest/index.js @@ -0,0 +1,716 @@ +/** + * @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_OUTPUT_MANIFEST_FILENAME = 'domstack-output-manifest.json' +export const BUILD_OUTPUT_MANIFEST_SCHEMA_PATH = 'lib/build-output-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 BUILD_OUTPUT_MANIFEST_SCHEMA_ID = getBuildOutputManifestSchemaId(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 buildOutputKindSchema = /** @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 buildOutputEntryPageMetaSchema = /** @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 buildOutputEntrySchema = /** @type {const} */ ({ + description: 'One public output emitted by domstack and included in the reconciled build output 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: buildOutputKindSchema, + 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: buildOutputEntryPageMetaSchema, + }, + required: ['outputRelname', 'kind', 'url', 'revision', 'bytes'], + additionalProperties: false, +}) + +/** @satisfies {JSONSchema} */ +export const buildOutputManifestSchema = /** @type {const} */ ({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: BUILD_OUTPUT_MANIFEST_SCHEMA_ID, + title: 'DomStack build output 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: BUILD_OUTPUT_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: buildOutputEntrySchema, + }, + }, + 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 getBuildOutputManifestSchemaId (version) { + return `https://unpkg.com/@domstack/static@${version}/${BUILD_OUTPUT_MANIFEST_SCHEMA_PATH}` +} + +/** + * @typedef {FromSchema} BuildOutputKind + */ + +/** + * @typedef {FromSchema} BuildOutputEntryPageMeta + */ + +/** + * A build step writes these as it emits files. Reconciliation turns them into + * revisioned manifest entries. + * + * @typedef {object} BuildOutputRecord + * @property {string} outputRelname + * @property {string} filepath + * @property {BuildOutputKind} kind + * @property {string} [url] + * @property {string} [sourceRelname] + * @property {string} [entryPoint] + * @property {string} [pagePath] + * @property {string} [pageUrl] + * @property {string} [templatePath] + * @property {BuildOutputEntryPageMeta} [page] + */ + +/** + * @typedef {FromSchema} BuildOutputEntry + */ + +/** + * @typedef {FromSchema} BuildOutputManifest + */ + +/** + * @typedef {object} BuildOutputManifestOptions + * @property {string[]} [exclude] + * @property {(entry: BuildOutputEntry) => boolean | Promise} [includeOutput] + * @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 {BuildOutputRecord[]} [params.records] + * @param {BuildOutputEntry[]} [params.entries] + * @param {BuildOutputManifestOptions} [params.options] + * @returns {Promise} + */ +export async function buildOutputManifest ({ 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_OUTPUT_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.includeOutput) { + /** @type {Array} */ + const included = [] + for (const entry of finalEntries) { + if (await options.includeOutput(toPublicEntry(entry))) included.push(entry) + } + finalEntries = included + } + + const version = createHash('sha256') + for (const entry of finalEntries) { + updateManifestVersionHash(version, toPublicEntry(entry)) + } + + return { + $schema: BUILD_OUTPUT_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 {BuildOutputKind} 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 {BuildOutputEntryPageMeta} [params.page] + * @returns {BuildOutputRecord} + */ +export function createOutputRecord ({ + 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 {BuildOutputRecord[]} + */ +export function createCopiedOutputRecords ({ src, dest, report, kind }) { + return extractCopiedFiles(report).map(copiedFile => { + const filepath = resolve(copiedFile.output) + return createOutputRecord({ + 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 {BuildOutputKind} + */ +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 {BuildOutputManifest} outputManifest + * @param {string} [filename] + */ +export async function writeOutputManifest (dest, outputManifest, filename = DEFAULT_OUTPUT_MANIFEST_FILENAME) { + const manifestPath = resolve(dest, filename) + assertInsideDest(dest, manifestPath) + await mkdir(dirname(manifestPath), { recursive: true }) + await writeFile(manifestPath, JSON.stringify(outputManifest, null, 2)) +} + +/** + * @param {object} params + * @param {string | undefined} params.manifestSettingsPath + * @param {DomStackOpts | undefined} params.opts + * @returns {Promise} + */ +export async function resolveOutputManifestOptions ({ manifestSettingsPath, opts }) { + const outputManifestOpts = typeof opts?.outputManifest === 'object' + ? opts.outputManifest + : {} + const manifestSettings = await resolveVars({ + varsPath: manifestSettingsPath, + }) + const includeOutput = typeof /** @type {{ includeOutput?: unknown }} */ (manifestSettings).includeOutput === 'function' + ? /** @type {(entry: BuildOutputEntry) => boolean | Promise} */ (/** @type {{ includeOutput: unknown }} */ (manifestSettings).includeOutput) + : undefined + + /** @type {BuildOutputManifestOptions} */ + const options = { + exclude: [ + ...((Array.isArray(outputManifestOpts.exclude) ? outputManifestOpts.exclude : [])), + ...((Array.isArray(/** @type {{ exclude?: unknown }} */ (manifestSettings).exclude) ? /** @type {string[]} */ (/** @type {{ exclude: unknown }} */ (manifestSettings).exclude) : [])), + ], + filename: getOutputManifestFilename(opts), + } + + if (includeOutput) options.includeOutput = includeOutput + + return options +} + +/** + * @param {DomStackOpts | undefined} opts + */ +export function shouldWriteOutputManifest (opts) { + if (opts?.outputManifest === false) return false + if (typeof opts?.outputManifest === 'object' && opts.outputManifest.write === false) return false + return true +} + +/** + * @param {DomStackOpts | undefined} opts + */ +export function getOutputManifestFilename (opts) { + return typeof opts?.outputManifest === 'object' && typeof opts.outputManifest.filename === 'string' + ? opts.outputManifest.filename + : DEFAULT_OUTPUT_MANIFEST_FILENAME +} + +/** + * @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 {BuildOutputRecord} params.record + * @returns {Promise<(BuildOutputRecord & { 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 {BuildOutputEntry} params.entry + * @returns {BuildOutputRecord & { 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 {(BuildOutputRecord & { 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 {BuildOutputRecord & { url: string, revision: string | null, bytes: number | null }} entry + * @returns {BuildOutputEntry} + */ +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 {BuildOutputEntry} 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 output 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 {BuildOutputKind} kind + */ +function priority (kind) { + return KIND_PRIORITY.get(kind) ?? -1 +} diff --git a/lib/build-output-manifest/schema.json b/lib/build-output-manifest/schema.json new file mode 100644 index 0000000..db2f25a --- /dev/null +++ b/lib/build-output-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/build-output-manifest/schema.json", + "title": "DomStack build output 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/build-output-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 build output 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/build-pages/index.js b/lib/build-pages/index.js index 854cb18..cef1936 100644 --- a/lib/build-pages/index.js +++ b/lib/build-pages/index.js @@ -2,6 +2,7 @@ * @import { BuilderOptions } from './page-builders/page-writer.js' * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' * @import { ResolvedLayout } from './page-data.js' + * @import { BuildOutputRecord } from '../build-output-manifest/index.js' */ import { Worker } from 'worker_threads' @@ -20,8 +21,7 @@ const __dirname = import.meta.dirname /** * @typedef {{ - * pages: Awaited>[] - * templates: Awaited>[] + * outputs: BuildOutputRecord[] * }} PageBuilderReport */ @@ -128,19 +128,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: [], @@ -249,14 +248,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. @@ -265,14 +263,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 b286a71..9edcb2b 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 { BuildOutputRecord } from '../../build-output-manifest/index.js' */ import { join } from 'path' import { writeFile, mkdir } from 'fs/promises' +import { createOutputRecord } from '../../build-output-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 {BuildOutputRecord[]} */ + const outputs = [ + createOutputRecord({ + 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(createOutputRecord({ + 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 98ff90f..dd076f7 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 { BuildOutputRecord } from '../../build-output-manifest/index.js' */ import { join, resolve, dirname } from 'path' +import { relative, sep, isAbsolute } from 'node:path' import { writeFile, mkdir } from 'fs/promises' +import { createOutputRecord } from '../../build-output-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 {BuildOutputRecord[]} */ + 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 {BuildOutputRecord[]} 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(createOutputRecord({ + 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 08315db..3210b75 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 { BuildOutputRecord } from '../build-output-manifest/index.js' */ import { copy } from 'cpx2' +import { createCopiedOutputRecords } from '../build-output-manifest/index.js' /** - * @typedef {Awaited>} StaticBuilderReport + * @typedef {{ outputs: BuildOutputRecord[] }} 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 = createCopiedOutputRecords({ + 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 61489e3..c553b36 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -5,6 +5,8 @@ * @import { PageBuildStepResult } from './build-pages/index.js' * @import { StaticBuildStepResult } from './build-static/index.js' * @import { CopyBuildStepResult } from './build-copy/index.js' + * @import { BuildOutputManifest } from './build-output-manifest/index.js' + * @import { BuildOutputRecord } from './build-output-manifest/index.js' */ import { buildPages } from './build-pages/index.js' @@ -14,6 +16,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 { + buildOutputManifest, + resolveOutputManifestOptions, + shouldWriteOutputManifest, + writeOutputManifest, +} from './build-output-manifest/index.js' /** * @typedef {Array} BuildStepErrors @@ -47,6 +55,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @typedef DomStackOpts * @property {boolean|undefined} [static=true] - Enable/disable static file processing * @property {boolean|undefined} [metafile=true] - Enable/disable the writing of the esbuild metadata file. + * @property {boolean | { write?: boolean, filename?: string, exclude?: string[] } | undefined} [outputManifest=true] - Configure writing the domstack output manifest file. Programmatic builds always return it. * @property {string[]|undefined} [ignore=[]] - Array of ignore strings * @property {string[]|undefined} [target=[]] - Array of target strings to pass to esbuild * @property {boolean|undefined} [buildDrafts=false] - Build draft files with the published:false variable @@ -65,6 +74,7 @@ import { ensureDest } from './helpers/ensure-dest.js' * @property {StaticBuildStepResult} [staticResults] * @property {CopyBuildStepResult} [copyResults] * @property {PageBuildStepResult} [pageBuildResults] + * @property {BuildOutputManifest} [outputManifest] * @property {BuildStepWarnings} warnings */ @@ -116,7 +126,7 @@ export async function builder (src, dest, opts) { buildEsbuild(src, dest, siteData, opts), opts.static ? buildStatic(src, dest, siteData, opts) - : Promise.resolve(), + : Promise.resolve(null), buildCopy(src, dest, siteData, opts), ]) @@ -154,7 +164,39 @@ 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 outputManifestOptions = await resolveOutputManifestOptions({ + manifestSettingsPath: siteData?.manifestSettings?.filepath, + opts, + }) + + const baseOutputRecords = collectOutputRecords( + esbuildResults, + staticResults, + copyResults, + pageBuildResults + ) + + const outputManifest = await buildOutputManifest({ + dest, + records: baseOutputRecords, + options: outputManifestOptions, + }) + + results.outputManifest = outputManifest + + if (shouldWriteOutputManifest(opts)) { + await writeOutputManifest(dest, outputManifest, outputManifestOptions.filename) + } + + return results +} + +/** + * @param {...{ report?: { outputs?: BuildOutputRecord[] } } | null | undefined} results + * @returns {BuildOutputRecord[]} + */ +function collectOutputRecords (...results) { + return results.flatMap(result => result?.report?.outputs ?? []) } diff --git a/lib/helpers/domstack-error.js b/lib/helpers/domstack-error.js index bb8a6d3..6576525 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 b911549..d00267b 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_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 12d4449..6a518f8 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 { BuildOutputRecord } from '../build-output-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, + manifestSettings: results?.siteData?.manifestSettings?.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?: BuildOutputRecord[] } } | null | undefined} result + * @returns {BuildOutputRecord[]} + */ +function getReportOutputs (result) { + return result?.report?.outputs ?? [] +} - targetNode.leaf[basename(srcFile)] = destFile +/** + * @param {BuildOutputRecord[]} 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 {BuildOutputRecord} 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 cec555a..fa77a86 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 manifestSettingsNames = nodeHasTS + ? [ + 'manifest.settings.ts', 'manifest.settings.mts', 'manifest.settings.cts', + 'manifest.settings.js', 'manifest.settings.mjs', 'manifest.settings.cjs' + ] + : ['manifest.settings.js', 'manifest.settings.mjs', '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 manifestSettings + /** @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 (manifestSettingsNames.some(name => basename(fileName) === name)) { + if (manifestSettings) { + warnings.push({ + code: 'DOM_STACK_WARNING_DUPLICATE_MANIFEST_SETTINGS', + message: `Skipping ${fileInfo.relname}. Duplicate manifest settings ${fileName} to ${manifestSettings.filepath}`, + }) + } else { + manifestSettings = 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, + manifestSettings, /** @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 3bfb45c..23158ae 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, manifestSettingsNames, 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.manifestSettings, '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.manifestSettings?.relname, 'manifest.settings.js', 'manifest settings are found') + }) + + test('identifies supported manifest settings filenames', async (t) => { + assert.ok(manifestSettingsNames.includes('manifest.settings.mjs'), 'mjs manifest settings names are supported') + assert.ok(manifestSettingsNames.includes('manifest.settings.cjs'), 'cjs manifest settings names are supported') + assert.ok(manifestSettingsNames.includes('manifest.settings.mts'), 'mts manifest settings names are supported') + assert.ok(manifestSettingsNames.includes('manifest.settings.cts'), 'cts 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 manifestSettingsName of manifestSettingsNames) { + const src = join(tmp, manifestSettingsName) + const assets = join(src, 'global-assets') + await rm(src, { recursive: true, force: true }) + await mkdir(assets, { recursive: true }) + await writeFile(join(assets, manifestSettingsName), 'export default {}\n') + + const results = await identifyPages(src) + + assert.equal(results.manifestSettings?.basename, manifestSettingsName, `${manifestSettingsName} is detected`) + assert.equal(results.manifestSettings?.relname, `global-assets/${manifestSettingsName}`, `${manifestSettingsName} 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 7e5e10b..c0ce083 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,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/build-output-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" @@ -30,6 +54,7 @@ "htm": "^3.1.1", "ignore": "^7.0.0", "js-yaml": "^4.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", @@ -83,9 +108,9 @@ "scripts": { "prepublishOnly": "npm run build && git push --follow-tags && gh-release -y", "postpublish": "npm run clean", - "version": "run-s version:*", + "version": "run-s version:changelog build:schema version:git", "version:changelog": "auto-changelog -p --template keepachangelog auto-changelog --breaking-pattern 'BREAKING CHANGE:'", - "version:git": "git add CHANGELOG.md", + "version:git": "git add CHANGELOG.md lib/build-output-manifest/schema.json", "test": "npm run clean && run-s test:*", "test:installed-check": "installed-check --ignore-dev", "test:neostandard": "eslint . --ignore-pattern 'test-cases/build-errors/src/**/*.js' --ignore-pattern 'test-cases/page-build-errors/src/**/*.js'", @@ -100,12 +125,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/build-output-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:pwa": "cd examples/pwa && npm i --production && npm run build", "example:uhtml-isomorphic": "cd examples/uhtml-isomorphic && npm i --production && npm run build", "start": "npm run watch" }, diff --git a/plans/build-output-manifest.md b/plans/build-output-manifest.md new file mode 100644 index 0000000..e3f2c46 --- /dev/null +++ b/plans/build-output-manifest.md @@ -0,0 +1,147 @@ +# Build Output Manifest + +## Status: Implemented + +## 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-output-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 + buildOutputManifest(records) + writeOutputManifest() +``` + +`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 `BuildOutputRecord` objects: + +```ts +type BuildOutputKind = + | 'page' + | 'template' + | 'script' + | 'style' + | 'chunk' + | 'worker' + | 'worker-manifest' + | 'static' + | 'copy' + | 'sourcemap' + | 'metadata' + +type BuildOutputRecord = { + outputRelname: string + filepath: string + kind: BuildOutputKind + 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 `BuildOutputEntry` objects by validating destination paths, hashing +file contents, adding byte sizes, deduping by `outputRelname`, sorting by URL, and applying filters. + +## Manifest Object + +```ts +type BuildOutputManifest = { + $schema: typeof BUILD_OUTPUT_MANIFEST_SCHEMA_ID + version: string + generatedAt: string + entries: BuildOutputEntry[] +} +``` + +`version` is a sha256 hash of sorted `(url, revision)` pairs. It does not depend on `generatedAt`. + +Programmatic builds always return `results.outputManifest`. The CLI writes +`domstack-output-manifest.json` by default unless `--no-output-manifest` or +`outputManifest: false` is used. + +domstack also exports `BUILD_OUTPUT_MANIFEST_SCHEMA_ID`, `BUILD_OUTPUT_MANIFEST_SCHEMA_PATH`, +`getBuildOutputManifestSchemaId(version)`, `buildOutputManifestSchema`, and the schema-derived public +manifest types. + +## Breadcrum Usage + +Breadcrum should ship a stable `service-worker.js` as a normal domstack template or static asset. +That service worker should: + +1. Fetch `/domstack-output-manifest.json` with `cache: 'no-store'` during install. +2. Open a cache named from `manifest.version`. +3. Precache eligible `manifest.entries`. +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. + +## Implemented Work + +- Added `lib/build-output-manifest/index.js`. +- Added `lib/build-output-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.outputManifest`. +- Added manifest writing controls: + - `--output-manifest ` + - `--no-output-manifest` + - `outputManifest.filename` + - `outputManifest.write` + - `outputManifest.exclude` +- Added `global.vars.js` `buildManifest.exclude` and `buildManifest.includeOutput(entry)` hooks. +- Fixed `metafile: false` so esbuild still produces internal metadata for output mapping but skips + writing `domstack-esbuild-meta.json`. +- Left output manifests unsupported in watch mode to keep the development pipeline simple. Watch + mode renders normal templates, but it does not write `domstack-output-manifest.json`. +- Documented the manifest API and service-worker runtime pattern in `README.md`. + +## Verification + +- `npm test` +- `npm pack --dry-run --json` confirms `lib/build-output-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 0000000..aaca2ea --- /dev/null +++ b/plans/first-class-service-workers.md @@ -0,0 +1,145 @@ +# First-Class Service Workers + +## Status: Implemented in the stacked PR + +## 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 build output 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-output-manifest.json`. + +## Proposed Source Conventions + +Support 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 should be allowed. If multiple forms are present anywhere in `src`, +fail with a clear duplicate-entry error. + +The output should be: + +```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. `buildEsbuild()` adds that entry to esbuild's entry points. +3. esbuild emits the entry as `service-worker.js` at the destination root. +4. `createEsbuildOutputRecords()` classifies it as `kind: 'service-worker'`. +5. `buildOutputManifest()` includes the service worker entry like any other emitted output. + +The service worker itself can fetch `/domstack-output-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 should use a stable output name even when other entry points use hashed +entry names. + +Options to evaluate: + +- Use esbuild object entry points with `out: 'service-worker'` and keep `entryNames` compatible. +- If global `entryNames` would still hash the entry, run a small separate esbuild build for the + service worker after the main asset build. + +Prefer one esbuild build if it stays simple. Use a separate build only if per-entry naming becomes +fragile. + +## Manifest Schema Changes + +Add a new output kind: + +```ts +type BuildOutputKind = + | ... + | '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/build-output-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.outputManifest` +- no `domstack-output-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 + +Add fixture coverage for: + +- `service-worker.js` detection anywhere in `src` and output at `/service-worker.js`. +- `service-worker.ts` detection when TypeScript is enabled. +- duplicate service worker source files fail clearly. +- service worker imports are bundled. +- output manifest includes `kind: 'service-worker'`. +- watch mode builds the service worker but does not write `domstack-output-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-output-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/build-output-manifest-schema.js b/scripts/build-output-manifest-schema.js new file mode 100644 index 0000000..57af06a --- /dev/null +++ b/scripts/build-output-manifest-schema.js @@ -0,0 +1,13 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { + BUILD_OUTPUT_MANIFEST_SCHEMA_PATH, + buildOutputManifestSchema, +} from '../lib/build-output-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, '..', BUILD_OUTPUT_MANIFEST_SCHEMA_PATH) + +await mkdir(dirname(outputPath), { recursive: true }) +await writeFile(outputPath, `${JSON.stringify(buildOutputManifestSchema, null, 2)}\n`) diff --git a/test-cases/general-features/index.test.js b/test-cases/general-features/index.test.js index 3c6e8e3..a4184ff 100644 --- a/test-cases/general-features/index.test.js +++ b/test-cases/general-features/index.test.js @@ -1,8 +1,13 @@ +/** + * @import { BuildOutputEntry } from '../../index.js' + */ + import { test } from 'node:test' import assert from 'node:assert' -import { DomStack } from '../../index.js' +import { BUILD_OUTPUT_MANIFEST_SCHEMA_ID, DomStack, buildOutputManifest } from '../../index.js' import * as path from 'path' -import { rm, stat, readFile } from 'fs/promises' +import { mkdir, mkdtemp, rm, stat, readFile, writeFile } from 'fs/promises' +import { tmpdir } from 'os' import * as cheerio from 'cheerio' import { allFiles } from 'async-folder-walker' @@ -18,6 +23,371 @@ test.describe('general-features', () => { const results = await siteUp.build() assert.ok(results, 'DomStack built site and returned build results') + assert.ok(results.outputManifest, 'build returned an output manifest') + assert.strictEqual( + results.outputManifest.$schema, + BUILD_OUTPUT_MANIFEST_SCHEMA_ID, + 'build output manifest includes its schema URL' + ) + + const outputManifestPath = path.join(dest, 'domstack-output-manifest.json') + /** @type {{ $schema: string, version: string, entries: Record[] }} */ + const writtenOutputManifest = JSON.parse(await readFile(outputManifestPath, 'utf8')) + assert.strictEqual( + writtenOutputManifest.$schema, + results.outputManifest.$schema, + 'written output manifest schema URL matches returned output manifest' + ) + assert.strictEqual( + writtenOutputManifest.version, + results.outputManifest.version, + 'written output manifest matches returned output manifest' + ) + assert.ok( + writtenOutputManifest.entries.every(entry => !('filepath' in entry)), + 'written output manifest does not expose absolute filesystem paths' + ) + + const manifestEntries = results.outputManifest.entries + const manifestEntryByUrl = new Map(manifestEntries.map(entry => [entry.url, entry])) + const publicManifestEntryKeys = new Set([ + 'outputRelname', + 'kind', + 'url', + 'revision', + 'bytes', + 'sourceRelname', + 'entryPoint', + 'pagePath', + 'pageUrl', + 'templatePath', + 'page', + ]) + + assert.ok(manifestEntryByUrl.has('/'), 'output manifest includes root page URL') + assert.ok(manifestEntryByUrl.has('/md-page/'), 'output manifest includes nested page URL') + assert.ok(manifestEntryByUrl.has('/md-page/loose-md.html'), 'output manifest includes loose markdown URL') + assert.ok(manifestEntryByUrl.has('/feeds/feed.json'), 'output manifest includes normal template output') + assert.ok(manifestEntryByUrl.has('/worker-page/workers.json'), 'output manifest includes worker manifest') + assert.ok( + !manifestEntryByUrl.has('/domstack-output-manifest.json'), + 'output manifest does not include itself' + ) + + const serviceWorkerEntry = manifestEntryByUrl.get('/service-worker.js') + assert.equal(serviceWorkerEntry?.kind, 'service-worker', 'output manifest classifies the site service worker') + assert.equal( + serviceWorkerEntry?.sourceRelname, + 'globals/service-worker.mts', + 'output manifest records the service worker source file' + ) + + assert.ok( + manifestEntries.some(entry => entry.kind === 'chunk' && entry.url.startsWith('/chunks/js/chunk-')), + 'output manifest classifies shared JS chunks' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'sourcemap' && entry.url.endsWith('.map')), + 'output manifest classifies source maps' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'worker' && entry.url.includes('/worker-page/counter.worker-')), + 'output manifest classifies worker bundles' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'copy' && entry.url === '/oldsite/client.js'), + 'output manifest includes copy directory outputs' + ) + assert.ok( + manifestEntries.some(entry => entry.kind === 'static' && entry.url === '/static.json'), + 'output manifest includes static outputs' + ) + assert.deepStrictEqual( + manifestEntryByUrl.get('/js-page/')?.page?.vars, + { precache: false, offline: false }, + 'output 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`) + assert.ok(!('filepath' in entry), `${entry.outputRelname} does not expose an absolute filesystem path`) + assert.deepStrictEqual( + Object.keys(entry).filter(key => !publicManifestEntryKeys.has(key)), + [], + `${entry.outputRelname} only includes public serialized manifest fields` + ) + } + + const serviceWorkerContent = await readFile(path.join(dest, 'service-worker.js'), 'utf8') + assert.ok(serviceWorkerContent.includes('/domstack-output-manifest.json'), 'service worker was bundled') + assert.ok(!serviceWorkerContent.includes('process.env.DOMSTACK_OUTPUT_MANIFEST_URL'), 'service worker receives the output manifest URL define') + assert.ok(!serviceWorkerContent.includes('process.env.DOMSTACK_OUTPUT_MANIFEST_ENABLED'), 'service worker receives the output manifest enabled define') + assert.ok(serviceWorkerContent.includes('cache: "no-store"'), 'service worker fetches the output manifest at runtime') + assert.ok(serviceWorkerContent.includes('caches.match(request)'), 'service worker has cache-first fetch handling') + + assert.ok(results.siteData.globalClient?.outputRelname, 'global client has an output relname') + const globalClientContent = await readFile(path.join(dest, results.siteData.globalClient.outputRelname), 'utf8') + assert.ok(globalClientContent.includes('/service-worker.js'), 'global client receives the service worker URL define') + assert.ok(!globalClientContent.includes('process.env.DOMSTACK_SERVICE_WORKER_URL'), 'global client service worker URL define was replaced') + assert.ok(!globalClientContent.includes('process.env.DOMSTACK_SERVICE_WORKER_SCOPE'), 'global client service worker scope define was replaced') + + 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 stableResults = await siteUp.build() + assert.strictEqual( + stableResults.outputManifest?.version, + results.outputManifest.version, + 'output manifest version is stable across identical builds' + ) + + await t.test('outputManifest version includes cache-relevant metadata', async () => { + const basePage = { + path: '', + url: '/', + vars: { + precache: true, + offline: true, + }, + } + /** @type {BuildOutputEntry} */ + const baseEntry = { + outputRelname: 'index.html', + kind: 'page', + url: '/', + revision: 'same-file-revision', + bytes: 42, + sourceRelname: 'pages/index.js', + page: basePage, + } + + const baseManifest = await buildOutputManifest({ dest, entries: [baseEntry] }) + const sourceOnlyManifest = await buildOutputManifest({ + dest, + entries: [{ + ...baseEntry, + sourceRelname: 'pages/renamed-index.js', + }], + }) + const kindChangedManifest = await buildOutputManifest({ + dest, + entries: [{ + ...baseEntry, + kind: 'template', + }], + }) + const offlineChangedManifest = await buildOutputManifest({ + dest, + entries: [{ + ...baseEntry, + page: { + ...basePage, + vars: { + ...basePage.vars, + offline: false, + }, + }, + }], + }) + const objectPrecacheManifest = await buildOutputManifest({ + dest, + entries: [{ + ...baseEntry, + page: { + ...basePage, + vars: { + ...basePage.vars, + precache: { core: true, priority: 1 }, + }, + }, + }], + }) + const reorderedObjectPrecacheManifest = await buildOutputManifest({ + dest, + entries: [{ + ...baseEntry, + page: { + ...basePage, + vars: { + ...basePage.vars, + precache: { priority: 1, core: true }, + }, + }, + }], + }) + + assert.strictEqual( + sourceOnlyManifest.version, + baseManifest.version, + 'source metadata does not affect output manifest version' + ) + assert.notStrictEqual( + kindChangedManifest.version, + baseManifest.version, + 'kind affects output manifest version' + ) + assert.notStrictEqual( + offlineChangedManifest.version, + baseManifest.version, + 'page offline policy affects output manifest version' + ) + assert.strictEqual( + reorderedObjectPrecacheManifest.version, + objectPrecacheManifest.version, + 'object-valued page cache policy uses stable key ordering' + ) + }) + + await t.test('outputManifest exclude handles root page URL', async () => { + const excludeDest = path.join(__dirname, './public-exclude-root') + const excludeSite = new DomStack(src, excludeDest, { + copy: [path.join(__dirname, './copyfolder')], + outputManifest: { + exclude: ['oldsite/**'], + }, + }) + t.after(async () => { + await rm(excludeDest, { recursive: true, force: true }) + }) + + await rm(excludeDest, { recursive: true, force: true }) + + const excludeResults = await excludeSite.build() + const excludeEntries = excludeResults.outputManifest?.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('manifest.settings.js filters output 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 () => "

Manifest settings

"\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, 'manifest.settings.js'), ` +export default async function manifestSettings () { + return { + exclude: ['settings/**'], + includeOutput (entry) { + return entry.kind !== 'sourcemap' + }, + } +} +`) + + const settingsSite = new DomStack(settingsSrc, settingsDest, { + outputManifest: { + exclude: ['programmatic/**'], + }, + }) + + const settingsResults = await settingsSite.build() + const settingsEntries = settingsResults.outputManifest?.entries ?? [] + + assert.ok( + settingsEntries.some(entry => entry.url === '/'), + 'root page survives 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 outputManifest exclude is applied' + ) + assert.ok( + !settingsEntries.some(entry => entry.url === '/settings/'), + 'manifest.settings.js exclude is applied' + ) + assert.ok( + !settingsEntries.some(entry => entry.kind === 'sourcemap'), + 'manifest.settings.js includeOutput is applied' + ) + }) + + await t.test('metafile false skips esbuild metadata without breaking output manifest', async () => { + const noMetaDest = path.join(__dirname, './public-no-meta') + const noMetaSite = new DomStack(src, noMetaDest, { + copy: [path.join(__dirname, './copyfolder')], + metafile: false, + }) + t.after(async () => { + await rm(noMetaDest, { recursive: true, force: true }) + }) + + await rm(noMetaDest, { recursive: true, force: true }) + + const noMetaResults = await noMetaSite.build() + const noMetaEntries = noMetaResults.outputManifest?.entries ?? [] + + assert.ok(noMetaResults.outputManifest, 'build returned an output manifest with metafile disabled') + assert.ok( + noMetaEntries.some(entry => entry.kind === 'script'), + 'output manifest still includes esbuild script outputs' + ) + assert.ok( + noMetaEntries.some(entry => entry.kind === 'sourcemap'), + 'output manifest still includes esbuild sourcemap outputs' + ) + assert.ok( + !noMetaEntries.some(entry => entry.kind === 'metadata' && entry.url === '/domstack-esbuild-meta.json'), + 'output 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 outputManifest filename', async () => { + const customManifestDest = path.join(__dirname, './public-custom-manifest') + const customManifestSite = new DomStack(src, customManifestDest, { + outputManifest: { + filename: 'metadata/build-output.json', + }, + }) + t.after(async () => { + await rm(customManifestDest, { recursive: true, force: true }) + }) + + await rm(customManifestDest, { recursive: true, force: true }) + + await customManifestSite.build() + const customServiceWorkerContent = await readFile(path.join(customManifestDest, 'service-worker.js'), 'utf8') + + await stat(path.join(customManifestDest, 'metadata/build-output.json')) + await assert.rejects( + () => stat(path.join(customManifestDest, 'domstack-output-manifest.json')), + 'default output manifest filename was not written' + ) + assert.ok( + customServiceWorkerContent.includes('/metadata/build-output.json'), + 'service worker receives the nested custom output manifest URL define' + ) + }) const globalAssets = { globalStyle: true, diff --git a/test-cases/general-features/src/feeds.template.js b/test-cases/general-features/src/feeds.template.js index 6cf6154..663f05a 100644 --- a/test-cases/general-features/src/feeds.template.js +++ b/test-cases/general-features/src/feeds.template.js @@ -1,9 +1,12 @@ +/** + * @import { TemplateAsyncIterator } from '../../../index.js' + */ import pMap from 'p-map' // @ts-ignore import jsonfeedToAtom from 'jsonfeed-to-atom' /** - * @type {import('../../../index.js').TemplateAsyncIterator<{ + * @type {TemplateAsyncIterator<{ * title: string, * layout: string, * siteName: string, diff --git a/test-cases/general-features/src/globals/global.client.js b/test-cases/general-features/src/globals/global.client.js index b6455a6..055a9a3 100644 --- a/test-cases/general-features/src/globals/global.client.js +++ b/test-cases/general-features/src/globals/global.client.js @@ -1,6 +1,8 @@ // @ts-ignore import { toggleTheme } from 'mine.css' console.log('This runs on every page') +console.log('Domstack service worker URL:', process.env['DOMSTACK_SERVICE_WORKER_URL']) +console.log('Domstack service worker scope:', process.env['DOMSTACK_SERVICE_WORKER_SCOPE']) export default 'global.client.js' 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 0000000..de74c4f --- /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 BuildOutputEntry = { + revision?: unknown, + kind: string, + url: string +} + +type BuildOutputManifest = { + version: string, + entries: BuildOutputEntry[] +} + +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 loadBuildManifest() + 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 loadBuildManifest() + 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 loadBuildManifest (): Promise { + return await loadManifest() as BuildOutputManifest +} 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 7cccd01..f061ca5 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 0000000..3e22e7a --- /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_OUTPUT_MANIFEST_URL'] ?? '' +export const DOMSTACK_OUTPUT_MANIFEST_ENABLED = process.env['DOMSTACK_OUTPUT_MANIFEST_ENABLED'] ?? 'false' +export const CACHE_PREFIX = 'domstack-precache-' + +export async function loadManifest () { + if (DOMSTACK_OUTPUT_MANIFEST_ENABLED !== 'true') { + throw new Error('Domstack output manifest is not enabled') + } + const response = await fetch(DOMSTACK_MANIFEST_URL, { cache: 'no-store' }) + if (!response.ok) throw new Error('Unable to load domstack output manifest') + return response.json() +} diff --git a/test-cases/general-features/src/manifest.settings.js b/test-cases/general-features/src/manifest.settings.js new file mode 100644 index 0000000..b1c6ea4 --- /dev/null +++ b/test-cases/general-features/src/manifest.settings.js @@ -0,0 +1 @@ +export default {} diff --git a/test-cases/general-features/src/templates/async-iterator.template.js b/test-cases/general-features/src/templates/async-iterator.template.js index 156053d..f4be8a5 100644 --- a/test-cases/general-features/src/templates/async-iterator.template.js +++ b/test-cases/general-features/src/templates/async-iterator.template.js @@ -1,5 +1,9 @@ /** - * @type {import('../../../../index.js').TemplateAsyncIterator<{ + * @import { TemplateAsyncIterator } from '../../../../index.js' + */ + +/** + * @type {TemplateAsyncIterator<{ * foo: string, * testVar: string * }>} diff --git a/test-cases/general-features/src/templates/object-array.template.js b/test-cases/general-features/src/templates/object-array.template.js index d55e884..406ea95 100644 --- a/test-cases/general-features/src/templates/object-array.template.js +++ b/test-cases/general-features/src/templates/object-array.template.js @@ -1,5 +1,9 @@ /** - * @type {import('../../../../index.js').TemplateFunction<{ + * @import { TemplateFunction } from '../../../../index.js' + */ + +/** + * @type {TemplateFunction<{ * foo: string, * testVar: string * }>} diff --git a/test-cases/general-features/src/templates/simple.txt.template.js b/test-cases/general-features/src/templates/simple.txt.template.js index 23a6032..3bd8b52 100644 --- a/test-cases/general-features/src/templates/simple.txt.template.js +++ b/test-cases/general-features/src/templates/simple.txt.template.js @@ -1,5 +1,9 @@ /** - * @type {import('../../../../index.js').TemplateFunction<{ + * @import { TemplateFunction } from '../../../../index.js' + */ + +/** + * @type {TemplateFunction<{ * foo: string, * testVar: string * }>} diff --git a/test-cases/general-features/src/templates/single-object.template.js b/test-cases/general-features/src/templates/single-object.template.js index 0a10027..d4ee02d 100644 --- a/test-cases/general-features/src/templates/single-object.template.js +++ b/test-cases/general-features/src/templates/single-object.template.js @@ -1,5 +1,9 @@ /** - * @type {import('../../../../index.js').TemplateFunction<{ + * @import { TemplateFunction } from '../../../../index.js' + */ + +/** + * @type {TemplateFunction<{ * foo: string, * }>} */ 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 0000000..89064ba --- /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 0000000..e23a3d1 --- /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 0000000..0f6a0d7 --- /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.js b/test-cases/type-exports/index.test.js index ac0ab5d..c7d1a5b 100644 --- a/test-cases/type-exports/index.test.js +++ b/test-cases/type-exports/index.test.js @@ -1,20 +1,97 @@ // @ts-check +/** + * @import { + * BuildOutputEntry, + * BuildOutputEntryPageMeta, + * BuildOutputKind, + * BuildOutputManifest, + * GlobalDataFunctionParams, + * LayoutFunctionParams, + * PageFunctionParams, + * PageInfo, + * TemplateFunctionParams, + * TemplateInfo, + * } from '../../index.js' + */ import { test } from 'node:test' import assert from 'node:assert' -import { PageData } from '../../index.js' +import { readFile } from 'node:fs/promises' +import { + BUILD_OUTPUT_MANIFEST_SCHEMA_ID, + BUILD_OUTPUT_MANIFEST_SCHEMA_PATH, + PageData, + buildOutputEntryPageMetaSchema, + buildOutputEntrySchema, + buildOutputKindSchema, + buildOutputManifestSchema, + getBuildOutputManifestSchemaId, +} from '../../index.js' +// Smoke test that public types are importable from the package entry point. +// The imports above are verified by TypeScript at compile time via `npm run test:tsc`. /** - * Smoke test that all public types are importable from the package entry point. - * The type imports below are verified by TypeScript at compile time via `npm run test:tsc`. - * - * @typedef {import('../../index.js').PageInfo} PageInfo - * @typedef {import('../../index.js').TemplateInfo} TemplateInfo - * @typedef {import('../../index.js').LayoutFunctionParams} LayoutFunctionParams - * @typedef {import('../../index.js').GlobalDataFunctionParams} GlobalDataFunctionParams - * @typedef {import('../../index.js').PageFunctionParams} PageFunctionParams - * @typedef {import('../../index.js').TemplateFunctionParams} TemplateFunctionParams + * @typedef {BuildOutputEntry} ImportedBuildOutputEntry + * @typedef {BuildOutputEntryPageMeta} ImportedBuildOutputEntryPageMeta + * @typedef {BuildOutputKind} ImportedBuildOutputKind + * @typedef {BuildOutputManifest} ImportedBuildOutputManifest + * @typedef {GlobalDataFunctionParams} ImportedGlobalDataFunctionParams + * @typedef {LayoutFunctionParams} ImportedLayoutFunctionParams + * @typedef {PageFunctionParams} ImportedPageFunctionParams + * @typedef {PageInfo} ImportedPageInfo + * @typedef {TemplateFunctionParams} ImportedTemplateFunctionParams + * @typedef {TemplateInfo} ImportedTemplateInfo */ test('PageData is importable from the package entry point', () => { assert.strictEqual(typeof PageData, 'function', 'PageData is a class') }) + +test('build output manifest schemas are importable from the package entry point', async () => { + const [schemaJson, packageJson] = await Promise.all([ + readFile(new URL('../../lib/build-output-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, + buildOutputManifestSchema, + 'packaged schema.json matches exported schema' + ) + assert.ok( + BUILD_OUTPUT_MANIFEST_SCHEMA_ID.includes(`@${packageInfo.version}/`), + 'manifest schema ID includes the package version' + ) + assert.strictEqual( + BUILD_OUTPUT_MANIFEST_SCHEMA_ID, + getBuildOutputManifestSchemaId(packageInfo.version), + 'schema ID helper builds the exported schema ID from the package version' + ) + assert.strictEqual( + BUILD_OUTPUT_MANIFEST_SCHEMA_PATH, + 'lib/build-output-manifest/schema.json', + 'schema path points at the packaged JSON schema' + ) + assert.strictEqual( + buildOutputManifestSchema.$id, + BUILD_OUTPUT_MANIFEST_SCHEMA_ID, + 'manifest schema uses exported schema ID' + ) + assert.strictEqual( + buildOutputManifestSchema.properties.$schema.const, + BUILD_OUTPUT_MANIFEST_SCHEMA_ID, + 'manifest instance schema field uses exported schema ID' + ) + assert.ok(buildOutputKindSchema.enum.includes('page'), 'kind schema includes pages') + assert.strictEqual( + buildOutputEntrySchema.properties.page, + buildOutputEntryPageMetaSchema, + 'entry schema uses page metadata schema' + ) + assert.strictEqual( + buildOutputManifestSchema.properties.entries.items, + buildOutputEntrySchema, + 'manifest schema uses entry schema' + ) +}) diff --git a/test-cases/watch/index.test.js b/test-cases/watch/index.test.js index 3b3e98d..10ed996 100644 --- a/test-cases/watch/index.test.js +++ b/test-cases/watch/index.test.js @@ -58,10 +58,20 @@ test.describe('watch', () => { const results = await siteUp.watch({ serve: false }) assert.ok(results, 'watch() returned initial build results') assert.ok(results.siteData, 'results include siteData') + assert.equal(results.outputManifest, undefined, 'watch mode does not return an output 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-output-manifest.json')), + 'watch mode does not write an output 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_OUTPUT_MANIFEST_ENABLED'), 'watch service worker receives the output manifest enabled define') + assert.ok(serviceWorkerContent.includes('"false"'), 'watch service worker knows the output 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 @@ -150,6 +160,27 @@ 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() + + const serviceWorkerFile = path.join(src, 'globals/service-worker.mts') + const original = await readFile(serviceWorkerFile, 'utf8') + await writeFile(serviceWorkerFile, original + '\n// touch') + + await settle(siteUp) + + const logs = getLogLines(mockLog) + assert.ok( + logs.some(l => l.includes('JS/CSS rebuild complete.')), + 'esbuild rebundled 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()