diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 152df60e..123842dd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,7 @@ -# Basic dependabot.yml file with -# minimum configuration for two package managers - version: 2 updates: - # Enable version updates for npm - package-ecosystem: "npm" + directory: "/" groups: typescript: patterns: @@ -13,150 +10,20 @@ updates: - "typescript" preact: patterns: + - "@preact/signals" - "preact" - "preact-render-to-string" - "htm" - directory: "/" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/basic" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/css-modules/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/default-layout/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/esbuild-settings" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/markdown-settings/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/nested-dest/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/preact-isomorphic/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/react/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" react: patterns: - "react" - "react-dom" - "@types/react" - "@types/react-dom" + schedule: interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/string-layouts/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/tailwind/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/type-stripping/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/uhtml-isomorphic/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - - package-ecosystem: "npm" - directory: "/examples/worker-examples/" - groups: - typescript: - patterns: - - "@voxpelli/tsconfig" - - "@types/node" - - "typescript" - schedule: - interval: "daily" - # Enable version updates for pnpm - # Enable updates to github actions + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.gitignore b/.gitignore index 02a8f93e..2a021aba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ node_modules sandbox.js .nyc_output package-lock.json +pnpm-lock.yaml +pnpm-workspace.yaml public coverage lcov.info diff --git a/.npmignore b/.npmignore deleted file mode 100644 index ad663373..00000000 --- a/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -sandbox.js -.nyc_output -package-lock.json -public -coverage -.tap -.nova diff --git a/README.md b/README.md index 1b04d23d..400af485 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,13 @@ Usage: domstack [options] --dest, -d path to build destination directory (default: "public") --ignore, -i comma separated gitignore style ignore string --drafts Build draft pages with the `.draft.{md,js,ts,html}` page suffix. + --noEsbuildMeta skip writing the esbuild metafile to disk + --domstackManifest write the domstack manifest to disk --eject, -e eject the DOMStack default layout, style and client into the src flag directory --watch, -w build, watch and serve the site build --watch-only watch and build the src folder without serving + --serve build once and serve the destination directory without watching + --port port for --serve (default: 3000) --copy path to directories to copy into dist; can be used multiple times --help, -h show help --version, -v show version information @@ -55,6 +59,7 @@ domstack (v12.0.0) - Running `domstack` will result in a `build` by default. - Running `domstack --watch` or `domstack -w` will build the site and start an auto-reloading development web-server that watches for changes (provided by [`@domstack/sync`][domstack-sync]). +- Running `domstack --serve` will run a normal one-shot build and then serve the destination directory without watching or injecting live-reload snippets. This is useful for PWA testing because service-worker hooks and manifest versions run against stable build bytes. Add `--domstackManifest` if you also need to serve `domstack-manifest.json`. Use `domstack --serve --port 3001` to choose a different local port. - Running `domstack --eject` or `domstack -e` will extract the default layout, global styles, and client-side JavaScript into your source directory and add the necessary dependencies to your package.json. `domstack` is primarily a unix `bin` written for the [Node.js](https://nodejs.org) runtime that is intended to be installed from `npm` as a `devDependency` inside a `package.json` committed to a `git` repository. @@ -133,7 +138,9 @@ src % tree │ ├── global.vars.ts # site wide variables get defined in global.vars.ts │ ├── global.data.ts # optional file to derive and aggregate data from all pages before rendering │ ├── markdown-it.settings.ts # You can customize the markdown-it instance used to render markdown -│ └── esbuild.settings.ts # You can even customize the build settings passed to esbuild +│ ├── domstack-manifest.settings.ts # You can customize the domstack manifest +│ ├── esbuild.settings.ts # You can even customize the build settings passed to esbuild +│ └── service-worker.ts # a site service worker builds to /service-worker.js. ├── page.md # The top level page can also be a page.md (or README.md) file. ├── client.ts # the top level page can define a page scoped js client. ├── style.css # the top level page can define a page scoped css style. @@ -153,15 +160,14 @@ To run examples: ```bash $ git clone git@github.com:bcomnes/domstack.git $ cd domstack -# install the top level deps +# install the root package and all example workspaces $ npm i -$ cd example:{example-name} -# install the example deps -$ npm i -# start the example -$ npm start +# build one example workspace +$ npm --workspace @domstack/basic-example run build ``` +Each example is an npm workspace under `examples/*`, so a root `npm install` links the local `@domstack/static` package into example builds. Example packages keep `@domstack/static` declared as `file:../../.` so the local dependency target remains explicit. + ### Additional examples Here are some additional external examples of larger domstack projects. @@ -533,13 +539,30 @@ Thanks for reading my article A page referencing a layout name that doesn't have a matching layout file will result in a build error. +Layouts may also export optional default variables for pages using that layout. Like page/global vars, layout vars may be an object, a sync function, or an async function: + +```ts +export const vars = { + showSidebar: true, + pageType: 'article', +} +``` + +Layout vars are merged into the same resolved page variable cascade that pages, layouts, templates, and domstack manifest settings receive. Precedence is: + +```txt +page/frontmatter vars > page.vars.* > layout vars > global.data/global.vars > domstack defaults +``` + +This makes layout vars useful for section-wide defaults while still letting individual pages override them. + ### The default `root.layout.js` A layout is a `ts`/`js` file that `export default`'s an async or sync function that implements an outer-wrapper html template that will house the inner content from the page (`children`) being rendered. Think of the frame around a picture. That's a layout. 🖼️ It is always passed a single object argument with the following entries: -- `vars`: An object of global, page folder, and page variables merged together. Pages can customize layouts by providing or overriding global defaults. +- `vars`: The resolved page variable cascade, including domstack defaults, global vars/data, layout vars, page vars, and page builder vars/frontmatter. Pages can customize layouts by overriding global or layout defaults. - `scripts`: array of paths that should be included onto the page in a script tag src with type `module`. - `styles`: array of paths that should be included onto the page in a `link rel="stylesheet"` tag with the `href` pointing to the paths in the array. - `children`: A string of the inner content of the page, or whatever type your js page functions returns. `md` and `html` page types always return strings. @@ -562,6 +585,10 @@ type RootLayoutVars = { basePath?: string } +export const vars = { + defaultStyle: true, +} + const defaultRootLayout: LayoutFunction = ({ vars: { title, @@ -1069,6 +1096,366 @@ await pMap(allPosts, async (page) => { const html = renderCache.get(page.pageInfo.path) ?? '' ``` +## Domstack Manifest + +> [!WARNING] +> The domstack manifest, `domstack-manifest.settings.*`, first-class `service-worker.*` builds, manifest hooks, and related browser `process.env.DOMSTACK_*` defines are an unstable preview feature. +> Their names, option shapes, manifest schema, generated output, and runtime semantics may change outside of a major version while the API is validated with real PWA use cases. +> Avoid depending on this preview contract for long-lived integrations without pinning `@domstack/static` to an exact version. + +Programmatic one-shot builds return a `domstackManifest` object when a manifest consumer exists. +A consumer is either a `domstack-manifest.settings.*` file or explicit `domstackManifest` configuration. +`hooks.manifestBuilt` receives that manifest before the final service-worker bundle is emitted. +The CLI does not write `domstack-manifest.json` by default; writing the JSON file is opt-in for deployment metadata or integrations that need a runtime/public manifest artifact. + +The manifest is a normalized list of files that domstack emitted: + +```js +/** + * @import { FromSchema } from 'json-schema-to-ts' + */ +import { DOMSTACK_MANIFEST_SCHEMA_ID, domstackManifestSchema } from '@domstack/static' + +/** + * @typedef {FromSchema} DomstackManifest + */ +``` + +Equivalent shape: + +```ts +type DomstackManifest, ManifestVars = Record> = { + $schema: typeof DOMSTACK_MANIFEST_SCHEMA_ID + version: string + generatedAt: string + entries: DomstackManifestEntry[] + policy?: Policy +} + +type DomstackManifestEntry> = { + url: string + outputRelname: string + kind: 'page' | 'template' | 'script' | 'style' | 'chunk' | + 'service-worker' | 'worker' | 'worker-manifest' | 'static' | + 'copy' | 'sourcemap' | 'metadata' + revision: string | null + bytes: number | null + contentType?: string + integrity?: string + manifestVars?: ManifestVars + urlRevisioned?: boolean + static?: boolean + role?: string + sourceRelname?: string + entryPoint?: string + pagePath?: string + pageUrl?: string + templatePath?: string + page?: { + path: string + url: string + vars?: { + precache?: unknown + offline?: unknown + } + } +} +``` + +domstack exports `DOMSTACK_MANIFEST_SCHEMA_ID`, `DOMSTACK_MANIFEST_SCHEMA_PATH`, +`getDomstackManifestSchemaId(version)`, `domstackManifestSchema`, `domstackManifestEntrySchema`, +`domstackManifestEntryPageMetaSchema`, and `domstackManifestKindSchema` for tools that want the JSON Schema +contract directly. The public `DomstackManifest`, `DomstackManifestEntry`, `DomstackManifestEntryPageMeta`, +`DomstackManifestKind`, `DomstackManifestOptions`, `DomstackManifestTransformContext`, +`DomstackManifestTransform`, `DomstackManifestPolicyTransformContext`, and +`DomstackManifestPolicyTransform` types are derived from or aligned with those schemas. + +`version` is a sha256 hash of each sorted entry's cache-relevant fields: `url`, `revision`, `kind`, +`contentType`, `integrity`, `manifestVars`, `urlRevisioned`, `static`, `role`, page-level +`precache` / `offline` vars, and root `policy`. It intentionally does not depend on `generatedAt`, source +metadata such as `sourceRelname`, or the final `/service-worker.js` build output, so identical cache +inputs keep the same version and that version can be safely embedded into the service worker. + +Write the standard public manifest from the CLI with `--domstackManifest`: + +```console +domstack --domstackManifest +``` + +Use the object form when you also need programmatic manifest options: + +```js +const site = new DomStack('src', 'public', { + domstackManifest: { + write: true, + exclude: ['blog/**', '**/*.map'], + manifestVars: ['offline', 'precache'], + policy: { + offlineFallbackUrl: '/offline/', + }, + }, +}) + +const results = await site.build() +``` + +Use `domstackManifest: true` when you only want to write the standard `domstack-manifest.json` file. +Use the object form when you need programmatic `exclude`, `manifestVars`, `policy`, or `hooks` settings. +Add `write: true` to the object form only when you also want Domstack to write `domstack-manifest.json`. +Leaving `domstackManifest` unset skips the manifest pipeline unless a `domstack-manifest.settings.*` file exists. +A settings file enables the pipeline, returns `results.domstackManifest`, and runs manifest hooks, but it does not write a public JSON file unless writing is explicitly enabled. +The manifest file itself is never included in its own `entries`. +Site service workers are also omitted from manifest entries so `manifest.version` can be embedded into `/service-worker.js` without a circular hash dependency. + +You can also add a `domstack-manifest.settings.js` file anywhere under `src`: + +```js +export default { + exclude: ['admin/**'], + manifestVars: ['offline', 'precache'], + policy: { + offlineFallbackUrl: '/offline/', + }, + includeEntry (entry) { + if (entry.kind === 'sourcemap') return false + if (entry.kind === 'metadata') return false + if (entry.manifestVars?.offline === false) return false + if (entry.manifestVars?.precache === false) return false + return true + }, +} +``` + +`domstack-manifest.settings.*` supports `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, and `.cts` when Node's +TypeScript support is available. It can default export an object or a sync/async function that +returns an object: + +```js +export default async function domstackManifestSettings () { + return { + exclude: process.env.INCLUDE_BLOG_OFFLINE === '1' + ? ['**/*.map'] + : ['blog/**', '**/*.map'], + } +} +``` + +`domstackManifest.exclude` from programmatic options and `domstack-manifest.settings.*` `exclude` values are combined. +Exclude patterns are ignore-style patterns checked against both `entry.url` and `entry.outputRelname`. +Excludes run before `includeEntry(entry)`. +The `includeEntry(entry)` hook receives the public manifest entry shape, not local filesystem paths. +Custom public manifest-like files should be written from a `manifestBuilt` hook with `context.writeFile()`. + +Each entry also includes best-known service-worker/deployment metadata when domstack can derive it: + +- `contentType`: best-known build-time MIME type. This is not a guarantee that every deployment will serve the exact same HTTP `Content-Type` header. +- `integrity`: SRI-formatted SHA-256 digest derived from the same file hash as `revision`. +- `urlRevisioned`: whether the public URL already contains a content hash or equivalent revision token. +- `static`: whether the entry is domstack static browser-loadable output. In domstack/static this includes pages as well as subresources. +- `role`: normalized runtime purpose such as `navigation`, `subresource`, `worker`, or `metadata`. + +For page entries, an explicit string `manifestRole` page var overrides the domstack-derived `role`: + +```js +export default { + manifestRole: 'offline-fallback', +} +``` + +For custom manifest data, use `manifestVars` to explicitly expose selected page/app vars on entries for tools that need to inspect them, and use root `policy` for normalized service-worker or integration decisions. `DomstackManifestOptions` lets TypeScript users coordinate the emitted policy shape, emitted entry var shape, and source variable-cascade shape: + +```js +export default { + manifestVars: ['offline', 'precache'], + policy: { + offlineFallbackUrl: '/offline/', + }, +} +``` + +`manifestVars` can also be a per-entry transform, and `policy` can be a whole-manifest transform that receives final entries: + +```js +export default { + manifestVars ({ vars }) { + return typeof vars.analyticsLabel === 'string' + ? { analyticsLabel: vars.analyticsLabel } + : undefined + }, + policy ({ entries }) { + return { + offlineUrls: entries + .filter(entry => entry.manifestVars?.offline === true) + .map(entry => entry.url), + } + }, +} +``` + +Only values selected by `manifestVars` are copied into public manifest entries. Root `policy` is emitted once on the manifest. This avoids leaking arbitrary page vars while still letting service workers, Workbox hooks, and deployment tools consume a stable manifest-level policy shape. + +### Manifest built hooks + +`hooks.manifestBuilt` runs after domstack has finalized `manifest.entries`, `manifest.policy`, and +`manifest.version`, but before `/service-worker.js` is bundled and before the manifest file is written. +Hooks can write additional generated output files, or define constants that are available only to the +final service-worker bundle: + +```js +export default { + hooks: { + manifestBuilt: [context => { + const urls = context.manifest.entries + .filter(entry => entry.role === 'navigation') + .map(entry => entry.url) + + context.defineServiceWorkerConstant('__APP_NAVIGATION_URLS__', urls) + }], + }, +} +``` + +Then the service worker can declare and use the injected constant: + +```js +/** @type {string[]} */ +const navigationUrls = __APP_NAVIGATION_URLS__ +``` + +Hook context shape: + +```ts +type DomstackManifestBuiltHookContext = { + dest: string + manifest: DomstackManifest + defineServiceWorkerConstant: (identifier: string, value: unknown) => void + writeFile: (outputRelname: string, contents: string | Uint8Array) => Promise +} +``` + +`defineServiceWorkerConstant()` serializes `value` with `JSON.stringify()` and passes it to esbuild's +`define` option for the final service-worker build. Use it for precomputed service-worker policy, such +as a vanilla precache list or Workbox-shaped `precacheManifest`, when you do not want a runtime fetch or generated global script. + +### 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. + +Domstack provides build facts to browser-side bundles through esbuild `define` values. The service +worker is built after the manifest is finalized, so it additionally receives the finalized manifest +version: + +| Define | Value | +| --- | --- | +| `process.env.DOMSTACK_MANIFEST_URL` | Standard public URL for the built-in domstack manifest, `/domstack-manifest.json` | +| `process.env.DOMSTACK_MANIFEST_VERSION` | Finalized manifest version in `/service-worker.js`; `""` in other bundles | +| `process.env.DOMSTACK_MANIFEST_ENABLED` | `"true"` for one-shot builds with an enabled manifest pipeline, `"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 | + +Prefer injecting finalized service-worker policy from `hooks.manifestBuilt` instead of fetching the public manifest at runtime: + +```js +// domstack-manifest.settings.js +export default { + manifestVars: ['offline', 'precache'], + hooks: { + manifestBuilt: [context => { + context.defineServiceWorkerConstant('__APP_CACHE_POLICY__', { + version: context.manifest.version, + precacheEntries: context.manifest.entries + .filter(entry => entry.static === true) + .filter(entry => entry.revision) + .map(entry => ({ + url: entry.url, + revision: entry.urlRevisioned ? null : entry.revision, + integrity: entry.integrity, + })), + }) + }], + }, +} +``` + +Then consume that policy from the service worker: + +```js +// service-worker.js +const CACHE_PREFIX = 'domstack-precache-' +const manifestEnabled = process.env.DOMSTACK_MANIFEST_ENABLED === 'true' + +self.addEventListener('install', event => { + if (!manifestEnabled) return + event.waitUntil(precache()) +}) + +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return + event.respondWith(cacheFirst(event.request)) +}) + +async function precache () { + const policy = __APP_CACHE_POLICY__ + const cache = await caches.open(CACHE_PREFIX + policy.version) + await cache.addAll(policy.precacheEntries.map(entry => new Request(entry.url, { + cache: 'reload', + credentials: 'same-origin', + ...(entry.integrity ? { integrity: entry.integrity } : {}), + }))) +} + +async function cacheFirst (request) { + const cached = await caches.match(request) + return cached || fetch(request) +} +``` + +Use `context.writeFile()` from a manifest hook only when you intentionally need a public runtime artifact. + +Register the built service worker from your site client code, usually `global.client.js`: + +```js +const serviceWorkerUrl = process.env.DOMSTACK_SERVICE_WORKER_URL +const serviceWorkerScope = process.env.DOMSTACK_SERVICE_WORKER_SCOPE + +if (serviceWorkerUrl && serviceWorkerScope && 'serviceWorker' in navigator) { + navigator.serviceWorker.register(serviceWorkerUrl, { scope: serviceWorkerScope }) +} +``` + +domstack does not inject this into the default layout. Registration timing, update prompts, +development opt-outs, and recovery behavior are application policy, so keep that logic in your +global client or an imported client module. + +This keeps domstack's build pipeline to one page/template pass and one manifest reconciliation. Use +`domstack-manifest.settings.*` `exclude` or `includeEntry(entry)` to keep entries such as source maps, admin +routes, or blog pages out of the written manifest before the service worker sees it. + +Watch mode builds and rebundles site service-worker entries, but it does not write +`domstack-manifest.json` or return `results.domstackManifest`. Use one-shot builds when testing +service-worker and PWA cache behavior. + +Domstack does not clean the destination directory before a build. During this preview, run clean +builds for service-worker lifecycle testing and deployments so removed or renamed preview outputs +(such as `/service-worker.js` or a custom manifest filename) cannot remain as stale files. + ## Global Assets There are a few important (and optional) global assets that live anywhere in the `src` directory. If duplicate named files that match the global asset file name pattern are found, a build error will occur until the duplicate file error is resolved. @@ -1097,6 +1484,9 @@ export const browser = { ``` The exported object is passed to esbuild's [`define`](https://esbuild.github.io/api/#define) options and is available to every js bundle. +Domstack also reserves `process.env.DOMSTACK_MANIFEST_URL`, +`process.env.DOMSTACK_MANIFEST_VERSION`, `process.env.DOMSTACK_MANIFEST_ENABLED`, +`process.env.DOMSTACK_SERVICE_WORKER_URL`, and `process.env.DOMSTACK_SERVICE_WORKER_SCOPE` for generated build facts. > [!WARNING] > Setting `define` in [`esbuild.settings.ts`](#esbuild-settingsts) while also using the `browser` export will throw an error. Use one or the other. @@ -1180,6 +1570,43 @@ Use `GlobalDataFunction` or `AsyncGlobalDataFunction` to type the function **`renderInnerPage()` is available.** `global.data.js` runs after page initialization has been attempted, and receives `PageData` instances (some may be uninitialized if they failed to initialize), so you can call `renderInnerPage()` here with the same care described above for `page.vars` and other page-dependent access. For examples and performance guidance, see [Accessing rendered page content](#accessing-rendered-page-content). +### `domstack-manifest.settings.ts` + +This is an optional file you can create anywhere. +It should export a default object or a default sync/async function that returns an object. +Use this to filter the domstack manifest before hooks receive it, before domstack optionally writes +`domstack-manifest.json`, and before `results.domstackManifest` is returned. + +```js +/** + * @import { DomstackManifestEntry } from '@domstack/static' + */ + +export default { + exclude: [ + 'admin/**', + '**/*.map', + ], + includeEntry, +} + +/** + * @param {DomstackManifestEntry} entry + */ +function includeEntry (entry) { + return entry.kind !== 'metadata' +} +``` + +The supported settings are: + +- `exclude` - ignore-style patterns matched against `entry.url` and `entry.outputRelname`. +- `includeEntry(entry)` - a sync or async function that receives a public `DomstackManifestEntry` and returns `true` to keep it. + +The `domstackManifest.exclude` option and `domstack-manifest.settings.*` `exclude` values are combined. +Excludes run before `includeEntry(entry)`. +Watch mode builds and rebundles service workers, but it does not write or return the domstack manifest, so `domstack-manifest.settings.*` is only applied during one-shot builds. + ### `esbuild.settings.ts` This is an optional file you can create anywhere. @@ -1643,7 +2070,7 @@ The following diagram illustrates the DomStack build process: │ │ │ │ │ │ │ • Bundle JS/CSS │ │ • Copy static │ │ • Copy extra │ │ • Generate │ │ files │ │ directories │ -│ metafile │ │ (if enabled) │ │ from opts │ +│ records │ │ • Record files │ │ • Record files │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ └───────────────────┼───────────────────┘ @@ -1656,6 +2083,13 @@ The following diagram illustrates the DomStack build process: │ • Process MD │ │ • Process JS │ │ • Apply layouts │ + │ • Record outputs │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Reconcile │ + │ Output Manifest │ └────────┬─────────┘ │ ▼ @@ -1667,6 +2101,7 @@ The following diagram illustrates the DomStack build process: │ • staticResults │ │ • copyResults │ │ • pageResults │ + │ • domstackManifest │ │ • warnings │ └──────────────────┘ ``` @@ -1675,11 +2110,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. @@ -1741,14 +2178,15 @@ The `buildPages()` step processes pages in parallel with a concurrency limit: └───────────────────────────────┘ ``` -Variable Resolution Layers: -- **Global vars** - Site-wide variables from `global.vars.js` (resolved once) -- **Layout vars** - Layout-specific variables from layout functions (resolved once) +Variable Resolution Layers, from lowest to highest precedence: +- **Domstack defaults** - Internal defaults such as the default `layout: 'root'`. +- **Global vars** - Site-wide variables from `global.vars.js` (resolved once). +- **Global data** - Derived variables from `global.data.js`, stamped onto every page after all pages initialize. +- **Layout vars** - Optional `export const vars` from the selected layout module. - **Page-specific vars** vary by type: - - **MD pages**: page.vars.js + builder vars (from frontmatter) - - **HTML pages**: page.vars.js - - **JS pages**: exported vars → page.vars.js -- **Global data** - Derived variables from `global.data.js`, stamped onto every page after all pages initialize (resolved once, after page init) + - **MD pages**: `page.vars.js` plus builder vars from frontmatter. + - **HTML pages**: `page.vars.js`. + - **JS pages**: exported `vars` plus `page.vars.js`. ### Watch Mode @@ -1758,6 +2196,10 @@ When you run `domstack --watch` (or `domstack -w`), domstack performs an initial **chokidar watch** — Page files, layouts, templates, and config files are watched by chokidar. When a file changes, domstack determines the minimal set of pages to rebuild using dependency tracking maps built at startup. +Domstack manifests are build-only artifacts. Watch mode builds and rebundles site service-worker +entries, but it does not write `domstack-manifest.json` or return `results.domstackManifest`. +Use `domstack --serve` when testing PWA cache lifecycle behavior locally: it runs a normal manifest-enabled build and serves the result without watch-mode filenames or live-reload HTML injection, so service-worker policy matches emitted build bytes. Add `--domstackManifest` if the worker or test needs the public JSON manifest file. + #### What triggers what | Change | Rebuild scope | @@ -1771,7 +2213,8 @@ When you run `domstack --watch` (or `domstack -w`), domstack performs an initial | `markdown-it.settings.*` | All `.md` pages | | `global.data.*` | All pages and templates | | `global.vars.*` or `esbuild.settings.*` | Full rebuild (esbuild restart + all pages) | -| `client.js`, `style.css`, `*.layout.css`, `*.layout.client.*`, `global.client.*`, `global.css`, `*.worker.*` | esbuild handles it — no page rebuild | +| `domstack-manifest.settings.*` | No rebuild in watch mode; domstack manifests are only generated in one-shot builds | +| `client.js`, `style.css`, `*.layout.css`, `*.layout.client.*`, `global.client.*`, `global.css`, `*.worker.*`, `service-worker.*` | esbuild handles it — no page rebuild | | Adding or removing an esbuild entry point (e.g. creating a new `client.js`) | esbuild restart + only the affected page(s) | | Adding or removing any other file | Full rebuild | diff --git a/agents.md b/agents.md index 9e03d4f4..e4c499d5 100644 --- a/agents.md +++ b/agents.md @@ -3,8 +3,10 @@ - Write Markdown prose with one sentence per line so git diffs stay focused and readable. - Never use inline type imports. - Always favor `@import` syntax at the top of JavaScript files for JSDoc types. +- Add explicit TypeScript lib reference headers when a standalone example file relies on browser or service-worker globals, such as `/// ` for client files or `/// ` for service-worker files. - This repo does not require TypeScript declaration builds during normal development. - Type builds are only needed during publish time or when debugging types. - After running a type build, clean up the generated build files and do not leave them sitting around. - Use the cleanup scripts in `package.json` for generated type build files. +- For formatting-only ESLint failures, use `npx eslint --fix` for a quick targeted fix before rerunning lint. - When handling PR review comments, validate that each comment is correct before making changes; maintainer comments are almost always valid, but review bot comments may be wrong, and after addressing a comment, always reply with what was done. diff --git a/bin.js b/bin.js index e5cdbcfa..7fd8d680 100755 --- a/bin.js +++ b/bin.js @@ -4,10 +4,11 @@ * @import { BuildStepWarnings, DomStackOpts as DomStackOpts } from './lib/builder.js' * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts' * @import { Logger as PinoLogger } from 'pino' + * @import { BsInstance } from '@domstack/sync' */ import { readFile } from 'node:fs/promises' -import { resolve, join, relative } from 'node:path' +import { basename, resolve, join, relative } from 'node:path' import { parseArgs } from 'node:util' import { printHelpText } from 'argsclopts' import readline from 'node:readline' @@ -15,6 +16,7 @@ import process from 'process' // @ts-expect-error import tree from 'pretty-tree' import { inspect } from 'util' +import { createServer } from '@domstack/sync' import { packageDirectory } from 'package-directory' import { readPackage } from 'read-pkg' import { addPackageDependencies } from 'write-package' @@ -58,15 +60,15 @@ const options = { help: 'Build draft pages with the `.draft.{md,js,html}` page suffix.', default: false }, - target: { - type: 'string', - short: 't', - help: 'comma separated target strings for esbuild', - }, noEsbuildMeta: { type: 'boolean', help: 'skip writing the esbuild metafile to disk', }, + domstackManifest: { + type: 'boolean', + help: 'write the domstack manifest to disk', + }, + eject: { type: 'boolean', short: 'e', @@ -81,6 +83,14 @@ const options = { type: 'boolean', help: 'watch and build the src folder without serving', }, + serve: { + type: 'boolean', + help: 'build once and serve the destination directory without watching', + }, + port: { + type: 'string', + help: 'port for --serve (default: 3000)', + }, copy: { type: 'string', help: 'path to directories to copy into dist; can be used multiple times', @@ -205,8 +215,8 @@ domstack eject actions: const opts = {} if (argv['ignore']) opts.ignore = String(argv['ignore']).split(',') - if (argv['target']) opts.target = String(argv['target']).split(',') if (argv['noEsbuildMeta']) opts.metafile = false + if (argv['domstackManifest']) opts.domstackManifest = true if (argv['drafts']) opts.buildDrafts = true if (argv['copy']) { const copyPaths = Array.isArray(argv['copy']) ? argv['copy'] : [argv['copy']] @@ -217,6 +227,16 @@ domstack eject actions: const logger = createDomStackLogger() opts.logger = logger const domStack = new DomStack(src, dest, opts) + /** @type {BsInstance | null} */ + let buildServer = null + + if (argv['serve'] && (argv['watch'] || argv['watch-only'])) { + throw new Error('--serve cannot be combined with --watch or --watch-only') + } + if (argv['port'] && !argv['serve']) { + throw new Error('--port can only be combined with --serve') + } + const servePort = argv['port'] ? parsePort(String(argv['port'])) : undefined process.once('SIGINT', quit) process.once('SIGTERM', quit) @@ -226,6 +246,11 @@ domstack eject actions: await domStack.stopWatching() logger.info('Watching stopped') } + if (buildServer) { + await buildServer.exit() + buildServer = null + logger.info('Server stopped') + } logger.info('Quitting cleanly') process.exit(0) } @@ -236,6 +261,16 @@ domstack eject actions: logger.info(tree(generateTreeData(cwd, src, dest, results))) logWarnings(logger, results?.warnings) logger.info('\nBuild Success!\n\n') + if (argv['serve']) { + buildServer = await createServer({ + server: dest, + files: basename(dest), + logger: logger.child({ component: 'sync', logPrefix: '[domstack-sync]' }), + ...(servePort ? { port: servePort } : {}), + snippet: false, + }) + logger.info(`Serving ${relative(cwd, dest)} without watching. Press Ctrl-C to stop.`) + } } catch (err) { if (!(err instanceof Error || err instanceof AggregateError)) throw new Error('Non-error thrown', { cause: err }) if (err instanceof DomStackAggregateError) { @@ -259,6 +294,17 @@ domstack eject actions: } } +/** + * @param {string} value + */ +function parsePort (value) { + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('--port must be an integer between 1 and 65535') + } + return port +} + /** * @param {PinoLogger} logger * @param {BuildStepWarnings | undefined} warnings diff --git a/docs/v12-migration.md b/docs/v12-migration.md index 89f1f6aa..664c91c2 100644 --- a/docs/v12-migration.md +++ b/docs/v12-migration.md @@ -10,9 +10,12 @@ Then apply the v12 changes below. 1. [Type exports moved to `@domstack/static/types.js`](#1-type-exports-moved-to-domstackstatictypesjs) 2. [Development Server Uses @domstack/sync](#2-development-server-uses-domstacksync) 3. [Default Layout Uses fragtml](#3-default-layout-uses-fragtml) -4. [Keep Layout Dependencies Explicit](#4-keep-layout-dependencies-explicit) -5. [JSX Runtime Is Opt-In](#5-jsx-runtime-is-opt-in) -6. [Migration Checklist](#6-migration-checklist) +4. [Layout Modules Can Export Vars](#4-layout-modules-can-export-vars) +5. [Keep Layout Dependencies Explicit](#5-keep-layout-dependencies-explicit) +6. [JSX Runtime Is Opt-In](#6-jsx-runtime-is-opt-in) +7. [CLI `--target` moved to `esbuild.settings.*`](#7-cli---target-moved-to-esbuildsettings) +8. [Static Cache Manifest and Service Worker Preview](#8-static-cache-manifest-and-service-worker-preview) +9. [Migration Checklist](#9-migration-checklist) --- @@ -72,7 +75,29 @@ Markdown output passed to a layout as `children` is one example. --- -## 4. Keep Layout Dependencies Explicit +## 4. Layout Modules Can Export Vars + +Layouts can now export optional `vars` that are merged into the resolved page variable cascade for pages using that layout. Like page/global vars, layout vars may be an object, a sync function, or an async function. + +```ts +// src/layouts/article.layout.ts +export const vars = { + showSidebar: true, + pageType: 'article', +} +``` + +Precedence is: + +```txt +page/frontmatter vars > page.vars.* > layout vars > global.data/global.vars > domstack defaults +``` + +This is additive for most sites. If a layout module already exported a named `vars` value for another purpose, that value will now participate in page variable resolution. Rename that export if it was not intended as layout defaults. + +--- + +## 5. Keep Layout Dependencies Explicit DOMStack only installs dependencies for its bundled defaults. Your project is responsible for any packages imported by pages, layouts, globals, or browser clients. @@ -82,7 +107,7 @@ If you migrate those server-side templates to `fragtml`, replace those dependenc --- -## 5. JSX Runtime Is Opt-In +## 6. JSX Runtime Is Opt-In Client `.jsx` and `.tsx` bundles are still supported through esbuild. Domstack no longer configures Preact as the default JSX runtime. @@ -123,13 +148,204 @@ export default async function esbuildSettingsOverride (esbuildSettings) { --- -## 6. Migration Checklist +## 7. CLI `--target` moved to `esbuild.settings.*` + +The `domstack --target` / `domstack -t` CLI flag has been removed in v12. Configure esbuild targets in +`esbuild.settings.*` instead. + +```js +// src/esbuild.settings.js +export default function esbuildSettings (opts) { + return { + ...opts, + target: ['es2022', 'chrome120', 'firefox121', 'safari17'], + } +} +``` + +Domstack does not set a rolling “modern browser” target by default. If your project needs specific +syntax lowering, set explicit esbuild targets in this settings file. See +[esbuild's target docs](https://esbuild.github.io/api/#target) for accepted values. + +--- + +## 8. Static Cache Manifest and Service Worker Preview + +v12 adds an unstable-preview manifest pipeline for static caching and first-class site service-worker builds. + +These APIs are preview APIs. +Their names, option shapes, manifest schema, generated output, and runtime semantics may change outside of a major version while they are validated with real PWA use cases. +Pin `@domstack/static` to an exact version if you rely on this contract. + +### Public manifest writing is opt-in + +The manifest pipeline can run without writing a public `domstack-manifest.json` file. +This is useful when your app only needs the finalized manifest in a build hook to inject service-worker policy. + +Write the standard public manifest from the CLI with: + +```sh +domstack --domstackManifest +``` + +Or write it from programmatic builds with: + +```js +const site = new DomStack('src', 'public', { + domstackManifest: true, +}) +``` + +Use the object form when you also need filters, manifest vars, root policy, or hooks: + +```js +const site = new DomStack('src', 'public', { + domstackManifest: { + write: true, + exclude: ['blog/**', '**/*.map'], + manifestVars: ['offline', 'precache'], + policy: { + offlineFallbackUrl: '/offline/', + }, + }, +}) +``` + +If `domstackManifest` is unset, Domstack skips the manifest pipeline unless a `domstack-manifest.settings.*` file exists. +A settings file enables the pipeline, returns `results.domstackManifest`, and runs manifest hooks, but does not write a public JSON file unless writing is explicitly enabled. + +### Add manifest settings when you need cache policy + +Apps can add one `domstack-manifest.settings.*` file anywhere under `src`: + +```txt +domstack-manifest.settings.js +domstack-manifest.settings.mjs +domstack-manifest.settings.cjs +domstack-manifest.settings.ts +domstack-manifest.settings.mts +domstack-manifest.settings.cts +``` + +Use `manifestVars` to explicitly expose selected page, layout, global, or default vars on manifest entries. +Use `policy` for root-level application policy. +Use `includeEntry(entry)` and `exclude` to filter final public output entries. + +```ts +import type { DomstackManifestOptions } from '@domstack/static/types.js' + +const settings = { + manifestVars: ['offline', 'precache'], + policy: { + offlineFallbackUrl: '/offline/', + }, + exclude: ['admin/**', 'blog/**', '**/*.map'], + includeEntry (entry) { + if (entry.kind === 'metadata') return false + if (entry.kind === 'sourcemap') return false + if (entry.manifestVars?.offline === false) return false + if (entry.manifestVars?.precache === false) return false + return true + }, +} satisfies DomstackManifestOptions + +export default settings +``` + +### Prefer manifest hooks over runtime manifest fetches + +The primary service-worker integration point is `hooks.manifestBuilt`. +This hook receives the finalized manifest before the final `/service-worker.js` bundle is emitted. +Use `context.defineServiceWorkerConstant()` to inject JSON-serializable policy directly into the service-worker bundle. + +```ts +import type { + DomstackManifestBuiltHookContext, +} from '@domstack/static/types.js' + +export async function injectServiceWorkerPolicy ( + context: DomstackManifestBuiltHookContext +): Promise { + context.defineServiceWorkerConstant('__APP_CACHE_POLICY__', { + version: context.manifest.version, + precacheEntries: context.manifest.entries.map(entry => ({ + url: entry.url, + revision: entry.urlRevisioned ? null : entry.revision, + })), + }) +} +``` + +This avoids shipping a runtime manifest file just so a service worker can discover the build output list. +If you intentionally need a public custom artifact, write it from the hook with `context.writeFile()`. + +### Add one site service worker when needed + +Domstack reserves one site service-worker source filename: + +```txt +service-worker.js +service-worker.mjs +service-worker.cjs +service-worker.ts +service-worker.mts +service-worker.cts +``` + +The source may live anywhere under `src`, but only one is allowed. +Domstack bundles it to a stable root output path: + +```txt +/service-worker.js +``` + +Domstack does not register the service worker for you. +Your app owns registration timing, update prompts, local-development opt-outs, reset/recovery behavior, route filtering, offline fallback behavior, and runtime cache policy. + +The service worker can read Domstack's build-time browser defines: + +| Define | Value | +| --- | --- | +| `process.env.DOMSTACK_MANIFEST_URL` | Standard public URL for the built-in manifest, `/domstack-manifest.json` | +| `process.env.DOMSTACK_MANIFEST_VERSION` | Finalized manifest version in `/service-worker.js`; `""` in other bundles | +| `process.env.DOMSTACK_MANIFEST_ENABLED` | `"true"` for one-shot builds with an enabled manifest pipeline, `"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 | + +### Use `--serve` for PWA testing + +Watch mode intentionally does not write or return the domstack manifest. +It still rebuilds the site service worker, but watch-mode output is not representative of production cache invalidation. + +Use `--serve` when testing PWA install/update/offline lifecycle behavior: + +```sh +domstack --serve +``` + +Add `--domstackManifest` only if you also want to serve the public `domstack-manifest.json` file while debugging: + +```sh +domstack --serve --domstackManifest +``` + +### Avoid circular manifest dependencies + +The built-in manifest file is never included in its own `entries`. +Site service workers are also omitted from manifest entries. +This lets Domstack inject the finalized `manifest.version` into `/service-worker.js` without making the manifest hash depend on the service-worker hash. + +--- + +## 9. Migration Checklist - [ ] If you import public types from `@domstack/static`, update those imports to `@domstack/static/types.js`. - [ ] If you rely on BrowserSync-specific dev-server behavior, test watch mode with `@domstack/sync`. - [ ] If you rely on the bundled default layout, make sure pages and child layouts return HTML strings or `fragtml` template results, not Preact or HTM VNodes. +- [ ] If any layout module already exports a named `vars` value, confirm it should now act as layout defaults. - [ ] If you want to keep the v11 Preact default layout, eject on v11 before upgrading to v12. - [ ] If your ejected layout or server-side pages still import `htm/preact`, `preact`, or `preact-render-to-string`, keep those dependencies in your own `package.json`. - [ ] If you want your ejected server-side layout to match the v12 default, migrate its templates to `fragtml` and install `fragtml`. - [ ] If you use `.jsx` or `.tsx` browser clients, add an `esbuild.settings` file that configures your JSX runtime. +- [ ] If you use `domstack --target` or `domstack -t`, move that target list to `esbuild.settings.*`. - [ ] If you use Preact browser clients, keep `preact` in your project dependencies. diff --git a/eslint.config.js b/eslint.config.js index caf35f01..cd55418b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ import neostandard, { resolveIgnoresFromGitignore } from 'neostandard' export default neostandard({ env: ['browser'], + ts: true, ignores: [ ...resolveIgnoresFromGitignore(), 'test-cases/build-errors/src/**/*.js', diff --git a/examples/basic/package.json b/examples/basic/package.json index 0c33dec8..c6e3ca68 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -1,5 +1,5 @@ { - "name": "basic", + "name": "@domstack/basic-example", "version": "0.0.0", "description": "", "type": "module", @@ -14,14 +14,17 @@ "author": "Bret Comnes (https://bret.io/)", "license": "MIT", "devDependencies": { - "npm-run-all2": "^6.0.0", - "typescript": "~5.8.2", - "@voxpelli/tsconfig": "^15.0.0" + "npm-run-all2": "^9.0.2", + "typescript": "~6.0.3", + "@voxpelli/tsconfig": "^16.2.1" }, "dependencies": { "@domstack/static": "file:../../.", "fragtml": "^0.0.9", - "mine.css": "^9.0.1", + "mine.css": "^10.0.3", "highlight.js": "^11.9.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/basic/src/js-page/loose-assets/shared-lib.ts b/examples/basic/src/js-page/loose-assets/shared-lib.ts index f8cf09d1..571df0c5 100644 --- a/examples/basic/src/js-page/loose-assets/shared-lib.ts +++ b/examples/basic/src/js-page/loose-assets/shared-lib.ts @@ -9,4 +9,4 @@ const sharedLib: SharedData = { shared: 'data', } -export default sharedLib; +export default sharedLib diff --git a/examples/blog/package.json b/examples/blog/package.json index 468ec337..a9017d14 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -1,5 +1,5 @@ { - "name": "blog", + "name": "@domstack/blog-example", "version": "0.0.0", "description": "A blog example for domstack demonstrating global.data.ts, nested layouts, and feeds.", "type": "module", @@ -14,12 +14,15 @@ "author": "Bret Comnes (https://bret.io/)", "license": "MIT", "devDependencies": { - "@voxpelli/tsconfig": "^16.0.0", - "typescript": "~5.9.2" + "@voxpelli/tsconfig": "^16.2.1", + "typescript": "~6.0.3" }, "dependencies": { "@domstack/static": "file:../../.", "fragtml": "^0.0.9", - "mine.css": "^10.0.0" + "mine.css": "^10.0.3" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/blog/src/layouts/post.layout.ts b/examples/blog/src/layouts/post.layout.ts index cd1924c7..703f573b 100644 --- a/examples/blog/src/layouts/post.layout.ts +++ b/examples/blog/src/layouts/post.layout.ts @@ -43,29 +43,35 @@ const postLayout: LayoutFunction = (args) ${/* Publish date */''} - ${publishDate ? html` + ${publishDate +? html` - ` : null} + ` +: null} ${/* Updated date */''} - ${updatedDate ? html` + ${updatedDate +? html` - ` : null} + ` +: null} ${/* Tags */''} - ${vars.tags && vars.tags.length > 0 ? html` + ${vars.tags && vars.tags.length > 0 +? html` - ` : null} + ` +: null} diff --git a/examples/css-modules/package.json b/examples/css-modules/package.json index 7a9b8ebb..e5291965 100644 --- a/examples/css-modules/package.json +++ b/examples/css-modules/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/css-modules-example", "version": "0.0.0", "type": "module", "scripts": { @@ -11,15 +11,18 @@ "author": "Bret Comnes (https://bret.io/)", "license": "MIT", "dependencies": { - "@preact/signals": "^2.0.0", + "@preact/signals": "^2.9.3", "highlight.js": "^11.9.0", "htm": "^3.1.1", - "mine.css": "^9.0.1", - "preact": "^10.24.0", + "mine.css": "^10.0.3", + "preact": "^10.29.6", "preact-render-to-string": "^6.5.11", "@domstack/static": "file:../../." }, "devDependencies": { - "npm-run-all2": "^6.0.0" + "npm-run-all2": "^9.0.2" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/default-layout/package.json b/examples/default-layout/package.json index 8933b848..920ac3c5 100644 --- a/examples/default-layout/package.json +++ b/examples/default-layout/package.json @@ -1,5 +1,5 @@ { - "name": "default-layout", + "name": "@domstack/default-layout-example", "version": "0.0.0", "description": "", "main": "index.js", @@ -14,5 +14,8 @@ }, "keywords": [], "author": "Bret Comnes (https://bret.io/)", - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^22.0.0 || >=24.0.0" + } } diff --git a/examples/esbuild-settings/package.json b/examples/esbuild-settings/package.json index eb9ec3cc..0a03aa0b 100644 --- a/examples/esbuild-settings/package.json +++ b/examples/esbuild-settings/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/esbuild-settings-example", "version": "0.0.0", "type": "module", "scripts": { @@ -15,6 +15,9 @@ }, "devDependencies": { "esbuild-plugin-polyfill-node": "^0.3.0", - "npm-run-all2": "^6.0.0" + "npm-run-all2": "^9.0.2" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/markdown-settings/package.json b/examples/markdown-settings/package.json index 563782ab..1e0192ba 100644 --- a/examples/markdown-settings/package.json +++ b/examples/markdown-settings/package.json @@ -1,5 +1,5 @@ { - "name": "markdown-settings-example", + "name": "@domstack/markdown-settings-example", "version": "1.0.0", "description": "Example demonstrating markdown-it.settings.js usage", "type": "module", @@ -14,5 +14,8 @@ "markdown-it-container": "^4.0.0", "markdown-it-admonition": "1.0.4", "highlight.js": "^11.9.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/markdown-settings/src/markdown-it.settings.js b/examples/markdown-settings/src/markdown-it.settings.js index 542f4df1..15855c85 100644 --- a/examples/markdown-settings/src/markdown-it.settings.js +++ b/examples/markdown-settings/src/markdown-it.settings.js @@ -1,4 +1,6 @@ /** + * @import MarkdownIt from 'markdown-it' + * * Custom Markdown-it Configuration * * This file demonstrates how to extend DOMStack's markdown rendering @@ -44,8 +46,8 @@ function createContainer (name, defaultTitle, cssClass) { /** * Customize the markdown-it instance with additional plugins and renderers * - * @param {import('markdown-it')} md - The markdown-it instance - * @returns {import('markdown-it')} - The modified markdown-it instance + * @param {MarkdownIt} md - The markdown-it instance + * @returns {Promise} - The modified markdown-it instance */ export default async function markdownItSettingsOverride (md) { // ===================================================== diff --git a/examples/nested-dest/package.json b/examples/nested-dest/package.json index 5b42a6ae..de6f0781 100644 --- a/examples/nested-dest/package.json +++ b/examples/nested-dest/package.json @@ -1,5 +1,5 @@ { - "name": "nested-dest", + "name": "@domstack/nested-dest-example", "version": "0.0.0", "description": "", "type": "module", @@ -15,9 +15,12 @@ "license": "MIT", "dependencies": { "@domstack/static": "file:../../.", - "mine.css": "^9.0.1" + "mine.css": "^10.0.3" }, "devDependencies": { - "npm-run-all2": "^6.0.0" + "npm-run-all2": "^9.0.2" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/preact-isomorphic/package.json b/examples/preact-isomorphic/package.json index f5ce3582..3a93adcd 100644 --- a/examples/preact-isomorphic/package.json +++ b/examples/preact-isomorphic/package.json @@ -1,5 +1,5 @@ { - "name": "@domstack/preact-example", + "name": "@domstack/preact-isomorphic-example", "version": "0.0.0", "type": "module", "scripts": { @@ -11,17 +11,20 @@ "author": "Bret Comnes (https://bret.io/)", "license": "MIT", "dependencies": { - "@preact/signals": "^2.0.0", + "@preact/signals": "^2.9.3", "highlight.js": "^11.9.0", "htm": "^3.1.1", - "mine.css": "^9.0.1", - "preact": "^10.24.0", + "mine.css": "^10.0.3", + "preact": "^10.29.6", "preact-render-to-string": "^6.5.11", "@domstack/static": "file:../../." }, "devDependencies": { - "@voxpelli/tsconfig": "^15.0.0", - "npm-run-all2": "^6.0.0", - "typescript": "~5.8.2" + "@voxpelli/tsconfig": "^16.2.1", + "npm-run-all2": "^9.0.2", + "typescript": "~6.0.3" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/preact-isomorphic/src/isomorphic/client.ts b/examples/preact-isomorphic/src/isomorphic/client.ts index 026ab90d..2f9f0961 100644 --- a/examples/preact-isomorphic/src/isomorphic/client.ts +++ b/examples/preact-isomorphic/src/isomorphic/client.ts @@ -7,11 +7,11 @@ * * It uses the same component code for both environments. */ -import { html, Component } from 'htm/preact'; -import { render } from 'preact'; -import { useCallback } from 'preact/hooks'; -import { useSignal, useComputed } from '@preact/signals'; -import type { ComponentChildren, JSX } from 'preact'; +import { html, Component } from 'htm/preact' +import { render } from 'preact' +import { useCallback } from 'preact/hooks' +import { useSignal, useComputed } from '@preact/signals' +import type { JSX } from 'preact' /** * Header component props @@ -30,7 +30,7 @@ const Header = ({ name, subtitle }: HeaderProps): JSX.Element => html`

${name}

${subtitle && html`

${subtitle}

`} -`; +` /** * Todo item props @@ -58,7 +58,7 @@ const TodoItem = ({ text, completed, onToggle, onDelete }: TodoItemProps): JSX.E -`; +` /** * Counter Component using Signals @@ -66,16 +66,16 @@ const TodoItem = ({ text, completed, onToggle, onDelete }: TodoItemProps): JSX.E */ const Counter = (): JSX.Element => { // Create a signal for the count value - const count = useSignal(0); + const count = useSignal(0) // Derived state that automatically updates when count changes - const doubled = useComputed(() => count.value * 2); - const isEven = useComputed(() => count.value % 2 === 0); + const doubled = useComputed(() => count.value * 2) + const isEven = useComputed(() => count.value % 2 === 0) // Event handlers - const increment = useCallback(() => { count.value++; }, []); - const decrement = useCallback(() => { count.value > 0 && count.value--; }, []); - const reset = useCallback(() => { count.value = 0; }, []); + const increment = useCallback(() => { count.value++ }, []) + const decrement = useCallback(() => { count.value > 0 && count.value-- }, []) + const reset = useCallback(() => { count.value = 0 }, []) return html`
@@ -90,8 +90,8 @@ const Counter = (): JSX.Element => {
- `; -}; + ` +} /** * Todo item structure @@ -122,8 +122,8 @@ interface TodoAppState { * Manages a list of todos with add/toggle/delete functionality */ class TodoApp extends Component { - constructor(props: TodoAppProps) { - super(props); + constructor (props: TodoAppProps) { + super(props) // Initialize with example todos this.state = { todos: [ @@ -132,18 +132,18 @@ class TodoApp extends Component { { id: 3, text: 'Deploy to production', completed: false } ], newTodo: '' - }; + } } // Update the new todo input value updateNewTodo = (e: JSX.TargetedEvent): void => { - this.setState({ newTodo: e.currentTarget.value }); - }; + this.setState({ newTodo: e.currentTarget.value }) + } // Add a new todo item addTodo = (e: JSX.TargetedEvent): void => { - e.preventDefault(); - const { todos, newTodo } = this.state; + e.preventDefault() + const { todos, newTodo } = this.state if (newTodo.trim()) { this.setState({ @@ -156,32 +156,32 @@ class TodoApp extends Component { } ], newTodo: '' - }); + }) } - }; + } // Toggle a todo's completion status toggleTodo = (id: number): void => { - const { todos } = this.state; + const { todos } = this.state this.setState({ todos: todos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ) - }); - }; + }) + } // Delete a todo item deleteTodo = (id: number): void => { - const { todos } = this.state; + const { todos } = this.state this.setState({ todos: todos.filter(todo => todo.id !== id) - }); - }; + }) + } - render({ title }: TodoAppProps, { todos, newTodo }: TodoAppState): JSX.Element { - const remaining = todos.filter(todo => !todo.completed).length; + render ({ title }: TodoAppProps, { todos, newTodo }: TodoAppState): JSX.Element { + const remaining = todos.filter(todo => !todo.completed).length return html`
@@ -218,7 +218,7 @@ class TodoApp extends Component { <${Counter} />
- `; + ` } } @@ -242,7 +242,7 @@ export const page = (): JSX.Element => html`

-`; +` /** * Client-side only code @@ -250,11 +250,11 @@ export const page = (): JSX.Element => html` */ if (typeof window !== 'undefined') { // Find the container that was server-rendered - const renderTarget = document.querySelector('.app-main'); + const renderTarget = document.querySelector('.app-main') // Hydrate the existing HTML with interactive components if (renderTarget) { - render(page(), renderTarget); - console.log('✅ Preact isomorphic app successfully hydrated'); + render(page(), renderTarget) + console.log('✅ Preact isomorphic app successfully hydrated') } } diff --git a/examples/preact-isomorphic/src/isomorphic/page.ts b/examples/preact-isomorphic/src/isomorphic/page.ts index 6ce07734..210c52d7 100644 --- a/examples/preact-isomorphic/src/isomorphic/page.ts +++ b/examples/preact-isomorphic/src/isomorphic/page.ts @@ -1,6 +1,6 @@ -import { page } from './client.ts'; -import type { JSX } from 'preact'; +import { page } from './client.ts' +import type { JSX } from 'preact' -export default function(): JSX.Element { - return page(); +export default function (): JSX.Element { + return page() } diff --git a/examples/preact-isomorphic/src/layouts/root.layout.ts b/examples/preact-isomorphic/src/layouts/root.layout.ts index e4a03d6a..3aaf3829 100644 --- a/examples/preact-isomorphic/src/layouts/root.layout.ts +++ b/examples/preact-isomorphic/src/layouts/root.layout.ts @@ -1,6 +1,6 @@ -import { html } from 'htm/preact'; -import { render } from 'preact-render-to-string'; -import type { VNode } from 'preact'; +import { html } from 'htm/preact' +import { render } from 'preact-render-to-string' +import type { VNode } from 'preact' /** * Page variables that can be passed to the layout @@ -27,12 +27,12 @@ export interface LayoutProps { /** * Type definition for layout functions */ -export type LayoutFunction = (props: LayoutProps) => string; +export type LayoutFunction = (props: LayoutProps) => string /** * Build all of the bundles using esbuild. */ -export default function defaultRootLayout({ +export default function defaultRootLayout ({ vars: { title, siteName = 'Domstack', @@ -70,5 +70,5 @@ export default function defaultRootLayout({ `)} - `; + ` } diff --git a/examples/react/package.json b/examples/react/package.json index a4d06664..2abed9cf 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -14,16 +14,19 @@ "dependencies": { "@domstack/static": "file:../../.", "htm": "^3.1.1", - "mine.css": "^9.0.1", + "mine.css": "^10.0.3", "react": "^19.1.1" }, "devDependencies": { "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", - "@voxpelli/tsconfig": "^15.0.0", - "npm-run-all2": "^6.0.0", + "@voxpelli/tsconfig": "^16.2.1", + "npm-run-all2": "^9.0.2", "react-dom": "^19.1.1", - "typescript": "~5.8.2", + "typescript": "~6.0.3", "highlight.js": "^11.9.0" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" } } diff --git a/examples/react/src/globals/esbuild.settings.ts b/examples/react/src/globals/esbuild.settings.ts index d164d593..e7fddfce 100644 --- a/examples/react/src/globals/esbuild.settings.ts +++ b/examples/react/src/globals/esbuild.settings.ts @@ -11,7 +11,7 @@ import type { BuildOptions } from 'esbuild' * @param esbuildSettings - The default ESBuild configuration * @returns The modified ESBuild configuration */ -export default async function esbuildSettingsOverride(esbuildSettings: BuildOptions): Promise { +export default async function esbuildSettingsOverride (esbuildSettings: BuildOptions): Promise { // Use React's automatic JSX runtime. esbuildSettings.jsx = 'automatic' esbuildSettings.jsxImportSource = 'react' diff --git a/examples/react/src/globals/global.client.ts b/examples/react/src/globals/global.client.ts index 782a44d7..5380f593 100644 --- a/examples/react/src/globals/global.client.ts +++ b/examples/react/src/globals/global.client.ts @@ -1,6 +1,8 @@ +/// + /** * Global client-side TypeScript for React example - * + * * This file is loaded on all pages before any page-specific TypeScript. * It's a good place to add global event listeners, polyfills, or * other initialization code that should run on every page. @@ -12,18 +14,17 @@ interface DomstackUtils { getRandomItem(array: T[]): T; } -// Extend the Window interface to include our global utilities -interface Window { - domstackUtils: DomstackUtils; +const globalWindow = window as Window & { + domstackUtils?: DomstackUtils } -console.log('React example global client TypeScript loaded'); +console.log('React example global client TypeScript loaded') // Add a class to indicate JavaScript is enabled -document.documentElement.classList.add('js-enabled'); +document.documentElement.classList.add('js-enabled') // Basic example of a global utility function -window.domstackUtils = { +globalWindow.domstackUtils = { /** * Format a date in a human-readable format * @param date - The date to format @@ -35,9 +36,9 @@ window.domstackUtils = { year: 'numeric', month: 'long', day: 'numeric' - }).format(date); + }).format(date) }, - + /** * Get a random item from an array * @param array - The array to get a random item from @@ -45,32 +46,32 @@ window.domstackUtils = { */ getRandomItem: (array: T[]): T => { if (array.length === 0) { - throw new Error("Cannot get random item from empty array"); + throw new Error('Cannot get random item from empty array') } - const index = Math.floor(Math.random() * array.length); + const index = Math.floor(Math.random() * array.length) // This assertion is safe because we've checked that array is not empty - return array[index] as T; + return array[index] as T } -}; +} // Add dark mode detection -const prefersDarkMode: MediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); +const prefersDarkMode: MediaQueryList = window.matchMedia('(prefers-color-scheme: dark)') if (prefersDarkMode.matches) { - document.body.classList.add('dark-mode-preferred'); + document.body.classList.add('dark-mode-preferred') } // Listen for dark mode changes prefersDarkMode.addEventListener('change', (e: MediaQueryListEvent): void => { if (e.matches) { - document.body.classList.add('dark-mode-preferred'); + document.body.classList.add('dark-mode-preferred') } else { - document.body.classList.remove('dark-mode-preferred'); + document.body.classList.remove('dark-mode-preferred') } -}); +}) // Example of measuring and logging performance -const pageLoadTime: number = performance.now(); +const pageLoadTime: number = performance.now() window.addEventListener('load', (): void => { - const totalLoadTime: number = performance.now() - pageLoadTime; - console.log(`Page fully loaded in ${totalLoadTime.toFixed(2)}ms`); -}); \ No newline at end of file + const totalLoadTime: number = performance.now() - pageLoadTime + console.log(`Page fully loaded in ${totalLoadTime.toFixed(2)}ms`) +}) diff --git a/examples/react/src/layouts/root.layout.ts b/examples/react/src/layouts/root.layout.ts index a06208eb..f2611a17 100644 --- a/examples/react/src/layouts/root.layout.ts +++ b/examples/react/src/layouts/root.layout.ts @@ -22,7 +22,7 @@ interface LayoutProps { * React components will be mounted client-side after the page loads. * Uses React DOM Server for server-side rendering. */ -export default function rootLayout({ +export default function rootLayout ({ vars: { title, siteName = 'React TypeScript Example', diff --git a/examples/react/src/react-page/client.tsx b/examples/react/src/react-page/client.tsx index 03ffada9..2fed2fc3 100644 --- a/examples/react/src/react-page/client.tsx +++ b/examples/react/src/react-page/client.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { createRoot } from 'react-dom/client'; +import React, { useState, useEffect } from 'react' +import { createRoot } from 'react-dom/client' // Type definitions interface CounterProps { @@ -8,25 +8,25 @@ interface CounterProps { } // Simple counter component with React hooks -const Counter: React.FC = ({ initialValue = 0, label = "Counter Component" }) => { - const [count, setCount] = useState(initialValue); - - const increment = (): void => setCount(count + 1); - const decrement = (): void => { if (count > 0) setCount(count - 1); }; - const reset = (): void => setCount(0); - +const Counter: React.FC = ({ initialValue = 0, label = 'Counter Component' }) => { + const [count, setCount] = useState(initialValue) + + const increment = (): void => setCount(count + 1) + const decrement = (): void => { if (count > 0) setCount(count - 1) } + const reset = (): void => setCount(0) + return ( -
+

{label}

Current count: {count}

-
- - - +
+ + +
- ); -}; + ) +} interface ThemeToggleProps { defaultDark?: boolean; @@ -34,29 +34,29 @@ interface ThemeToggleProps { // Color theme toggle component const ThemeToggle: React.FC = ({ defaultDark = false }) => { - const [isDark, setIsDark] = useState(defaultDark); - + const [isDark, setIsDark] = useState(defaultDark) + useEffect((): void => { if (isDark) { - document.body.classList.add('dark-theme'); + document.body.classList.add('dark-theme') } else { - document.body.classList.remove('dark-theme'); + document.body.classList.remove('dark-theme') } - }, [isDark]); - + }, [isDark]) + return ( -
+

Theme Toggle

Current theme: {isDark ? 'Dark' : 'Light'}

-
- ); -}; + ) +} interface FormData { name: string; @@ -70,125 +70,127 @@ const SimpleForm: React.FC = () => { name: '', email: '', feedback: '' - }); - const [isSubmitted, setIsSubmitted] = useState(false); - + }) + const [isSubmitted, setIsSubmitted] = useState(false) + const handleChange = (e: React.ChangeEvent): void => { - const { name, value } = e.target; + const { name, value } = e.target setFormData(prev => ({ ...prev, [name]: value - })); - }; - + })) + } + const handleSubmit = (e: React.FormEvent): void => { - e.preventDefault(); - console.log('Form submitted:', formData); - setIsSubmitted(true); - + e.preventDefault() + console.log('Form submitted:', formData) + setIsSubmitted(true) + // Reset form after 3 seconds setTimeout((): void => { - setIsSubmitted(false); + setIsSubmitted(false) setFormData({ name: '', email: '', feedback: '' - }); - }, 3000); - }; - + }) + }, 3000) + } + return ( -
+

Simple Form

- - {isSubmitted ? ( -
-

Thanks for your feedback, {formData.name}!

-

We'll be in touch at {formData.email}

-
- ) : ( -
-
- - -
- -
- - -
- -
- -