diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51671fa2..55f78bcc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,13 +24,7 @@ jobs: - run: npm i - run: npx playwright install --with-deps chromium - name: Run tests - run: | - node_major=$(node -p "process.versions.node.split('.')[0]") - if [ "$node_major" -lt 23 ]; then - NODE_OPTIONS="--experimental-strip-types" npm test - else - npm test - fi + run: npm test - name: Coveralls uses: coverallsapp/github-action@v2 with: diff --git a/.gitignore b/.gitignore index 7abcc4ea..35cf472c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !.claude/hooks/ !.claude/hooks/worktree-hook.sh !.claude/settings.json +.delta/* node_modules sandbox.js .nyc_output diff --git a/README.md b/README.md index 27ab8385..01b78a52 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,48 @@ # domstack + [![npm version](https://img.shields.io/npm/v/@domstack/static.svg)](https://npmjs.org/package/@domstack/static) [![npm beta version](https://img.shields.io/npm/v/@domstack/static/beta.svg?label=beta)](https://www.npmjs.com/package/@domstack/static?activeTab=versions) [![Actions Status](https://github.com/bcomnes/domstack/workflows/tests/badge.svg)](https://github.com/bcomnes/domstack/actions) [![Coverage Status](https://coveralls.io/repos/github/bcomnes/domstack/badge.svg?branch=master)](https://coveralls.io/github/bcomnes/domstack?branch=master) -[![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)](https://github.com/voxpelli/types-in-js) -[![Neocities][neocities-img]](https://domstack.net) - -`domstack`: Cut the [πŸͺ’ gordian knot](https://en.wikipedia.org/wiki/Gordian_Knot) of modern web development and build websites with a stack of HTML, CSS, and Javascript (Typescript and JSX included). - -[DOMStack](#) provides a few project conventions around [esbuild][esbuild] ande [Node.js](https://nodejs.org/en) that lets you quickly, cleanly and easily build websites and web apps using all of your favorite technolgies without any framework specific impurities, unlocking the web platform as a freeform canvas, by simply placing some standard file types into a directory structure that represents the website. It's deceptively simple, highly efficient and very flexible and powerful. - -```console -npm install @domstack/static@beta -``` - -> [!NOTE] -> DOMStack v12 is currently published under npm's `beta` dist-tag. Omit `@beta` to install the latest stable release. - -- 🌎 [domstack docs website](https://domstack.net) -- πŸ’¬ [Discord Chat](https://discord.gg/AVTsPRGeR9) -- πŸ“’ [v12 Migration Guide](docs/v12-migration.md) -- πŸ“š [fragtml docs][fragtml-docs] -- πŸ“’ [v11 - top-bun is now domstack](docs/v11-migration.md) -- πŸ“’ [v7 Announcement](https://bret.io/blog/2023/reintroducing-top-bun/) - -## Table of Contents - -[[toc]] - -## Usage - -```console -$ domstack --help -Usage: domstack [options] - - Example: domstack --src website --dest public - - --src, -s path to source directory (default: "src") - --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 -domstack (v12.0.0) -``` - -`domstack` builds a `src` directory into a `dest` directory (default: `public`). -- 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 --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 a devtool. It's 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. -It can be used outside of this context, but it works best within it. - -## Core Concepts - -`domstack` builds pages from a `src` directory into a destination directory, usually `public`. Page URLs follow the source directory structure, creating a filesystem router without separate routing configuration. +DOMStack builds static websites and multi-page apps from [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML), [Markdown](https://commonmark.org/), [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), and [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript). +No special syntax to learn. +No editor plugins to install. +No complex configuration files to learn. +Just create pages in a directory, and DOMStack builds your site. +It's built around [Node.js](https://nodejs.org/) and [esbuild](https://esbuild.github.io/), with a bunch of features that are there when you need them and stay out of the way when you don't. + +[Documentation](docs/) Β· [Examples](docs/example-projects/) Β· [v12 migration guide](docs/migrations/v12-migration.md) Β· [Discord](https://discord.gg/AVTsPRGeR9) + +`domstack` supports: + +- A natural [filesystem-based router](docs/pages/#page-files) +- Reusable and composable [layouts](docs/layouts/) with fully customizable [templating systems](docs/layouts/#custom-layout-renderers) +- [Markdown pages with frontmatter](docs/pages/#md-pages) +- [HTML pages](docs/pages/#html-pages) (with template support) +- [TS/JS pages](docs/pages/#ts-pages) (pages generated with anything you want) +- A comprehensive [variable cascade system](docs/pages/#variables) (global, layout, and page variables) +- [Static asset management](docs/assets/#static-assets) +- A [live-reloading development server](docs/cli/#usage) (with cross-device sync and debugging tools) +- Fast builds +- Faster [incremental rebuilds](docs/implementation/#watch-mode) +- [esbuild](docs/settings/#esbuildsettingsts)-based [page](docs/pages/#page-client-bundles), [layout](docs/layouts/#layout-client-bundles), and [global client bundling](docs/global-bundles/#global-client-bundles) ([TSX/JSX supported](docs/pages/#tsx)) +- [esbuild](docs/settings/#esbuildsettingsts)-based [page](docs/pages/#page-styles), [layout](docs/layouts/#layout-styles), and [global CSS bundling](docs/global-bundles/#global-styles) +- A [global data introspection and collection pipeline](docs/data/) +- Expressive (optional) [TypeScript support](docs/typescript/) +- A comprehensive [build manifest](docs/workers/#domstack-manifest) (for offline MPA support) +- [Service worker support](docs/workers/#service-workers) +- [Web worker support](docs/workers/#web-workers) +- [Page generators](docs/generation/#generated-pages) (generate pages from other pages) +- [Template generators](docs/generation/#templates) (generate anything from pages) +- [Test helpers](docs/api/#test-builds) +- A [default layout and stylesheet](docs/layouts/#the-default-rootlayoutts) if none are provided +- Extensive [examples](docs/example-projects/), [docs](docs/), and a [cookbook](docs/cookbook/) + +## Core concepts + +`domstack` builds pages from a `src` directory into a destination directory (usually `public`). +Page URLs follow the source directory structure, creating a filesystem router without separate routing configuration. Given this source: @@ -115,7 +92,8 @@ public/ └── diagram.svg # Copied alongside the page that uses it ``` -A page directory contains a `page.md`, `page.html`, or `page.ts` file. `README.md` may be used instead of `page.md`, making the source tree browsable on GitHub. +A page directory contains a `page.md`, `page.html`, or `page.ts` file. +`README.md` may be used instead of `page.md`, making the source tree browsable on GitHub. Pages can also have colocated assets: @@ -125,3124 +103,61 @@ Pages can also have colocated assets: - `*.worker.ts` for web workers > [!NOTE] -> Wherever you see `.ts` being used, you can also use `.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -Layouts wrap page content in complete HTML documents. The `root` layout is the default, while pages can select another layout through the `layout` variable. Global styles, browser code, and variables apply across the site regardless of where their files live in `src`. - -Templates and other advanced features can generate additional output as needed. The following sections document each convention in detail. - -`domstack` ships with sane defaults, so you can point it at a standard [markdown-documented repository](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github) and build a website with near-zero preparation. - -## Examples - -A collection of examples can be found in the [`./examples`](https://github.com/bcomnes/domstack/tree/master/examples) folder: - -- [`basic`](https://github.com/bcomnes/domstack/tree/master/examples/basic) β€” A broad tour of Markdown, HTML, and TypeScript pages, nested pages and layouts, variables, styles, client bundles, and static assets. -- [`blog`](https://github.com/bcomnes/domstack/tree/master/examples/blog) β€” A blog with derived global data, generated archive pages, redirects, nested layouts, and feed templates. -- [`css-modules`](https://github.com/bcomnes/domstack/tree/master/examples/css-modules) β€” Using CSS Modules from page code alongside global and page styles. -- [`default-layout`](https://github.com/bcomnes/domstack/tree/master/examples/default-layout) β€” Building a Markdown site with DOMStack's built-in default layout and no custom layout. -- [`esbuild-settings`](https://github.com/bcomnes/domstack/tree/master/examples/esbuild-settings) β€” Customizing the browser build through `esbuild.settings`. -- [`markdown-settings`](https://github.com/bcomnes/domstack/tree/master/examples/markdown-settings) β€” Customizing Markdown rendering with `markdown-it.settings` and Markdown-it plugins. -- [`nested-dest`](https://github.com/bcomnes/domstack/tree/master/examples/nested-dest) β€” Using the project root as `src` while writing the built site to a nested `public` directory. -- [`preact-isomorphic`](https://github.com/bcomnes/domstack/tree/master/examples/preact-isomorphic) β€” Rendering with Preact on the server and mounting page-scoped Preact and JSX in the browser. -- [`react`](https://github.com/bcomnes/domstack/tree/master/examples/react) β€” Configuring React and TypeScript for a page-scoped TSX client. -- [`static-mpa-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-offline) β€” A static multi-page app with DOMStack manifests, an offline fallback, precaching, and custom service-worker caching policies. -- [`static-mpa-workbox-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-workbox-offline) β€” The offline static MPA pattern implemented with Workbox routing, strategies, and precaching. -- [`string-layouts`](https://github.com/bcomnes/domstack/tree/master/examples/string-layouts) β€” Writing layouts that return plain HTML strings instead of using the default renderer. -- [`tailwind`](https://github.com/bcomnes/domstack/tree/master/examples/tailwind) β€” Integrating Tailwind CSS through an esbuild plugin. -- [`type-stripping`](https://github.com/bcomnes/domstack/tree/master/examples/type-stripping) β€” Using Node.js type stripping for TypeScript pages and layouts, plus a page-scoped TSX client. -- [`uhtml-isomorphic`](https://github.com/bcomnes/domstack/tree/master/examples/uhtml-isomorphic) β€” Rendering with `uhtml-isomorphic` on the server and mounting or hydrating UI in the browser. -- [`worker-example`](https://github.com/bcomnes/domstack/tree/master/examples/worker-example) β€” Bundling and communicating with page-scoped JavaScript and TypeScript Web Workers. - -To run an example: - -```bash -$ git clone git@github.com:bcomnes/domstack.git -$ cd domstack -# install the root package and all example workspaces -$ npm i -# build one example workspace -$ npm --workspace @domstack/basic-example run build -``` - -### External examples - -Here are some additional external examples of larger domstack projects. -If you have a project that uses domstack and could act as a nice example, please PR it to the list! - -- [Blog Example](https://github.com/bcomnes/bret.io/) - A personal blog written with DOMStack -- [Isomorphic Static/Client App](https://github.com/hifiwi-fi/breadcrum.net/tree/master/packages/web/client) - Pages build from client templates and hydrate on load. -- [Zero-Conf Markdown Docs](https://github.com/bcomnes/deploy-to-neocities/blob/70b264bcb37fca5b21e45d6cba9265f97f6bfa6f/package.json#L38) - A npm package with markdown docs, transformed into a website without any any configuration +> Wherever you see `.ts` being used, you can also use `.js`. +Type checking is supported in both file types. +See [Supported file types](docs/typescript/#supported-file-types) for all available extensions. -(Did you make a cool DOMStack website that is open source? PR it to the list!) +Layouts wrap page content in complete HTML documents. +The `root` layout is the default, while pages can select another layout through the `layout` variable. +Global styles, browser code, and variables apply across the site regardless of where their files live in `src`. -## Ejecting the defaults +Templates and other advanced features can generate additional output as needed. +The [documentation](docs/) covers each convention in detail. -The `--eject` (or `-e`) flag extracts DOMStack's default layout, global CSS, and client-side JavaScript into your source directory. This allows you to fully customize these files while maintaining the same functionality. +`domstack` ships with sane defaults, so you can point it at a standard [Markdown-documented repository](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github) and build a website with near-zero preparation. -When you run `domstack --eject`, it will: +## Installation and first build -1. Create a default root layout file at `layouts/root.layout.js` (or `.mjs` depending on your package.json type) -2. Create a default global CSS file at `globals/global.css` -3. Create a default client-side JavaScript file at `globals/global.client.js` -4. Add the necessary dependencies to your package.json: - - mine.css - - fragtml - - highlight.js +Use Node.js 22.18+ within the 22.x release line, or Node.js 24 or newer. +The v12 prerelease is published under the `beta` npm tag. -It is recomended to eject early in your project so that you can customize the root layout as you see fit, and de-couple yourself from potential unwanted changes in the default layout as new versions of DOMStack are released. +In a new project directory: -## Pages - -Pages are named directories inside `src` with **one of** the following page files: - -- `md` pages are [CommonMark](https://commonmark.org) markdown pages, with an optional [YAML](https://yaml.org) front-matter block. -- `html` pages are an inner [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML) fragment that get inserted into the page layout. -- `ts` pages are [TypeScript](https://developer.mozilla.org/en-US/docs/Glossary/TypeScript) files that export a default function that resolves into an inner HTML fragment inserted into the page layout. - -> [!NOTE] -> A **source-backed page** is discovered directly from a page file in `src`, rather than created by a `*.pages.ts` module. Source-backed pages exist before `global.data.ts` and [Generated Pages](#generated-pages) run. - -Variables are available in all pages. `md` and `html` pages support variable access via [handlebars][hb] template blocks. `ts` pages receive variables as part of the argument passed to them. See the [Variables](#variables) section for more info. - -Pages can define a special variable called [`layout`](#layouts) that determines which layout the page is rendered into. - -Because pages are just directories, they nest and structure naturally as a filesystem router. Directories in the `src` folder that lack one of these special page files can exist along side page directories and can be used to store co-located code or static assets without conflict. - -### `md` pages - -A `md` page looks like this on the filesystem: - -```bash -src/page-name/page.md -# or -src/page-name/README.md -# or -src/page-name/loose-md.md +```sh +npm init -y +npm install --save-dev @domstack/static@beta +mkdir src ``` -- `md` pages have three types: a `page.md`, a `README.md`, or a loose `whatever-name-you-want.md` file. -- `page.md` and `README.md` files transform to an `index.html` at the same path. When both exist in the same directory, `page.md` takes precedence over `README.md`. `whatever-name-you-want.md` loose markdown files transform into `whatever-name-you-want.html` files at the same path in the `dest` directory. -- `md` pages can have [YAML](https://yaml.org/) [frontmatter](https://docs.github.com/en/contributing/writing-for-github-docs/using-yaml-frontmatter), with variables that are accessible to the page layout and handlebars template blocks when building. -- You can include HTML in markdown files, so long as you adhere to the allowable markdown syntax around html tags. -- `md` pages support [handlebars][hb] template placeholders. -- You can disable `md` page [handlebars][hb] processing by setting the `handlebars` variable to `false`. -- `md` pages support many [github flavored markdown features](https://github.com/bcomnes/domstack/blob/master/lib/build-pages/page-builders/md/get-md.js#L25-L36). - -An example of a `md` page: +Create `src/page.md`: ```markdown ---- -title: A title for a markdown page -favoriteColor: 'Blue' ---- - -Just writing about web development. - -## Favorite colors - -My favorite color is {{ vars.favoriteColor }}. -``` - -### `html` pages - -A `html` page looks like this: - -```bash -src/page-name/page.html -``` - -- `html` pages are named `page.html` inside an associated page folder. -- `html` pages are the simplest page type in `domstack`. They let you build with raw html for when you don't want that page to have access to markdown features. Some pages are better off with just raw `html`, and the rules with building `html` in a real `html` file are much more flexible than inside of a `md` file. -- `html` page variables can only be set in a `page.vars.ts` file inside the page directory. -- `html` pages support [handlebars][hb] template placeholders. -- You can disable `html` page [handlebars][hb] processing by setting the `handlebars` variable to `false`. - -An example `html` page: - -```html -

Favorite frameworks

- -``` - -### `ts` pages - -A `ts` page looks like this: - -```bash -src/page-name/page.ts -``` - -> [!NOTE] -> Wherever you see `.ts` being used, you can also use `.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -- `ts` pages consist of a named directory with a `page.ts` file that exports a default function returning the contents of the inner page. -- A `ts` page needs to `export default` a function (async or sync) that accepts a variables argument and returns a string of the inner HTML of the page, or any other type that your layout can accept. -- You can specify the return type using `PageFunction` where `T` is the variables type, `U` is the return type (defaults to `any`), and `D` is the declared global-data shape. -- A `ts` page can export a [`vars` variable provider](#variable-providers) that takes highest variable precedence when rendering the page. `export vars` is similar to a `md` page's front matter. -- A `ts` page receives the standard `domstack` [Variables](#variables) set. -- There is no built-in Handlebars support in `ts` pages; however, you are free to use any template library that you can import. -- `ts` pages run in a Node.js context only. - -An example TypeScript page: - -```typescript -import type { PageFunction } from '@domstack/static/types.js' - -export const vars = { - favoriteCookie: 'Chocolate Chip with Sea Salt' -} - -const page: PageFunction = async ({ - vars -}) => { - return /* html */`
-

This is just some html.

-

My favorite cookie: ${vars.favoriteCookie}

-
` -} - -export default page -``` - -It is recommended to use some level of template processing over raw string templates so that HTML is well-formed and variable values are properly escaped. -DOMStack's default layout uses [`fragtml`][fragtml], a safe-by-default HTML tagged template library. -Here is a more realistic TypeScript example that uses `fragtml` and an explicit global-data subscription. - - -```typescript -import { html } from 'fragtml' -import type { HtmlResult } from 'fragtml/types.js' -import type { PageFunction } from '@domstack/static/types.js' - -type BlogVars = { - favoriteCake: string -} - -type BlogData = { - blogYears: number[] -} - -export const vars = { - favoriteCake: 'Chocolate Cloud Cake', - dataDeps: ['blogYears'], -} - -const blogIndex: PageFunction = async ({ - vars: { favoriteCake }, - data, -}) => { - return html`
-

I love ${favoriteCake}!!

- -
` -} - -export default blogIndex -``` - -### Page Styles - -You can create a `style.css` file in any page folder. -Page styles are loaded on just that one page. -You can import common use styles into a `style.css` page style using css [`@import`](https://developer.mozilla.org/en-US/docs/Web/CSS/@import) statements to re-use common css. -You can `@import` paths to other css files, or out of `npm` modules you have installed in your projects `node_modues` folder. -`css` page bundles are bundled using [`esbuild`][esbuild]. - -An example of a page `style.css` file: - -```css -/* /some-page/style.css */ -@import "some-npm-module/style.css"; -@import "../common-styles/button.css"; - -.some-page-class { - color: blue; - - & .button { - color: purple; - } -} -``` - -### Page client bundles - -You can create a `client.ts` file in any page folder. -Page bundles are client-side JavaScript bundles that are loaded on that one page only. -You can import common code and modules from relative paths, or `npm` modules out of `node_modules`. -Page client bundles are bundle-split with every other client-side entry point, so shared code is loaded efficiently. -Page bundles run in a browser context only; however, they can share carefully crafted code that also runs in a Node.js or layout context. -Page bundles are built using [`esbuild`][esbuild]. - -An example of a page `client.ts` file: - -```typescript -/* /some-page/client.ts */ -import { funnyLibrary } from 'funny-library' -import { someHelper } from '../helpers/foo.ts' - -await someHelper() -await funnyLibrary() -``` - -#### `.tsx` - -Client bundles support [`.tsx`](https://www.typescriptlang.org/docs/handbook/jsx.html) through [esbuild's JSX transform](https://esbuild.github.io/content-types/#jsx). - -> [!NOTE] -> Wherever you see `.tsx` being used for a client bundle, you can also use [`.jsx`](https://facebook.github.io/jsx/). Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -> [!IMPORTANT] -> `.tsx` and `.jsx` are supported only in client bundles. JSX syntax is unavailable in page files, layouts, templates, settings, and anything else that runs in the Node.js context. - -DOMStack does not include a JSX runtime by default. -Install the runtime you want and configure it with `esbuild.settings`. -[Preact][preact] is the recommended JSX runtime for DomStack because it is small, browser-focused, and works well with page-scoped client bundles. -See the [preact-isomorphic](./examples/preact-isomorphic/) and [react](./examples/react/) examples for complete projects. - -To use Preact in browser TSX bundles, add it to your project and opt into Preact's automatic JSX runtime: - -```console -npm install preact -``` - -```typescript -// src/esbuild.settings.ts -export default async function esbuildSettingsOverride (esbuildSettings) { - esbuildSettings.jsx = 'automatic' - esbuildSettings.jsxImportSource = 'preact' - - return esbuildSettings -} -``` - -If a dependency expects React, you can often swap React for `@preact/compat` with an npm package alias. -This installs `@preact/compat` into `node_modules/react`. -See [Simple TanStack Query in Preact](https://bret.io/blog/2026/simple-tanstack-query-in-preact/) for more details. - -```json -{ - "dependencies": { - "react": "npm:@preact/compat@^18.3.1" - } -} -``` - -React also works if your project needs React-specific APIs or ecosystem packages. -To use React in browser TSX bundles, add React to your project and opt into React's automatic JSX runtime: - -```console -npm install react react-dom -``` - -```typescript -// src/esbuild.settings.ts -export default async function esbuildSettingsOverride (esbuildSettings) { - esbuildSettings.jsx = 'automatic' - esbuildSettings.jsxImportSource = 'react' - - return esbuildSettings -} -``` - -### Page variable files - -Each page can also have an adjacent `page.vars.ts` file that default-exports a [variable provider](#variable-providers) containing page-specific variables. - -```typescript -// export an object -export default { - my: 'vars' -} - -// OR export a default function -export default () => { - return { my: 'vars' } -} - -// OR export a default async function -export default async () => { - return { my: 'vars' } -} -``` - -Page variable files have higher precedence than `global.vars.ts` variables, but lower precedence than frontmatter or `vars` exports from `ts` pages. See [Variables](#variables) for the full variable cascade. - -### Draft pages - -A complete draft page can use the same colocated files as a published page: - -```text -src/ -└── blog/ - └── unpublished-post/ - β”œβ”€β”€ page.draft.md # Draft page content - β”œβ”€β”€ page.vars.ts # Page-specific variables - β”œβ”€β”€ client.ts # Page-specific browser code - └── style.css # Page-specific styles -``` - -If you add a `.draft.{md,html,ts}` suffix to any page type, the page is considered a draft page. -Draft pages are not built by default. -If you pass the `--drafts` flag when building or watching, the draft pages will be built. -When draft pages are omitted, they are completely ignored. - -Draft pages can be detected in layouts using the `page.draft === true` or `pages[n].draft === true` variable. -It is a good idea to display something indicating the page is a draft in your templates so you don't get confused when working with the `--drafts` flag. - -> [!NOTE] -> Static assets colocated with draft pages are still copied when drafts are excluded because static assets are processed independently from pages. - -Draft pages let you work on pages before they are ready and easily omit them from a build when deploying pages that are ready. - -## Layouts - -Layouts are "outer page templates" that pages get rendered into. -You can define as many as you want, and they can live anywhere in the `src` directory. - -Layouts are named `${layout-name}.layout.ts` where `${layout-name}` becomes the name of the layout. -Layouts should have a unique name, and layouts with duplicate names result in a build error. - -> [!NOTE] -> Wherever you see `.layout.ts` being used, you can also use `.layout.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -Example layout file names: - -```bash -src/layouts/root.layout.ts # this layout is referenced as 'root' -src/other-layouts/article.layout.ts # this layout is referenced as 'article' -``` - -At a minimum, your site requires a `root` layout (a file named `root.layout.ts`), though `domstack` ships a default `root` layout so defining one in your `src` directory is optional, though recommended. -Owning your own root layout will make DOMStack updates easier, and give you more control over your site. - -All pages have a `layout` variable that defaults to `root`. If you set the `layout` variable to a different name, pages will build with a layout matching the name you set to that variable. - -The following markdown page would be rendered using the `article` layout. - -```md ---- -layout: 'article' -title: 'My Article Title' ---- - -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. -Filenames determine layout names, but nesting is an explicit module declaration, not a directory or import convention. - -### Layout module exports - -DOMStack recognizes these exports from a layout module: - -| Export | Required | Contract | -| --- | --- | --- | -| `default` | Yes | A synchronous or asynchronous [layout render function](#layout-render-function). | -| `vars` | No | An object, or a sync/async function returning an object, providing [layout defaults](#layout-variables). | -| `parentLayout` | No | A non-empty string naming the immediate outer layout; see [Declaring nested layouts](#declaring-nested-layouts). | - -### Declaring nested layouts - -Declare a parent with a named `parentLayout` export in the child layout module: - -```ts -// src/layouts/article.layout.ts -import type { LayoutFunction } from '@domstack/static/types.js' - -export const parentLayout = 'root' - -const articleLayout: LayoutFunction, string, string> = ({ children }) => { - return `
${children}
` -} - -export default articleLayout -``` - -`parentLayout` is a layout name, not a file path or imported function. -For example, `'root'` resolves the discovered `root.layout.ts` or `root.layout.js`, wherever it lives under `src`, or DOMStack's bundled root when no custom root exists. -Names are matched exactly, using the same filename-derived names as the page's `layout` variable. - -Omit `parentLayout` (or export `undefined`) when the layout has no parent; DOMStack does not automatically wrap a selected non-root layout in `root`. - -DOMStack renders the page, passes its result to `article`, then passes that result to `root`: `root(article(page()))`. -Each parent can declare another parent, forming a chain that ends at a layout without `parentLayout`. -Missing parents and cycles, including a layout naming itself, fail the build. - -Every render step is awaited, and each parent receives its immediate child's return value as `children` without intermediate string conversion. -The outermost result is converted to a string for HTML output. -All layouts receive the same final resolved page vars, metadata, and asset lists. -Layout defaults merge outermost-to-innermost before page overrides, and ancestor CSS/client entries are included automatically between global and page assets. -Watch mode tracks the resolved chain and each layout's static imports for source-backed and generated pages, updating those relationships after successful rebuilds. - -Each layout can also declare its own global-data subscriptions through `vars.dataDeps`. -DOMStack passes only those declared keys to that layout's `data` argument; a child does not receive its parent's data or need to repeat its declarations. -For rebuilds, the page depends on the union of its own subscriptions and every layout's subscriptions in the declared chain. -See [Data subscriptions in nested layouts](#data-subscriptions-in-nested-layouts) for typed declarations and examples. - -Manual function composition remains supported, but `parentLayout` is recommended so DOMStack manages the ancestor chain and its rebuild dependencies. -Do not both declare a parent and call its render function manually, or the parent will render twice. -See [Compose nested layouts](#compose-nested-layouts) for a complete example, asset guidance, and the manual-composition alternative. - -### Layout variables - -Layouts may also export an optional [`vars` variable provider](#variable-providers) containing defaults for pages that use the layout: - -```ts -export const vars = { - showSidebar: true, - pageType: 'article', -} -``` - -Layout vars are merged into the resolved variable cascade for pages using that layout. -Precedence is: - -```txt -page/frontmatter vars > page.vars.* > inner layout vars > outer layout vars > global.vars > domstack defaults -``` - -This makes layout vars useful for section-wide defaults while still letting individual pages override them. - -### Layout render function - -A layout's default export is an async or sync function that wraps its `children` in an outer template. -With nested layouts, `children` is the result of the immediately inner layout, or the page itself for the innermost layout. - -It is always passed a single object argument with the following entries. -See [Page data and introspection](#page-data-and-introspection) for details about `page`, and [Global data](#global-data) for `data`: - -- `vars`: The resolved page variable cascade, including domstack defaults, global vars, layout vars, page vars, and page builder vars/frontmatter. Pages can customize layouts by overriding global or layout defaults. -- `data`: Only the top-level global-data keys declared by this layout through `vars.dataDeps`. -- `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`: The immediate child's render result: the page's content for the innermost layout, or the next inner layout's return value for a parent. -Markdown and HTML pages return strings; TypeScript pages and nested layouts may return other values. -- `page`: An object with metadata and other facts about the current page being rendered into the template. - -### The default `root.layout.ts` - -The default `root.layout.ts` is featured below, and is implemented with [`fragtml`][fragtml], though it could just be done with a template literal or any other template system that runs in Node.js. -See the [`fragtml` docs][fragtml-docs] for escaping, raw HTML, rendering, and fragment usage. - -`root.layout.ts` can live anywhere in the `src` directory. - -```typescript -import { html, raw, render } from 'fragtml' -import type { HtmlResult } from 'fragtml/types.js' -import type { LayoutFunction } from '@domstack/static/types.js' - -type RootLayoutVars = { - title: string, - siteName: string, - defaultStyle: boolean, - basePath?: string -} - -export const vars = { - defaultStyle: true, -} - -const defaultRootLayout: LayoutFunction = ({ - vars: { - title, - siteName = 'Domstack', - basePath, - /* defaultStyle = true Set this to false in global or page vars to disable the default style in the default layout */ - }, - scripts, - styles, - children, - data, - page, -}) => { - return render(html` - - - - - ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} - - - ${scripts - ? scripts.map(script => html``) - : null} - ${styles - ? styles.map(style => html``) - : null} - - -
${typeof children === 'string' ? raw(children) : children}
- - - `) -} - -export default defaultRootLayout -``` - -If your `src` folder doesn't have a `root.layout.ts` file somewhere in it, `domstack` will use the default [`default.root.layout.js`](./lib/defaults/default.root.layout.js) file it ships. The default `root` layout includes a special boolean variable called `defaultStyle` that lets you disable a default page style (provided by [mine.css](http://github.com/bcomnes/mine.css)) that it ships with. - -### Layout styles - -You can create a `${layout-name}.layout.css` next to any layout file. -While the layout file can live anywhere in `src`, the layout style must live next to the associated layout file. - -```css -/* /layouts/article.layout.css */ -.layout-specific-class { - color: blue; - - & .button { - color: purple; - } -} - -/* This layout style is included in every page rendered with the 'article' layout */ -``` -Layout styles are loaded on all pages that use that layout directly or through a `parentLayout` chain. -Layout styles are bundled with [`esbuild`][esbuild] and can bundle relative and `npm` css using css `@import` statements. -DOMStack loads stylesheets in this order: optional defaults, global, outermost-to-innermost layouts, then page. -Under the normal [CSS cascade](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade), later styles take precedence when origin, importance, cascade layer, and specificity are otherwise equal. -This lets page styles override layout styles, and inner layout styles override outer layout styles. - - -### Layout client bundles - -You can create a `${layout-name}.layout.client.ts` next to any layout file. -While the layout file can live anywhere in `src`, the layout client bundles must live next to the associated layout file. +# Hello, web -> [!NOTE] -> Use `${layout-name}.layout.client.tsx` when a layout client bundle contains JSX. You can also use `.jsx`. See [Supported file types](#supported-file-types) for all available extensions and [`.tsx` client bundles](#tsx) for JSX configuration. - -```typescript -/* /layouts/article.layout.client.ts */ - -console.log('I run on every page rendered with the \'article\' layout') - -/* This layout client is included in every page rendered with the 'article' layout */ -``` - -Layout client bundles are loaded on all pages that use that layout directly or through a `parentLayout` chain. -Layout client bundles are built with [`esbuild`][esbuild] and can bundle relative and `npm` modules using ESM `import` statements. - -### Layout types - -Layouts can be typed using `LayoutFunction` where: - -- `T` is the variables type -- `U` is the immediate child's render result, from a page or nested layout (defaults to `any`) -- `V` is the layout's return type (defaults to `string` for HTML output) -- `D` is the declared global-data shape (defaults to `Record`) - -```typescript -import type { LayoutFunction } from '@domstack/static/types.js' -import type { HtmlResult } from 'fragtml/types.js' -import { html, raw, render } from 'fragtml' - -type ArticleLayoutVars = { - title: string - showSidebar: boolean -} - -const articleLayout: LayoutFunction = ({ - vars, - children, -}) => { - return render(html` -
-

${vars.title}

- ${typeof children === 'string' ? raw(children) : children} - ${vars.showSidebar ? html`` : null} -
- `) -} - -export default articleLayout -``` - -## Variables - -### Variable providers - -DOMStack accepts variable providers anywhere variables can be supplied. A variable provider is an object or a sync/async function that returns an object. - -Object provider: - -```typescript -// src/global.vars.ts -export default { - siteName: 'My site' -} -``` - -Synchronous function provider: - -```typescript -// src/global.vars.ts -export default function vars () { - return { - siteName: 'My site' - } -} -``` - -Asynchronous function provider: - -```typescript -// src/global.vars.ts -export default async function vars () { - return { - siteName: 'My site' - } -} -``` - -Pages and layouts receive an object with the following parameters: - -- `vars`: An object with the variables of `global.vars.ts`, `page.vars.ts`, layout vars, and any frontmatter or `vars` exports from the page merged together. -- `data`: Only the top-level values selected from [`global.data.ts`](#global-data) by this renderer's own `dataDeps` declarations. -- `page`: The current page's [`PageInfo` metadata](#page-metadata). - -Template files receive a similar set of variables: - -- `vars`: An object with the variables from `global.vars.ts`. -- `data`: Only the top-level values selected from [`global.data.ts`](#global-data) by the template's `dataDeps` named export. -- `template`: Information about the current template file. - -## Static assets - -All static assets in the `src` directory are copied 1:1 to the destination directory using [cpx2](https://github.com/bcomnes/cpx2). Files ending in `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.css`, `.html`, or `.md` are reserved for DOMStack processing and are not copied as static assets. - -### `--copy` directories - -You can specify directories to copy into your `dest` directory using the `--copy` flag. Everything in those directories will be copied as-is into the destination, including js, css, html and markdown, preserving the internal directory structure. - -> [!NOTE] -> `--copy` intentionally accepts directories, not individual files. Place a file in a directory whose structure encodes its desired destination path. To copy multiple directories, repeat the flag: `domstack --copy oldsite --copy archived-docs`. - -> [!WARNING] -> DOMStack does not detect conflicts between copied directories and other build output. If multiple inputs produce the same destination path, the result is undefined. - -Copy folders must live **outside** of the `dest` directory. Copy directories can be in the src directory allowing for nested builds. In this case they are added to the ignore glob and ignored by the rest of `domstack`. - -> [!NOTE] -> When using the programmatic `DomStack` constructor, `copy` entries may be relative or absolute paths. Relative paths are resolved from the current working directory, matching the CLI `--copy` behavior, before being stored in `domstack.opts.copy` and passed to the copy build step. -> -> ```typescript -> const site = new DomStack('src', 'public', { -> copy: ['./legacy-site', '/srv/shared-docs'], -> }) -> ``` - -The intention of this feature is to include legacy or archived site content without asking DOMStack to process or modify it. In general, static content should live in your primary `src` directory, but keeping older content in a separate, unprocessed directory can make it easier to merge into the final build. - -For example: - -``` -src/... -oldsite/ -β”œβ”€β”€ client.js -β”œβ”€β”€ hello.html -└── styles/ - └── globals.css -``` - -After build: - -``` -src/... -oldsite/... -public/ -β”œβ”€β”€ client.js -β”œβ”€β”€ hello.html -└── styles/ - └── globals.css -``` - -## Global Assets - -There are a few important and optional global files that can live anywhere in the `src` directory. Global browser assets preserve their source-relative directory when built into `dest`. For example, `src/assets/global.css` produces an output such as `dest/assets/global-[hash].css`. Build-time files such as `global.vars.ts`, `esbuild.settings.ts`, and `markdown-it.settings.ts` are consumed by DOMStack and are not emitted. - -Only one file may match each global filename pattern. When DOMStack discovers a duplicate, it keeps the first file it found, skips the duplicate, and reports a warning. Define each global file once rather than relying on discovery order. - -> [!NOTE] -> Wherever this section uses `.ts`, you can also use `.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -### `global.vars.ts` - -The `global.vars.ts` file should default-export a [variable provider](#variable-providers). -The variables in this file are available to all pages, unless the page sets a variable with the same key, taking a higher precedence. - -```typescript -export default { - siteName: 'The name of my website', - authorName: 'Mr. Wallace' -} +This page is built with DOMStack. ``` -#### `browser` variable +Build the site: -`global.vars.ts` can uniquely export a [`browser` variable provider](#variable-providers). These variables are made available in all client bundles. - -```typescript -export const browser = { - 'process.env.TRANSPORT': 'http', - 'process.env.HOST': 'localhost' -} +```sh +npx domstack ``` -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. +The generated page is `public/index.html`, rendered with the bundled default layout and stylesheet. +Run `npx domstack --watch` to rebuild on changes, then open the local development server's URL. +Use `npx domstack --serve` to preview a production build. -### `global.client.ts` +## Links -This is a script bundle that is included on every page. It provides an easy way to inject analytics, or other small scripts that every page should have. Try to minimize what you put in here. +- [Documentation](docs/) +- [About](docs/about/) +- [Examples](docs/example-projects/) +- [Changelog](CHANGELOG.md) +- [Contributing](CONTRIBUTING.md) +- [Dependency graph](dependencygraph.svg) +- [fragtml documentation](https://github.com/bcomnes/fragtml#readme) +- [Historical v11 migration guide](docs/migrations/v11-migration.md) -> [!NOTE] -> Use `global.client.tsx` when the global client bundle contains JSX. You can also use `global.client.jsx`. See [Supported file types](#supported-file-types) for all available extensions and [`.tsx` client bundles](#tsx) for JSX configuration. - -```typescript -console.log('I run on every page in the site!') -``` - -### `global.css` - -This is a global stylesheet that every page will use. -Any styles that need to be on every single page should live here. -Importing css from `npm` modules work well here. - -#### Optional cascade layers - -The bundled default stylesheet imports mine.css's main rules in its low-priority `mine` layer and its optional layout and syntax styles in `domstack.default`. -Normal unlayered styles in your project override those defaults, so custom stylesheets do not have to use cascade layers. - -For projects that prefer explicit layers, each stylesheet can declare only its own optional scope: - -```css -/* global.css */ -@layer domstack.global { - /* Site-wide rules */ -} -``` - -```css -/* article.layout.css */ -@layer domstack.layout { - /* Layout rules */ -} -``` - -```css -/* style.css */ -@layer domstack.page { - /* Page rules */ -} -``` - -DOMStack loads default, global, layout, and page stylesheets in that order, which gives these layers the same low-to-high precedence when they are used. -A global stylesheet does not need to enumerate the layout or page layers. -This is a recommended organization pattern, not a requirement. - -### `esbuild.settings.ts` - -This is an optional file you can create anywhere. -It should export a default sync or async function that accepts a single argument (the esbuild settings object generated by domstack) and returns a modified build object. -Use this to customize the esbuild settings directly. - -Important esbuild settings you may want to set here are: - -- [target](https://esbuild.github.io/api/#target) - Set the `target` to make `esbuild` run a few small transforms on your CSS and JS code. -- [jsx](https://esbuild.github.io/api/#jsx) - Configure how esbuild transforms JSX and TSX. -- [jsxImportSource](https://esbuild.github.io/api/#jsx-import-source) - Set this when using an automatic JSX runtime such as React or Preact. -- [define](https://esbuild.github.io/api/#define) - Define compile-time constants for JS bundles. Setting `define` here conflicts with the [`browser` export](#browser-variable) in `global.vars.ts` and throws an error if both are set. - -> [!WARNING] -> An invalid esbuild override can break DOMStack's browser build. Preserve DOMStack's required build options unless you intentionally replace their behavior. - -Here is an example of using this file to polyfill Node.js built-ins in the browser bundle: - -```typescript -import { polyfillNode } from 'esbuild-plugin-polyfill-node' -// BuildOptions re-exported from esbuild -import type { BuildOptions } from '@domstack/static/types.js' - -const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise => { - esbuildSettings.plugins = [polyfillNode()] - return esbuildSettings -} - -export default esbuildSettingsOverride -``` - -#### Default build behavior - -DOMStack passes its complete default `BuildOptions` into this function. The default browser build: - -- Bundles ESM with code splitting enabled -- Emits source maps and an esbuild metafile -- Preserves source-relative directories through `outbase: src` -- Uses `[dir]/[name]-[hash]` for production entry files and stable `[dir]/[name]` filenames in watch mode -- Writes shared chunks to `chunks/[ext]/[name]-[hash]` -- Does not configure a JSX runtime - -Default asset loaders are: - -| Loader | Extensions | Behavior | -|---|---|---| -| `dataurl` | `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.avif` | Embeds the imported asset in its bundle | -| `file` | `.ico`, `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf` | Emits a separate file and returns its URL | - -> [!NOTE] -> Images imported by a client bundle are embedded regardless of their size by default. Use the `file` loader when large images should remain separate files. - -The function's return value becomes the effective esbuild configuration. Preserve DOMStack's build wiring, including `entryPoints`, `outdir`, and `outbase`, unless you intentionally replace that behavior. Spread nested options such as `loader` when adding entries because replacing the object discards its existing defaults. DOMStack preserves its reserved `define` values after the override runs. - -These options also form the basis of the [service-worker](#service-workers) build. DOMStack replaces the service-worker entry point and filename and disables code splitting, while options such as plugins, loaders, `target`, and JSX configuration carry over. - -You can return a shallow copy that modifies the defaults when you only need a small change. For example, this keeps DOMStack's default asset loaders and adds a custom loader for `.wasm` files: - -```typescript -import type { BuildOptions } from '@domstack/static/types.js' - -const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise => { - return { - ...esbuildSettings, - loader: { - ...esbuildSettings.loader, - '.wasm': 'file', - }, - } -} - -export default esbuildSettingsOverride -``` - -If you want full control, reset DOMStack's convenience defaults back to esbuild's defaults while preserving the required DOMStack build wiring (`entryPoints`, `outdir`, `outbase`, etc.). -From there, define only the settings you want: - -```typescript -import type { BuildOptions } from '@domstack/static/types.js' - -const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise => { - return { - ...esbuildSettings, - jsx: undefined, - jsxImportSource: undefined, - loader: { - '.png': 'file', - '.svg': 'text', - }, - } -} - -export default esbuildSettingsOverride -``` - - -### `markdown-it.settings.ts` - -This is an optional file you can create anywhere. -It should export a default sync or async function that accepts a single argument (the markdown-it instance configured by domstack) and returns a modified markdown-it instance. -Use this to add custom markdown-it plugins or modify the parser configuration. -Here are some examples: - -```typescript -import markdownItContainer from 'markdown-it-container' -import markdownItPlantuml from 'markdown-it-plantuml' -import type { MarkdownIt } from 'markdown-it' - -const markdownItSettingsOverride = async (md: MarkdownIt) => { - // Add custom plugins - md.use(markdownItContainer, 'spoiler', { - validate: (params: string) => { - return params.trim().match(/^spoiler\s+(.*)$/) !== null - }, - render: (tokens: any[], idx: number) => { - const m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/) - if (tokens[idx].nesting === 1) { - return '
' + md.utils.escapeHtml(m[1]) + '\n' - } else { - return '
\n' - } - } - }) - - md.use(markdownItPlantuml) - - return md -} - -export default markdownItSettingsOverride -``` - -```typescript -import markdownIt, { MarkdownIt } from 'markdown-it' -import myCustomPlugin from './my-custom-plugin' - -const markdownItSettingsOverride = async (md: MarkdownIt) => { - // Create a new instance with different settings - const newMd = markdownIt({ - html: false, // Disable HTML tags in source - breaks: true, // Convert \n to
- linkify: false, // Disable auto-linking - }) - - // Add only the plugins you want - newMd.use(myCustomPlugin) - - return newMd -} - -export default markdownItSettingsOverride -``` - -By default, DOMStack ships with the following markdown-it plugins enabled: - -- [markdown-it](https://github.com/markdown-it/markdown-it) -- [markdown-it-footnote](https://github.com/markdown-it/markdown-it-footnote) -- [markdown-it-highlightjs](https://github.com/valeriangalliat/markdown-it-highlightjs) -- [markdown-it-emoji](https://github.com/markdown-it/markdown-it-emoji) -- [markdown-it-sub](https://github.com/markdown-it/markdown-it-sub) -- [markdown-it-sup](https://github.com/markdown-it/markdown-it-sup) -- [markdown-it-deflist](https://github.com/markdown-it/markdown-it-deflist) -- [markdown-it-ins](https://github.com/markdown-it/markdown-it-ins) -- [markdown-it-mark](https://github.com/markdown-it/markdown-it-mark) -- [markdown-it-abbr](https://github.com/markdown-it/markdown-it-abbr) -- [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists) -- [markdown-it-github-alerts](https://www.npmjs.com/package/markdown-it-github-alerts) -- [markdown-it-anchor](https://github.com/valeriangalliat/markdown-it-anchor) -- [markdown-it-attrs](https://github.com/arve0/markdown-it-attrs) -- [markdown-it-table-of-contents](https://github.com/cmaas/markdown-it-table-of-contents) - -## Global data - -The `global.data.ts` file is an optional file that can live anywhere in your `src` tree. The first one found wins and duplicates warn. It runs **once per build**, after [source-backed pages](#pages) are initialized and before generated-page factories run. - -> [!NOTE] -> `global.data.js` works too. See [Supported file types](#supported-file-types) for all available extensions. - -For data that aggregates across multiple pages β€” like blog indexes, sitemaps, recent-post lists, or RSS feed content β€” use `global.data.ts`. -It is the only public build hook that receives the source-backed `PageData[]` collection. -It returns an object of named, top-level values that downstream consumers can explicitly subscribe to. - -```typescript -// src/global.data.ts -import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' -import { html, render } from 'fragtml' - -export type GlobalData = { - blogPostsHtml: string -} - -export type ArchiveData = Pick - -const buildGlobalData: AsyncGlobalDataFunction = async ({ pages }) => { - const blogPosts = pages - .filter(p => p.vars?.layout === 'blog' && p.vars?.publishDate) - .sort((a, b) => new Date(b.vars.publishDate) - new Date(a.vars.publishDate)) - .slice(0, 5) - - const blogPostsHtml = render(html` - - `) - - return { blogPostsHtml } -} - -export default buildGlobalData -``` - -The returned object is not merged into `vars`. -A page or layout declares the keys it needs through `dataDeps`, then reads those keys from the separate `data` argument: - -```md - ---- -dataDeps: - - blogPostsHtml ---- - -## [Blog](./blog/) - -{{{ data.blogPostsHtml }}} -``` - -HTML pages declare the same field in an adjacent `page.vars.ts` file: - -```typescript -// src/archive/page.vars.ts -export default { - dataDeps: ['blogPostsHtml'], -} -``` - -TypeScript pages and layouts can put the declaration in their `vars` export: - -```typescript -import type { DataDeps, PageFunction } from '@domstack/static/types.js' -import type { ArchiveData } from './global.data.js' - -export const vars = { - dataDeps: ['blogPostsHtml'] satisfies DataDeps, -} - -const archivePage: PageFunction, string, ArchiveData> = ({ data }) => - `

Archive

${data.blogPostsHtml}` - -export default archivePage -``` - -Keep these focused consumer contracts beside the complete global-data type so pages and layouts can import a meaningful name instead of reconstructing a `Pick` selection. -`DataDeps` checks declaration names against that contract and accepts readonly arrays, including `as const` tuples. -The declaration is still required at runtime; a TypeScript type alone does not subscribe a renderer. - -For `*.template.ts` and `*.pages.ts` files, export `dataDeps` as a named module export because those files do not have consumer vars: - -```typescript -export const dataDeps = ['blogPostsHtml'] - -export default function archiveTemplate ({ data }) { - return data.blogPostsHtml -} -``` - -`dataDeps` is build metadata and is removed from the resolved `vars` object. -The page receives the union of its own frontmatter, page-vars, and builder declarations. -Each layout receives only its own `vars.dataDeps`, not its parent's or the page's data. -For output invalidation, DOMStack unions the page's declarations with those of every layout in its resolved `parentLayout` chain. -Children do not repeat ancestor declarations, and a parent's subscriptions cannot be cleared by a child's empty declaration. -When one layout calls another layout function directly, the composing layout must declare every global-data key the composed rendering needs. - -**Key properties of `global.data.ts`:** - -- **Centralizes page collation and processing.** Collect, filter, group, sort, and render source pages once, then expose purpose-built values instead of the page graph itself. -- Receives fully resolved source-backed `PageData[]` β€” every page has `.vars` (merged global + page + builder vars), `.pageInfo` (path, type, etc.), `.styles`, `.scripts`, and more. Generated pages do not exist yet. -- Gives pages, layouts, templates, and page factories only their declared top-level keys through `data`. -- Keeps global data separate from ordinary `vars`, so derived values cannot silently collide with page or layout configuration. -- Runs inside the worker process (same as all other dynamic imports) to avoid ESM caching issues. -- Skipped entirely if no `global.data.*` file exists β€” zero overhead. -- In watch mode, DOMStack fingerprints each top-level returned value and rebuilds only consumers subscribed to changed keys. -- Editing `global.data.*` or one of its statically imported helpers recomputes data; a shared helper also rebuilds its direct page, layout, template, and factory consumers. -- Values composed of JSON-safe primitives, arrays, and plain objects get stable fingerprints; opaque values such as functions, class instances, maps, sets, or cycles conservatively invalidate their subscribers on every page build. -- A declaration naming a missing key fails the build, and access to an existing but undeclared key throws a focused error. - -Subscription failures use `DomStackDataError` with code `DOM_STACK_ERROR_DATA`. -Its `dataDependency` metadata identifies the consumer, optional key, and reason: `INVALID_DECLARATION`, `MISSING_KEY`, `UNDECLARED_KEY`, or `NOT_READY`. -The subtype and metadata survive worker transport inside the build's aggregate errors. -After a failed watch build, the next page build retries the complete page phase before returning to incremental routing. - -### Global data types - -`GlobalDataFunction` accepts synchronous or asynchronous implementations; `AsyncGlobalDataFunction` specifically requires a promise. -In both types, `T` describes the named data returned by `global.data.ts`: - -```typescript -// src/global.data.ts -import type { GlobalDataFunction } from '@domstack/static/types.js' - -type DerivedData = { - pageCount: number - pageUrls: string[] -} - -const globalData: GlobalDataFunction = ({ pages }) => { - return { - pageCount: pages.length, - pageUrls: pages.map(page => page.pageInfo.url), - } -} - -export default globalData -``` - -Use `AsyncGlobalDataFunction` instead when the implementation needs to await rendering, network requests, or other asynchronous work. -For typed source input, use `GlobalDataFunction` or its async counterpart. -Helpers can accept `GlobalDataFunctionParams['pages']` without recovering types from the full global-data result. - -### Global data caveats - -> [!CAUTION] -> `page.vars` is a cached, shallow-frozen object containing the resolved variable cascade. Treat it as read-only. Create a new object when you need to add or replace values. - -```typescript -// src/global.data.ts -// Do not mutate the resolved page variables. -page.vars.slug = createSlug(page.vars.title) - -// Create a new object instead. -const derivedVars = { - ...page.vars, - slug: createSlug(page.vars.title), -} -``` - -> [!WARNING] -> Accessing `page.vars` throws when that page failed to initialize, such as when a page-variable module contains a syntax error, missing dependency, or runtime error. Fix the underlying page initialization failure rather than treating missing variables as valid data. - -> [!NOTE] -> Raw Markdown is not exposed as `page.vars.content`. Markdown variables include frontmatter-derived values such as `title`. Call `readMarkdownContent()` when you need the source body. - -```typescript -// src/global.data.ts -const markdownSources = await Promise.all( - pages - .filter(page => page.pageInfo.type === 'md') - .map(async page => ({ - path: page.pageInfo.path, - markdown: await page.readMarkdownContent(), - })) -) -``` - -> [!TIP] -> `global.data.ts` can call `renderInnerPage()` because it runs after source-backed page initialization has been attempted. -> The same initialization caveat applies. - -```typescript -// src/global.data.ts -const renderedPages = await Promise.all( - pages.map(async page => ({ - path: page.pageInfo.path, - html: await page.renderInnerPage(), - })) -) -``` - -Global-data computation cannot read the `data` values it is still producing. -If a source page declares data dependencies, attempting to render it from `global.data.ts` fails rather than creating a hidden cycle. - -See [Rendering page content](#rendering-page-content) for rendering semantics and performance guidance. - -## Generated Pages - -Generated-pages files create one or more DOMStack pages from a central `*.pages.*` module. -Unlike templates, generated pages use the normal page and layout pipeline: each definition supplies page variables and children, which DOMStack renders through the selected layout. -Use generated pages for data-driven output such as blog index pages or HTML redirects derived from frontmatter. - -Generated-pages files use the `*.pages.ts` suffix. - -> [!NOTE] -> Wherever you see `*.pages.ts` being used, you can also use `*.pages.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -### Generated-pages exports - -Like [variable providers](#variable-providers), generated-page factories may be synchronous or asynchronous. Unlike variable providers, they return page definitions and may produce multiple results. - -A generated-pages module can default-export: - -| Export | Use when | -|---|---| -| One `GeneratedPageDefinition` object | The module always creates one page | -| An array of definitions | The module always creates a fixed set of pages and needs no build context | -| A normal or `async` function | Definitions depend on global vars, declared global data, or pages-file metadata | -| An async iterable, usually returned by `async function*` | Pages are discovered incrementally or the total is not known in advance | - -Static objects and arrays do not receive factory parameters. - -#### One page definition - -Export one object when the module always creates a single page: - -```ts -// src/about.pages.ts -export default { - outputName: 'about/index.html', - vars: { layout: 'root', title: 'About' }, - children: '

About this site

', -} -``` - -#### Page definition array - -Export an array when the module always creates a fixed set of pages: - -```ts -// src/legal.pages.ts -export default [ - { - outputName: 'terms/index.html', - vars: { layout: 'legal', title: 'Terms' }, - children: 'Terms of service', - }, - { - outputName: 'privacy/index.html', - vars: { layout: 'legal', title: 'Privacy' }, - children: 'Privacy policy', - }, -] -``` - -#### Synchronous factory - -Export a function when definitions depend on declared global data or shared variables: - -```ts -// src/tag-indexes.pages.ts -export const dataDeps = ['tagIndex'] - -export default function tagIndexes ({ data }) { - return Object.entries(data.tagIndex).map(([tag, posts]) => ({ - outputName: `tags/${tag}/index.html`, - vars: { layout: 'tag-index', title: `Posts tagged ${tag}`, posts }, - })) -} -``` - -For a complete two-stage factory example, see [Generate yearly blog index pages](#generate-yearly-blog-index-pages). - -#### Asynchronous factory - -Export an async function when creating definitions requires asynchronous work: - -```ts -// src/team.pages.ts -import { readFile } from 'node:fs/promises' - -export default async function teamPages () { - const members = JSON.parse( - await readFile(new URL('./data/team.json', import.meta.url), 'utf8') - ) - - return members.map(member => ({ - outputName: `team/${member.slug}/index.html`, - vars: { layout: 'profile', title: member.name, member }, - })) -} -``` - -#### Async iterable - -Export an async generator when pages should be yielded incrementally: - -```ts -// src/archive.pages.ts -export const dataDeps = ['blogYears'] - -export default async function * archivePages ({ data }) { - for (const year of data.blogYears) { - yield { - outputName: `blog/${year}/index.html`, - vars: { layout: 'archive', year }, - } - } -} -``` - -### Generated-pages factory parameters - -Functions receive one object with: - -| Parameter | Contents | -|---|---| -| `vars` | Default and global vars. | -| `data` | Only the top-level values named by the module's `dataDeps` export. | -| `pagesFile` | Information about the current file. `name` is the filename without its `.pages.*` suffix, `path` is its source-relative directory, and `pagesFile` contains the underlying file information. | - -Factories do not receive raw source or generated `PageData` collections. -Put page-collection logic in `global.data.ts`, return a focused serializable value, and subscribe to its key from the factory. -This keeps factories downstream of source discovery without exposing generation order or creating page-generation cycles. - -### Generated page definitions - -| Field | Behavior | -|---|---| -| `outputName` | Output path relative to the pages file's directory. It must name a file, must not be absolute or contain `..` segments, and cannot end in a path separator. Defaults to `/index.html`. | -| `vars` | Page-level vars merged with the normal default, global, layout, and builder vars. | -| `children` | Optional static child content or inline `PageFunction` rendered before the layout. | -| `draft` | When `true`, the page is omitted unless the CLI uses `--drafts` or a programmatic build uses `buildDrafts: true`. | - -Generated pages use [global assets](#global-assets) and [layout assets](#layout-styles). They do not have page-local `style.css`, `client.js`, or worker entries because they do not have their own source-page directory. - -### Generated-pages types - -Use `GeneratedPageDefinition` to type an individual definition. -`T` is the generated page's variables type, `U` is its children type, which defaults to `string`, and `D` is the declared data shape for inline page functions: - -```ts -// src/terms.pages.ts -import type { GeneratedPageDefinition } from '@domstack/static/types.js' - -type LegalPageVars = { - layout: string - title: string -} - -const terms: GeneratedPageDefinition = { - outputName: 'terms/index.html', - vars: { layout: 'legal', title: 'Terms' }, - children: 'Terms of service', -} - -export default terms -``` - -Use `PagesFunction` for normal functions, async functions, and async generators: - -- `T` is the variables type added to each generated page. -- `U` is the generated children type (defaults to `string`). -- `V` is the default and global vars type received by the factory. -- `D` is the global-data shape declared by the factory. - -```ts -// src/archive.pages.ts -import type { PagesFunction } from '@domstack/static/types.js' - -type ArchiveVars = { layout: string, year: number } -type ArchiveData = { blogYears: number[] } - -export const dataDeps = ['blogYears'] - -const archivePages: PagesFunction, ArchiveData> = async function * ({ data }) { - for (const year of data.blogYears) { - yield { - outputName: `blog/${year}/index.html`, - vars: { layout: 'archive', year }, - } - } -} - -export default archivePages -``` - -For metadata-driven redirects, see the cookbook recipe [Generate redirect pages from page metadata](#generate-redirect-pages-from-page-metadata). - -## Templates - -Template files let you write any kind of file type to the `dest` folder while customizing the contents with global vars and explicitly subscribed global data. -Template files can be located anywhere in the `src` directory. -For a complete feed-generation recipe, see [Generate RSS and JSON feeds](#generate-rss-and-json-feeds). - -Template files look like: - -```bash -name-of-template.txt.template.ts -${name-portion}.template.ts -``` - -Template files are `.ts` files that default-export one of the following sync/async functions: - -> [!NOTE] -> Wherever you see `.template.ts` being used, you can also use `.template.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -### Simple string template - -A function that returns a string. The `name-of-template.txt` portion of the template file name becomes the file name of the output file. - -```typescript -// name-of-template.txt.template.ts -import type { TemplateFunction } from '@domstack/static/types.js' - -interface TemplateVars { - foo: string; - testVar: string; -} - -const simpleTemplate: TemplateFunction = async ({ - vars: { - foo, - testVar - } -}) => { - return `Hello world - -This is just a file with access to global vars: ${foo}` -} - -export default simpleTemplate -``` - -### Object template - -A function that returns a single object with a `content` and `outputName` entries. The `outputName` overrides the name portion of the template file name. - -```typescript -import type { TemplateFunction } from '@domstack/static/types.js' - -interface TemplateVars { - foo: string; -} -export default async ({ - vars: { foo } -}) => ({ - content: `Hello world - -This is just a file with access to global vars: ${foo}`, - outputName: './single-object-override.txt' -}) -``` - -### Object array template - -A function that returns an array of objects with a `content` and `outputName` entries. This template file generates more than one file from a single template file. - -```typescript -import type { TemplateFunction } from '@domstack/static/types.js' - -interface TemplateVars { - foo: string; - testVar: string; -} - -const objectArrayTemplate: TemplateFunction = async ({ - vars: { - foo, - testVar - } -}) => { - return [ - { - content: `Hello world - -This is just a file with access to global vars: ${foo}`, - outputName: 'object-array-1.txt' - }, - { - content: `Hello world again - -This is just a file with access to global vars: ${testVar}`, - outputName: 'object-array-2.txt' - } - ] -} - -export default objectArrayTemplate -``` - -### AsyncIterator template - -An [AsyncIterator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator) that `yields` objects with `content` and `outputName` entries. - -```typescript -import type { TemplateAsyncIterator } from '@domstack/static/types.js' - -interface TemplateVars { - foo: string; - testVar: string; -} - -const templateIterator: TemplateAsyncIterator = async function * ({ - vars: { - foo, - testVar - } -}) { - // First item - yield { - content: `Hello world - -This is just a file with access to global vars: ${foo}`, - outputName: 'yielded-1.txt' - } - - // Second item - yield { - content: `Hello world again - -This is just a file with access to global vars: ${testVar}`, - outputName: 'yielded-2.txt' - } -} - -export default templateIterator -``` - -Templates receive only global vars, their declared global `data`, and metadata for the current template. -Use `global.data.ts` to turn source-page collections into values a template can subscribe to. - -### Choosing a template return type - -Use the simplest return type that fits your needs: - -| Return type | Multiple outputs | Custom output path | Buffers the output set | Use when | -|---|---|---|---|---| -| String | No | No (derived from template filename) | β€” | Single file, output path derived from template filename | -| Object | No | Yes | β€” | Single file with a custom output path | -| Array | Yes | Yes | Yes | Fixed set of output files known at build time | -| AsyncIterator | Yes | Yes | No | Dynamic or unknown number of outputs, or when outputs should be yielded incrementally without buffering the full set | - -Start with a string return and only switch to a more complex type when you need what it provides. All template forms can do async work (string, object, and array all support `async` functions). Choose AsyncIterator specifically when the number of output files is not known until the template runs, or when you want to stream outputs one at a time rather than building the full list in memory first. - - -## Page data and introspection - -Page functions and layouts, including those rendering generated pages, receive metadata for the current page through `page`. -Only `global.data.ts` receives the collection of source-backed `PageData` instances. -This is the intentional boundary between source-page introspection and downstream rendering. - -```typescript -// src/example/page.ts -export default function examplePage ({ page }) { - console.log(page.url) - return '' -} -``` - -Generated-page factories do not receive `PageData`. -They consume values explicitly returned by `global.data.ts` instead. - -### Page metadata - -The current `page` is a `PageInfo` object with the following properties: - -- `type`: The page type (`md`, `html`, or `js`). -- `path`: The source-relative directory path for the page. -- `url`: The canonical URL path, such as `/blog/my-post/` for index pages or `/blog/loose-page.html` for loose pages. -- `outputName`: The final output filename. -- `outputRelname`: The destination-relative output path. -- `pageFile`: Source-file path details. -- `pageStyle`: File information when the page has a page style. -- `clientBundle`: File information when the page has a client bundle. -- `pageVars`: File information when the page has an adjacent page-variable file. -- `generated`: Metadata about the `*.pages.ts` file that created a generated page, or `undefined` for a source-backed page. - -Each `PageData` entry supplied to `global.data.ts` exposes this object as `page.pageInfo`. -Combine `page.pageInfo.url` with a `siteUrl` from `global.vars.ts` to build an absolute URL: `` `${vars.siteUrl}${page.pageInfo.url}` ``. -The [RSS and JSON feed recipe](#generate-rss-and-json-feeds) uses this pattern for feed item URLs. - -### Rendering page content - -Each `PageData` instance passed to `global.data.ts` exposes two methods for accessing rendered output. -This is useful when derived data needs to embed a page's content, such as the [`global.data.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/global.data.ts) implementation used by the [RSS and JSON feed recipe](#generate-rss-and-json-feeds). - -- `await page.renderInnerPage()` returns the page's inner render output as produced by its builder, without a layout wrapper applied. This is often an HTML string, such as Markdown rendered to HTML, but the type depends on the page builder. -- `await page.renderFullPage()` returns the complete page output with its layout applied. - -Both methods are async, and rendering errors propagate and fail the build. -While `global.data.ts` is resolving, `renderInnerPage()` is allowed if the page itself has no subscriptions, even when its layouts subscribe to data. -`renderFullPage()` requires the page and its entire layout chain to be unsubscribed at that stage, because derived data does not exist yet. - -### Rendering many pages - -Use [`global.data.ts`](#global-data) to pre-render content shared by multiple downstream pages or templates. -This centralizes the work and makes the result available through an explicit subscription: - -```typescript -// src/global.data.ts -import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' - -const globalData: AsyncGlobalDataFunction = async ({ pages }) => { - const entries = await Promise.all( - pages.map(async page => [ - page.pageInfo.path, - await page.renderInnerPage() - ] as const) - ) - - return { renderedPagesByPath: Object.fromEntries(entries) } -} - -export default globalData -``` - -Rendering performed inside `global.data.ts` cannot use the derived values that the same file is still computing. -After `global.data.ts` returns, consumers receive only the values named by their `dataDeps` declarations. - - -## TypeScript Support - -`domstack` supports **TypeScript** via native type-stripping in Node.js. -It helps you write better Javascript and with type stripping, has very little overhead. -It's recommended that you use it! - -- **Requires Node.js β‰₯23** *(built-in)* or **Node.js 22** with the `NODE_OPTIONS="--experimental-strip-types" domstack` env variable. -- Seamlessly mix `.ts`, `.mts`, `.cts` files alongside `.js`, `.mjs`, `.cjs`. -- No explicit compilation step neededβ€”Node.js handles type stripping at runtime. -- Fully compatible with existing `domstack` file naming conventions. -- Anywhere DOMStack loads JS files, it can now load TS files. - -### Supported File Types - -Anywhere you can use a `.js`, `.mjs`, or `.cjs` file in DOMStack, you can use the corresponding `.ts`, `.mts`, or `.cts` extension. - -> [!TIP] -> Prefer the regular `.ts` and `.js` extensions with [`"type": "module"`](https://nodejs.org/api/packages.html#type) in `package.json`. Use the module-format escape-hatch extensions only when an individual file must override the package's module format. - -When running in a Node.js context, [type-stripping](https://nodejs.org/api/typescript.html#type-stripping) is used. -When running in a web client context, [esbuild](https://esbuild.github.io/content-types/#typescript) type stripping is used. -Type stripping provides 0 type checking, so be sure to set up `tsc` and `tsconfig.json` so you can catch type errors while editing or in CI. - -### Recommended `tsconfig.json` - -Install [@voxpelli/tsconfig](https://ghub.io/@voxpelli/tsconfig), which enables type checking in `.js` and `.ts` files and configures TypeScript for `--noEmit`. Extend its Node.js 22 baseline with DOMStack's type-stripping and client-TSX settings: - -```jsonc -// tsconfig.json -{ - "extends": "@voxpelli/tsconfig/node22.json", - "compilerOptions": { - "skipLibCheck": true, - "jsx": "preserve", - "erasableSyntaxOnly": true, - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - "verbatimModuleSyntax": true - }, - "include": ["src/**/*"], - "exclude": [ - "node_modules", - "public", - "coverage" - ] -} -``` - -### Using TypeScript with domstack Types - -You can use `domstack`'s built-in types to strongly type your layout, page, and template functions. Runtime values are imported from `@domstack/static`; types are imported from the dedicated `@domstack/static/types.js` entry. The following types are available: - -```ts -// src/types.ts -import type { - // Type a synchronous or asynchronous layout default export - LayoutFunction, - // Require a layout default export to return a promise - AsyncLayoutFunction, - // Type a synchronous or asynchronous global.data.ts default export - GlobalDataFunction, - // Require a global.data.ts default export to return a promise - AsyncGlobalDataFunction, - // Type a synchronous or asynchronous TypeScript page function - PageFunction, - // Require a TypeScript page function to return a promise - AsyncPageFunction, - // Type a template that returns one or more buffered outputs - TemplateFunction, - // Type an async-generator template that yields outputs incrementally - TemplateAsyncIterator, - // Type a generated-pages factory in a *.pages.ts file - PagesFunction, - - // Describe one initialized entry in the pages collection - PageData, - // Describe metadata for the current page - PageInfo, - // Describe the current *.template.ts file - TemplateInfo, - // Describe the current *.pages.ts file - PagesFileInfo, - // Describe one page returned by a generated-pages module - GeneratedPageDefinition, - - // Type a helper that receives a layout function's arguments - LayoutFunctionParams, - // Type a helper that receives global.data.ts arguments - GlobalDataFunctionParams, - // Type a helper that receives a page function's arguments - PageFunctionParams, - // Type a helper that receives a template function's arguments - TemplateFunctionParams, - // Type a helper that receives a generated-pages factory's arguments - PagesFunctionParams, -} from '@domstack/static/types.js' -``` - -> [!NOTE] -> Use `PageFunction`, `LayoutFunction`, `TemplateFunction`, and `GlobalDataFunction` for ordinary synchronous or asynchronous implementations. -> Their `Async*` variants are available when a type must specifically require a promise return value, including JSDoc annotations directly on async functions. -> `PagesFunction` supports normal functions, `async` functions, and async generators. - -The function types are generic and accept variable shapes that you can develop and share between files. - -The data and parameter types (`PageData`, `PageInfo`, `TemplateInfo`, `PagesFileInfo`, `GeneratedPageDefinition`, and `*FunctionParams`) are useful when you want to annotate variables or helper functions that receive these objects without using the function types directly: - -```ts -// src/page-utils.ts -import type { GlobalDataFunctionParams, PageData, PageInfo } from '@domstack/static/types.js' - -function getPublishedPages({ pages }: GlobalDataFunctionParams): PageData[] { - return pages.filter((p: PageData) => { - const info: PageInfo = p.pageInfo - return !info.draft - }) -} -``` - -#### Advanced type parameters - -`PageFunction`, `LayoutFunction`, `TemplateFunction`, and `PagesFunction` support additional type parameters for precise input, data, and return type control: - -**PageFunction** - -- `T` - The type of variables passed to the page (required) -- `U` - The return type of the page function (optional, defaults to `any`) -- `D` - The declared global-data shape (optional, defaults to `Record`) - -**LayoutFunction** - -- `T` - The type of variables passed to the layout (required) -- `U` - The type of content received from pages as `children` (optional, defaults to `any`) -- `V` - The return type of the layout function (optional, defaults to `string`) -- `D` - The declared global-data shape (optional, defaults to `Record`) - -**TemplateFunction** - -- `T` - The global vars passed to the template (required) -- `D` - The declared global-data shape (optional, defaults to `Record`) - -**PagesFunction** - -- `T` - The vars added to generated pages (optional, defaults to `Record`) -- `U` - The static children or inline page-function return type (optional, defaults to `string`) -- `V` - The default and global vars received by the pages factory (optional, defaults to `Record`) -- `D` - The factory's declared global-data shape (optional, defaults to `Record`) -- `P` - Inline pages' declared global-data shape (optional, defaults to `D` for convenience; set it independently when factory and page subscriptions differ) - -Each layout's input, output, and data types are independent of its parent and page. -DOMStack resolves layout names at runtime, so it cannot statically prove that two separately declared layout modules have compatible content types. -Manual function calls do receive normal TypeScript argument checking. - -This allows pages to return custom types (like VDOM or JSON), ensures layouts produce HTML strings, and keeps generated-page vars separate from the vars used to create them: - -```ts -// src/rendering-types.ts -// Define custom types -type VDOMNode = { - type: string - props: Record - children: Array -} - -// Page returns VDOM -const page: PageFunction<{title: string}, VDOMNode> = ({ vars }) => ({ - type: 'h1', - props: {}, - children: [vars.title] -}) - -// Layout accepts VDOM, returns HTML string -const layout: LayoutFunction<{site: string}, VDOMNode, string> = ({ children }) => { - const html = renderVDOM(children) // Convert VDOM to HTML - return `${html}` -} -``` - -## Advanced - -These features customize DOMStack’s rendering pipeline or coordinate generated assets with browser runtimes. - -### Custom layout renderers - -DOMStack's bundled default layout uses [`fragtml`][fragtml] because the default template only needs safe string manipulation. -You can eject or replace that layout with any Node-compatible renderer that returns an HTML string. -The previous incumbent for this job was `htm/preact` with [`preact-render-to-string`](https://github.com/preactjs/preact-render-to-string). -That is still a good fit when your Node-side pages or layouts produce Preact VNodes, or when you want the same component model on the server and in browser bundles. -If you also want Preact or React in browser JSX/TSX bundles, configure that separately as described in [`.tsx`](#tsx). - -```console -npm install htm preact preact-render-to-string -``` - -```js -/** - * @import { LayoutFunction } from '@domstack/static/types.js' - * @import { VNode } from 'preact' - */ -import { html } from 'htm/preact' -import { render } from 'preact-render-to-string' - -/** @type {LayoutFunction, string | VNode, string>} */ -export default function rootLayout ({ children, vars, scripts, styles }) { - return ` -${render(html` - - ${vars.title} - ${styles?.map(style => html``)} - ${scripts?.map(script => html``)} - - - ${typeof children === 'string' - ? html`
` - : html`
${children}
`} - -`)}` -} -``` - -[`preact-render-to-string`](https://github.com/preactjs/preact-render-to-string) works, but it builds a virtual DOM tree just to serialize layout HTML. -For layouts that mostly combine strings and already-rendered page content, [`async-htm-to-string`](https://github.com/voxpelli/async-htm-to-string) keeps the familiar HTM tagged-template style while rendering directly to strings. -That can be a better-performing and more direct tool for server-only layout templates. -You can still use Preact for browser-side components and use `async-htm-to-string` for Node-side layout rendering. - -```console -npm install async-htm-to-string -``` - -```js -/** - * @import { LayoutFunction } from '@domstack/static/types.js' - */ -import { html, rawHtml } from 'async-htm-to-string' - -/** @type {LayoutFunction, string, Promise>} */ -export default async function rootLayout ({ children, vars, scripts, styles }) { - return await html` - - - ${vars.title} - ${styles?.map(style => html``)} - ${scripts?.map(script => html``)} - - -
${rawHtml(children)}
- -` -} -``` - -Key differences from `htm/preact` and DOMStack's `fragtml` default: - -- **Attribute names are standard HTML.** -Use `class` and `for` rather than React aliases like `className` and `htmlFor`, which `async-htm-to-string` will output literally with no warning. -For attributes like `tabindex`, `tabIndex` is only a casing preference in HTML, but using standard lowercase keeps templates consistent. -- **Always `await` the `html` tag.** -The tag returns an object that resolves to a string asynchronously. -If you return it without `await` from a non-async function, or assign it where a string is expected, you will get `[object Object]` in the output with no error thrown. -Use `async function` and `await` the result. - -> [!CAUTION] -> `rawHtml()` bypasses HTML escaping and is equivalent to setting `innerHTML` directly. Only use it with trusted HTML that you generated or sanitized yourself, such as the output of `await page.renderInnerPage()` or a trusted Markdown renderer. `children` passed to a layout can be any type returned by a page function and may contain unsanitized content; always verify its source before passing it to `rawHtml()`. - -### Web workers - -You can easily write [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) for a page by adding a file called `${name}.worker.ts` or `${name}.worker.js` where `name` becomes the name of the worker filename in the `workers.json` file. -DOMStack will build these similarly to page `client.ts` bundles, and will even bundle split their contents with the rest of your site. - -``` -page-directory/ - β”œβ”€β”€ page.js - β”œβ”€β”€ client.js - β”œβ”€β”€ counter.worker.js # Worker with counter functionality - └── data.worker.js # Worker for data processing -``` - -To use a woker, load in a `./workers.json` file that is generated along with the worker bundle to get the final name of the worker entrypoint and then create a worker with that filename. - -```typescript -// First, fetch the workers.json to get worker paths in your client.ts -async function initializeWorkers() { - const response = await fetch('./workers.json'); - const workersData = await response.json(); - - // Initialize workers with the correct hashed filenames - const counterWorker = new Worker( - new URL(`./${workersData.counter}`, import.meta.url), - { type: 'module' } - ); - - // Use the worker - counterWorker.postMessage({ action: 'increment' }); - - counterWorker.onmessage = (e) => { - console.log(e.data); - }; - - return counterWorker; -} - -const worker = await initializeWorkers(); -``` - -See the [Web Workers Example](https://github.com/domstack/domstack/tree/master/examples/worker-example) for a complete implementation. - -### Service workers - -DOMStack has full native support for [service workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API). -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.ts -``` - -DOMStack produces: - -```txt -public/ -└── service-worker.js -``` - -> [!NOTE] -> Wherever `service-worker.ts` is used, you can also use `service-worker.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - -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 using the project’s [`esbuild.settings.ts`](#esbuild-settingsts) configuration, 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 the service-worker URL and scope to browser bundles through esbuild `define` values: - -| Define | Value | -| --- | --- | -| `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 | - -Register the built service worker from your site client code, usually `global.client.ts`: - -```typescript -// src/globals/global.client.ts -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, - type: 'module', - updateViaCache: 'none' - }) -} -``` - -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. - -#### Registration and Web App Manifests - -Browsers allow service-worker registration only in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), normally HTTPS in production or localhost during development. The service-worker script must be served from the same origin as the page. DOMStack emits it at the origin root so its default scope can cover the entire site. Register it with `type: 'module'` because DOMStack builds the worker as ESM. - -A [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest) is not required to register or run a service worker. Add one when the site also needs installable-app metadata such as its name, icons, start URL, display mode, and theme colors. DOMStack does not generate this browser manifest. Author it as a [static asset](#static-assets) and reference it from the document head: - -```html - - -``` - -See these complete examples: - -- [`static-mpa-offline`](./examples/static-mpa-offline/) uses DOMStack's manifest hooks with a custom service worker and registration lifecycle. -- [`static-mpa-workbox-offline`](./examples/static-mpa-workbox-offline/) implements the same offline MPA pattern with Workbox. - -> [!CAUTION] -> DOMStack does not clean `dest` before building. Clean the destination before deployment, especially after removing or renaming a service worker, so an old `/service-worker.js` cannot remain publicly available. - -### DOMStack manifest - -The DOMStack manifest is build metadata for service workers, deployment tools, and other build-time integrations. It is not a [Web App Manifest](#registration-and-web-app-manifests). (A Web App Manifest such as `site.webmanifest` can be generated independently with a [template](#templates).) - -A generated manifest resembles: - -```jsonc -// public/domstack-manifest.json -{ - "$schema": "https://unpkg.com/@domstack/static@/lib/domstack-manifest/schema.json", - "version": "a1b2c3...", - "generatedAt": "2026-08-31T12:00:00.000Z", - "entries": [ - { - "outputRelname": "index.html", - "kind": "page", - "url": "/", - "revision": "d4e5f6...", - "bytes": 1240, - "contentType": "text/html; charset=utf-8", - "static": true, - "role": "navigation" - } - ], - "policy": { - "offlineFallbackUrl": "/offline/" - } -} -``` - -When enabled, DOMStack collects its emitted pages, templates, bundles, workers, copied files, and static assets into a normalized list of public outputs. You can filter that list, expose selected page variables, attach application policy, and consume the finalized result from a hook or programmatic build. The finalized manifest can be injected statically into your service worker or emitted as a standalone `domstack-manifest.json` file. - -> [!WARNING] -> The DOMStack manifest pipeline is an unstable preview feature. This includes its schema, settings, hooks, policy and entry variables, and `process.env.DOMSTACK_MANIFEST_*` defines. Pin `@domstack/static` to an exact version when building against this preview API. - -The manifest lifecycle is: - -1. DOMStack collects and reconciles emitted outputs. -2. Excludes and entry filters run, then selected page variables are attached. -3. DOMStack finalizes the manifest entries, root policy, and deterministic version. -4. `manifestBuilt` hooks receive the finalized manifest. -5. DOMStack bundles the site service worker with any constants defined by the hooks. -6. DOMStack optionally writes `domstack-manifest.json` and returns the manifest from programmatic builds. - -The site service worker is omitted from manifest entries. This allows the finalized manifest version to be embedded in `/service-worker.js` without creating a circular content hash. - -#### Enable the manifest - -The manifest pipeline is disabled by default. Enable it with one of these configuration surfaces: - -| Configuration | Pipeline enabled | Writes `domstack-manifest.json` | -|---|---:|---:| -| One `domstack-manifest.settings.ts` file anywhere in `src` | Yes | No | -| `domstackManifest: true` | Yes | Yes | -| `domstackManifest: { ... }` | Yes | Only with `write: true` | -| CLI `--domstackManifest` | Yes | Yes | - -A settings file enables manifest reconciliation, hooks, and `results.domstackManifest` without requiring a public JSON file. This is sufficient when a service worker receives its cache policy through an injected build constant. - -> [!NOTE] -> Wherever `domstack-manifest.settings.ts` is used, you can also use `domstack-manifest.settings.js`. Type checking is supported in both file types. See [Supported file types](#supported-file-types) for all available extensions. - - -#### Configure entries and policy - -Create one `domstack-manifest.settings.ts` file anywhere under `src`. It can default-export an options object or a synchronous or asynchronous function that returns one. - -```typescript -// src/globals/domstack-manifest.settings.ts -import type { DomstackManifestOptions } from '@domstack/static/types.js' - -type PageVars = { - offline?: boolean - precache?: boolean -} - -type ManifestVars = Pick - -type ManifestPolicy = { - offlineFallbackUrl: string -} - -const settings = { - exclude: ['admin/**', '**/*.map'], - includeEntry: entry => entry.kind !== 'metadata', - manifestVars: ['offline', 'precache'], - policy: { - offlineFallbackUrl: '/offline/' - } -} satisfies DomstackManifestOptions< - ManifestPolicy, - ManifestVars, - PageVars -> - -export default settings -``` - -The main settings are: - -| Setting | Purpose | -|---|---| -| `exclude` | Ignore-style patterns matched against both `entry.url` and `entry.outputRelname` | -| `includeEntry(entry)` | A final synchronous or asynchronous predicate that returns `true` to retain an entry | -| `manifestVars` | An allowlist or per-entry transform that exposes selected resolved page variables | -| `policy` | A manifest-wide object or transform for application-defined policy | -| `hooks.manifestBuilt` | Hooks that consume the finalized manifest before the service worker is bundled | - -Only variables explicitly selected by `manifestVars` are copied into entries. Arbitrary page variables are not exposed automatically. `exclude` runs before `includeEntry(entry)`. - -The resulting manifest contains: - -- `version`: A deterministic digest that changes when retained cache-relevant entries or root policy change -- `generatedAt`: The build timestamp, which does not affect `version` -- `entries`: Included public outputs sorted by URL -- `policy`: Optional application-defined manifest-wide policy - -Useful entry fields include `url`, `revision`, `kind`, `bytes`, `contentType`, `integrity`, `urlRevisioned`, `static`, `role`, and explicitly selected `manifestVars`. Import `DomstackManifest` and `DomstackManifestEntry` from `@domstack/static/types.js` when consuming these objects directly. - -#### Manifest built hooks - -`hooks.manifestBuilt` runs after entries, policy, and version are finalized but before `/service-worker.js` is bundled. Each hook receives: - -- `manifest`: The finalized manifest -- `dest`: The absolute destination directory -- `defineServiceWorkerConstant(name, value)`: Injects a JSON-serializable value into only the final service-worker bundle -- `writeFile(outputRelname, contents)`: Writes an additional file under `dest` - -Files written by a hook are not added back to the already-finalized manifest. Prefer an injected constant when only the service worker needs the generated data. - -#### Service worker integration - -A manifest hook can turn the normalized entries into a small application-specific cache policy: - -```typescript -// src/globals/domstack-manifest.settings.ts -import type { - DomstackManifestBuiltHookContext, - DomstackManifestOptions -} from '@domstack/static/types.js' - -export type CachePolicy = { - version: string - precacheEntries: Array<{ - url: string - revision: string | null - integrity?: string - }> -} - -function injectCachePolicy ( - context: DomstackManifestBuiltHookContext -): void { - const policy: CachePolicy = { - 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, - ...(entry.integrity ? { integrity: entry.integrity } : {}) - })) - } - - context.defineServiceWorkerConstant('__APP_CACHE_POLICY__', policy) -} - -const settings = { - hooks: { - manifestBuilt: [injectCachePolicy] - } -} satisfies DomstackManifestOptions - -export default settings -``` - -The service worker can then consume the injected value without fetching a public manifest at runtime: - -```typescript -// src/globals/service-worker.ts -import type { CachePolicy } from './domstack-manifest.settings.ts' - -declare const __APP_CACHE_POLICY__: CachePolicy - -const cachePolicy = __APP_CACHE_POLICY__ -``` - -Manifest-enabled builds also define: - -| Define | Value | -|---|---| -| `process.env.DOMSTACK_MANIFEST_ENABLED` | `"true"` for a manifest-enabled one-shot build and `"false"` otherwise | -| `process.env.DOMSTACK_MANIFEST_VERSION` | The finalized version inside `/service-worker.js`; `""` in other bundles | -| `process.env.DOMSTACK_MANIFEST_URL` | The conventional `/domstack-manifest.json` URL | - -`DOMSTACK_MANIFEST_URL` does not guarantee that the JSON file was written. Fetch it only when `--domstackManifest`, `domstackManifest: true`, or `{ write: true }` enabled public output. - -> [!IMPORTANT] -> Watch mode still bundles the service worker, but it does not finalize, return, or write the DOMStack manifest. Manifest hooks do not inject production cache policy in watch mode. Use a one-shot build or `domstack --serve` to test manifest-driven service-worker behavior. - -`domstack --serve` runs a normal one-shot build and serves `dest` without watch-mode filenames or live-reload injection: - -```console -domstack --serve -domstack --serve --port 3001 -``` - -See the complete examples for production-oriented cache lifecycle behavior: - -- [`static-mpa-offline`](./examples/static-mpa-offline/) injects DOMStack manifest entries into a custom service worker. -- [`static-mpa-workbox-offline`](./examples/static-mpa-workbox-offline/) converts the finalized entries into Workbox precaching and routing policy. - -#### Programmatic configuration - -Configure the manifest through the `DomStack` constructor when coordinating it with another build tool or script: - -```typescript -// scripts/build.ts -import { DomStack } from '@domstack/static' - -const site = new DomStack('src', 'public', { - domstackManifest: { - write: true, - exclude: ['admin/**', '**/*.map'] - } -}) - -const results = await site.build() -console.log(results.domstackManifest?.version) -``` - -### Programmatic test builds - -Use the top-level `testBuild` helper to build into a temporary directory from tests without managing setup and cleanup yourself. - -```js -import { test } from 'node:test' -import assert from 'node:assert' -import { testBuild } from '@domstack/static' - -test('site output', async () => { - const build = await testBuild('./src') - - try { - const html = await build.readOutput('index.html') - assert.match(html, /Hello/) - } finally { - await build.cleanup() - } -}) -``` - -`testBuild(src, opts)` creates a temporary destination directory, runs `new DomStack(src, dest, opts).build()`, and returns `{ dest, results, readOutput, cleanup }`. Options are passed through to `DomStack`, including `copy` paths. - -See these repository tests for complete usage: - -- [`test-build-helper/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/test-build-helper/index.test.js) tests temporary output, `readOutput()`, copied directories, and cleanup. -- [`default-layout/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/default-layout/index.test.js) uses `testBuild()` for a focused output assertion. -- [`generated-pages/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/generated-pages/index.test.js) uses it with generated pages, global data, and templates. - -## Cookbook - -Applied examples that combine multiple DOMStack features. - -### Compose nested layouts - -This recipe uses the [explicit `parentLayout` declaration](#declaring-nested-layouts) described in the layout API. -Pages select their innermost layout with `vars.layout`. -A layout can export a static `parentLayout` name to let DOMStack wrap it in another layout. - -```typescript -// article.layout.ts -import { html, raw, render } from 'fragtml' -import type { LayoutFunction } from '@domstack/static/types.js' -import type { RootLayoutVars } from './root.layout.ts' - -export const parentLayout = 'root' -export const vars = { showSidebar: true } - -const articleLayout: LayoutFunction = ({ children }) => { - return render(html`
${raw(children)}
`) -} - -export default articleLayout -``` - -```typescript -// posts/example/page.ts -export const vars = { layout: 'article', title: 'A post' } -export default () => '

Hello from the post.

' -``` - -DOMStack renders `root(article(page()))`. -A root layout omits `parentLayout`; child layouts can name any discovered layout, including the bundled `root`. -Names are the same filename-derived names used by `vars.layout`, not import paths. -Missing parents, invalid parent exports, and cycles fail the build with the offending layout or chain. - -All renderers receive the same resolved vars, page metadata, worker URLs, and asset lists. -Vars merge from outermost to innermost layout, followed by page vars and builder/frontmatter vars. -Layout `vars.layout` does not select a parent; only the named `parentLayout` export establishes nesting. -Async layouts are awaited at every step, and intermediate values pass through unchanged until the final result is serialized. -Each parent must accept the kind of children its immediate child returns. - -#### Data subscriptions in nested layouts - -Each layout declares only the data it reads. -Keep focused consumer types beside their producer in `global.data.ts`: - -```typescript -// global.data.ts -export type RootLayoutData = { navigation: { title: string, url: string }[] } -export type ArticleLayoutData = { recentPosts: { title: string, url: string }[] } -export type GlobalData = RootLayoutData & ArticleLayoutData -``` - -```typescript -// root.layout.ts -import type { DataDeps } from '@domstack/static/types.js' -import type { RootLayoutData } from './global.data.ts' - -export const vars = { - dataDeps: ['navigation'] satisfies DataDeps, -} -``` - -```typescript -// article.layout.ts -import type { DataDeps } from '@domstack/static/types.js' -import type { ArticleLayoutData } from './global.data.ts' - -export const parentLayout = 'root' -export const vars = { - dataDeps: ['recentPosts'] satisfies DataDeps, -} -``` - -These declaration snippets accompany each layout's render function. -The root receives `data.navigation`, and the article receives `data.recentPosts`. -A page using `article` rebuilds when either key changes, but it receives neither key unless it declares its own subscription. -Only put a subscription in a shared root when every descendant genuinely uses that data through the root. - -#### Nested layout client bundles and styles - -DOMStack includes each ancestor's own style and client entry automatically. -The order is defaults β†’ globals β†’ outer layouts β†’ inner layouts β†’ page assets. -For example, a post using `article` receives `root.layout.css` before `article.layout.css`. -Do not also import the parent's layout CSS or client from the child: doing both duplicates its contents or execution. - -Watch mode uses the resolved chain for source-backed and generated pages. -Changing a parent layout or one of its imported helpers rebuilds descendant pages, and changing the chain updates those relationships after a successful build. -Existing asset edits use esbuild's watcher; adding or removing a layout asset updates the affected pages' asset lists. - -#### Manual composition - -Manual function composition is supported and tested for source-backed and generated pages. -Prefer `parentLayout` for ordinary nesting: DOMStack can then manage the full chain's defaults, assets, dependencies, and rebuilds for you. -A layout without `parentLayout` still runs once, and it may import and call other render functions itself. -DOMStack does not infer a parent from those imports, merge the imported function's vars, or add its assets. -Manual composition must forward the required arguments and explicitly import parent assets. -The composing layout also declares every data key its manually called helpers need and forwards `data` itself. - -```typescript -// manual.layout.ts -import rootLayout from './root.layout.ts' -import type { DataDeps, LayoutFunction } from '@domstack/static/types.js' -import type { RootLayoutVars } from './root.layout.ts' -import type { RootLayoutData } from './global.data.ts' - -export const vars = { dataDeps: ['navigation'] satisfies DataDeps } - -const manualLayout: LayoutFunction = args => { - return rootLayout({ ...args, children: `
${args.children}
` }) -} - -export default manualLayout -``` - -Static import tracking still rebuilds these pages when an imported parent or helper changes. -The composing layout's declared keys also trigger rebuilds when their global-data values change, for both source-backed and generated pages. -If the parent has layout CSS or client code, import those files from the composing layout's corresponding asset entries. -These manual responsibilities are why explicit `parentLayout` nesting is recommended, not a restriction on using ordinary functions. - -To migrate, replace the parent function call with a `parentLayout` export and return only the child wrapper. -Move shared defaults into exported layout `vars`, and remove child imports of the parent's layout CSS and client. -Do not keep the manual parent call when adding `parentLayout`, or the parent will render twice. - -### Generate RSS and JSON feeds - -Use `global.data.ts` to inspect and render source pages, then let a feed template subscribe to the prepared records. - -The following example generates an [RSS](https://www.rssboard.org) and [JSON Feed](https://www.jsonfeed.org) from the 10 most recent date-sorted pages using the `blog` layout and the AsyncIterator template type. -It uses [`renderInnerPage()`](#rendering-page-content) while global data is computed, so the template never receives the page graph. -See the [blog example's `global.data.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/global.data.ts) and [`feeds.template.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/feeds.template.ts) for a working implementation. - -```typescript -// src/global.data.ts -import pMap from 'p-map' -import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' - -export interface FeedItem { - datePublished: string - title: string - urlPath: string - contentHtml: string -} - -export interface GlobalData { - feedItems: FeedItem[] -} - -export type FeedsTemplateData = Pick - -const globalData: AsyncGlobalDataFunction = async ({ pages }) => { - const posts = pages - .filter(page => page.pageInfo.path.startsWith('blog/') && page.vars.layout === 'blog') - .sort((a, b) => new Date(b.vars.publishDate).valueOf() - new Date(a.vars.publishDate).valueOf()) - .slice(0, 10) - - const feedItems = await pMap(posts, async page => ({ - datePublished: String(page.vars.publishDate), - title: String(page.vars.title), - urlPath: page.pageInfo.url, - contentHtml: String(await page.renderInnerPage()), - }), { concurrency: 4 }) - - return { feedItems } -} - -export default globalData -``` - -```typescript -// src/feeds.template.ts -import jsonfeedToAtom from 'jsonfeed-to-atom' -import type { DataDeps, TemplateAsyncIterator } from '@domstack/static/types.js' -import type { FeedsTemplateData } from './global.data.js' - -interface TemplateVars { - title: string; - layout: string; - siteName: string; - homePageUrl: string; - authorName: string; - authorUrl: string; - authorImgUrl?: string; - siteDescription: string; - language: string; -} - -export const dataDeps = ['feedItems'] satisfies DataDeps - -const feedsTemplate: TemplateAsyncIterator = async function * ({ - vars: { - siteName, - siteDescription, - homePageUrl, - language = 'en-us', - authorName, - authorUrl, - authorImgUrl, - }, - data, -}) { - const jsonFeed = { - version: 'https://jsonfeed.org/version/1', - title: siteName, - home_page_url: homePageUrl, - feed_url: `${homePageUrl}/feed.json`, - description: siteDescription, - author: { - name: authorName, - url: authorUrl, - avatar: authorImgUrl - }, - items: data.feedItems.map(item => { - return { - date_published: item.datePublished, - title: item.title, - url: `${homePageUrl}${item.urlPath}`, - id: `${homePageUrl}${item.urlPath}#${item.datePublished}`, - content_html: item.contentHtml, - } - }), - } - - yield { - content: JSON.stringify(jsonFeed, null, ' '), - outputName: './feeds/feed.json' - } - - yield { - content: jsonfeedToAtom(jsonFeed), - outputName: './feeds/feed.xml' - } -} - -export default feedsTemplate -``` - -### Generate yearly blog index pages - -Global data centralizes collection and grouping once, then generated pages turn those records into pages. See the working [blog example directory](./examples/blog/), [`global.data.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/global.data.ts), [`blog-indexes.pages.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/blog-indexes.pages.ts), and [`year-index.layout.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/layouts/year-index.layout.ts). - -First, collect source-backed pages whose layout is `post`, validate and normalize their publish dates, sort them newest-first, and group them into yearly `blogIndexes`: - -```typescript -// src/global.data.ts -import type { - AsyncGlobalDataFunction, - GlobalDataFunctionParams, -} from '@domstack/static/types.js' - -export interface BlogPost { - path: string - title: string - publishDate: string -} - -export interface BlogIndex { - year: number - posts: BlogPost[] -} - -export interface GlobalData { - blogIndexes: BlogIndex[] -} - -export type BlogIndexesPagesData = Pick - -type SourcePageVars = { layout?: string, title?: unknown, publishDate?: unknown } - -function collectBlogPosts (pages: GlobalDataFunctionParams['pages']): BlogPost[] { - return pages - .filter(page => page.vars.layout === 'post') - .map(page => { - const value = page.vars.publishDate - if (typeof value !== 'string' && !(value instanceof Date)) { - throw new TypeError(`Post "${page.pageInfo.path}" needs a publishDate`) - } - - const publishDate = new Date(value.valueOf()) - if (Number.isNaN(publishDate.valueOf())) { - throw new TypeError(`Post "${page.pageInfo.path}" has an invalid publishDate`) - } - - return { - path: page.pageInfo.path, - title: String(page.vars.title ?? 'Untitled'), - publishDate: publishDate.toISOString(), - } - }) - .sort((a, b) => b.publishDate.localeCompare(a.publishDate)) -} - -const globalData: AsyncGlobalDataFunction = async ({ pages }) => { - const postsByYear = new Map() - - for (const post of collectBlogPosts(pages)) { - const year = new Date(post.publishDate).getUTCFullYear() - postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]) - } - - const blogIndexes = [...postsByYear] - .map(([year, posts]) => ({ year, posts })) - .sort((a, b) => b.year - a.year) - - return { blogIndexes } -} - -export default globalData -``` - -Then subscribe to `blogIndexes` and create one `blog//index.html` page per group using the `year-index` layout: - -```typescript -// src/blog-indexes.pages.ts -import type { DataDeps, PagesFunction } from '@domstack/static/types.js' -import type { BlogIndexesPagesData, BlogPost } from './global.data.js' - -type YearIndexPageVars = { - layout: 'year-index' - title: string - posts: BlogPost[] -} - -export const dataDeps = ['blogIndexes'] satisfies DataDeps - -const blogIndexes: PagesFunction< - YearIndexPageVars, - string, - Record, - BlogIndexesPagesData -> = ({ data }) => - data.blogIndexes.map(({ year, posts }) => ({ - outputName: `blog/${year}/index.html`, - vars: { - layout: 'year-index', - title: String(year), - posts, - }, - })) - -export default blogIndexes -``` - -### Generate redirect pages from page metadata - -See the working [blog example directory](./examples/blog/), [`redirects.pages.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/redirects.pages.ts), and [`redirect.layout.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/layouts/redirect.layout.ts). - -Sites migrating from another platform often need redirect pages for old URLs that no longer exist. Keep that history on the current page with `redirectFrom` metadata instead of maintaining a separate old/new mapping: - -```md ---- -title: Current Post -redirectFrom: - - /2020/old-slug/ - - /blog/original-title/ ---- - - -# Current Post -``` - -Collect the metadata in `global.data.ts`. The current page's URL becomes the redirect target automatically: - -```typescript -// src/global.data.ts -function collectRedirects (pages) { - const redirects = [] - const redirectOwners = new Map() - - for (const page of pages) { - const redirectFrom = page.vars.redirectFrom - if (redirectFrom === undefined) continue - - const source = page.pageInfo.pageFile.relname - if (!Array.isArray(redirectFrom)) throw new TypeError(`redirectFrom on "${source}" must be an array`) - - for (const from of redirectFrom) { - if (typeof from !== 'string') throw new TypeError(`redirectFrom entries on "${source}" must be strings`) - if (from.trim() !== from || !from.startsWith('/') || from.startsWith('//')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": expected a same-origin URL path`) - if (from.includes('?') || from.includes('#') || from.includes('\\') || from.split('/').some(part => part === '.' || part === '..')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": unsupported URL path`) - - const existingSource = redirectOwners.get(from) - if (existingSource) throw new Error(`redirectFrom "${from}" is declared by both "${existingSource}" and "${source}"`) - - redirectOwners.set(from, source) - redirects.push({ from, to: page.pageInfo.url }) - } - } - - return redirects -} - -export default function globalData ({ pages }) { - return { redirects: collectRedirects(pages) } -} -``` - -Validation happens while the destination page is still known, so malformed or duplicate metadata reports the page that declared it. The pages factory then consumes the validated collection and renders each old location through a reusable redirect layout: - -```typescript -// src/redirects.pages.ts -function redirectOutputName (from) { - if (!from.startsWith('/') || from.startsWith('//')) throw new Error(`redirectFrom must be a same-origin URL path: ${from}`) - if (from.includes('?') || from.includes('#')) throw new Error(`redirectFrom must not include a query or fragment: ${from}`) - - const relativePath = from.slice(1) - if (relativePath.length === 0) return 'index.html' - return relativePath.endsWith('/') ? `${relativePath}index.html` : relativePath -} - -export const dataDeps = ['redirects'] - -export default function redirectsPages ({ data }) { - const pages = [] - - for (const { from, to } of data.redirects) { - pages.push({ - outputName: redirectOutputName(from), - vars: { - layout: 'redirect', - title: 'Redirecting...', - redirectTo: to, - }, - }) - } - - return pages -} -``` - -```typescript -// src/redirect.layout.ts - -import { html, render } from 'fragtml' - -export default function redirectLayout ({ vars }) { - return render(html` - - - - - - ${vars.title} - - -

Redirecting to ${vars.redirectTo}

- -`) -} -``` - -`redirectFrom` contains old same-origin public URL paths. `redirectOutputName()` converts directory URLs such as `/2020/old-slug/` to `2020/old-slug/index.html`. DOMStack's generated-output validation still rejects escaping paths such as `..`. The redirect target comes from the current page's normalized `pageInfo.url`, so moving the page again only requires retaining its previous URLs in that page's metadata. `fragtml` escapes interpolated values by default, including attribute values and link text. - -**SEO note:** Meta-refresh is a client-side redirect. Search engines may not treat it as a permanent 301 redirect. For static hosting platforms that support server-side redirects, you can instead generate a `_redirects` file (Netlify, Cloudflare Pages) or `vercel.json` (Vercel) using the object template type: - -```typescript -// src/redirects-netlify.txt.template.ts -// Generates a _redirects file for Netlify / Cloudflare Pages. - -export const dataDeps = ['redirects'] - -export default function ({ data }) { - return { - outputName: '_redirects', - content: data.redirects.map(({ from, to }) => `${from} ${to} 301`).join('\n'), - } -} -``` - -Both approaches can coexist and consume the same `global.data.ts` redirect collection. Copying a directory that contains a hand-crafted `_redirects` file via `--copy` is also an option when you prefer to manage redirects outside the build. - -## Implementation - -`domstack` bundles the best tools for every technology in the stack: - -- `js` and `css` is bundled with [`esbuild`](https://github.com/evanw/esbuild). -- `md` is processed with [markdown-it](https://github.com/markdown-it/markdown-it). -- static files are processed with [cpx2](https://github.com/bcomnes/cpx2). -- `ts` support via native typestripping in Node.js and esbuild. -- `jsx/tsx` support via esbuild. - -These tools are treated as implementation details, but they may be exposed more in the future. The idea is that they can be swapped out for better tools in the future if they don't make it. - -### Build Process Flow - -The following diagram illustrates the DomStack build process: - -``` - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ START β”‚ - β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ identifyPages() β”‚ - β”‚ β”‚ - β”‚ β€’ Find pages β”‚ - β”‚ β€’ Find layouts β”‚ - β”‚ β€’ Find templates β”‚ - β”‚ β€’ Find globals β”‚ - β”‚ β€’ Find settings β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ β”‚ - β–Ό β–Ό β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ buildEsbuild() β”‚ β”‚ buildStatic() β”‚ β”‚ buildCopy() β”‚ -β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β€’ Bundle JS/CSS β”‚ β”‚ β€’ Copy static β”‚ β”‚ β€’ Copy extra β”‚ -β”‚ β€’ Generate β”‚ β”‚ files β”‚ β”‚ directories β”‚ -β”‚ records β”‚ β”‚ β€’ Record files β”‚ β”‚ β€’ Record files β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ buildPages() β”‚ - β”‚ β”‚ - β”‚ β€’ Process HTML β”‚ - β”‚ β€’ Process MD β”‚ - β”‚ β€’ Process JS β”‚ - β”‚ β€’ Apply layouts β”‚ - β”‚ β€’ Record outputs β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Reconcile β”‚ - β”‚ Output Manifest β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Return Results β”‚ - β”‚ β”‚ - β”‚ β€’ siteData β”‚ - β”‚ β€’ esbuildResults β”‚ - β”‚ β€’ staticResults β”‚ - β”‚ β€’ copyResults β”‚ - β”‚ β€’ pageBuildResults β”‚ - β”‚ β€’ domstackManifest β”‚ - β”‚ β€’ warnings β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -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 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 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. - -#### buildPages() Detail - -The `buildPages()` step processes pages in parallel with a concurrency limit: - -``` - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ buildPages() β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Resolve Once: β”‚ - β”‚ β€’ Global vars β”‚ - β”‚ β€’ All layouts β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Parallel Page Init β”‚ - β”‚(Concurrency: min(CPUs, 24))β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ β”‚ - β–Ό β–Ό β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ MD Page Task β”‚ β”‚ HTML Page Task β”‚ β”‚ JS Page Task β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚1. Parse MD β”‚ β”‚ β”‚ β”‚1. Read .htmlβ”‚ β”‚ β”‚ β”‚1. Import .jsβ”‚ β”‚ -β”‚ β”‚ frontmatter β”‚ β”‚ β”‚ β”‚ file β”‚ β”‚ β”‚ β”‚ module β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚2. Variable β”‚ β”‚ β”‚ β”‚2. Variable β”‚ β”‚ β”‚ β”‚2. Variable β”‚ β”‚ -β”‚ β”‚ Resolution β”‚ β”‚ β”‚ β”‚ Resolution β”‚ β”‚ β”‚ β”‚ Resolution β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ β”‚ β–Ό β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ builder + β”‚ β”‚ β”‚ β”‚page.vars.js β”‚ β”‚ β”‚ β”‚ Exported β”‚ β”‚ -β”‚ β”‚ page.vars.jsβ”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + page.varsβ”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ global.data.ts runs β”‚ - β”‚ (receives source PageData[])β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ *.pages.* generates pages β”‚ - β”‚ using the derived data β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Select data, render + write β”‚ - β”‚ (Concurrency: min(CPUs, 24)) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -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). -- **Layout vars** - Optional `export const vars` from the resolved layout chain, merged outermost to innermost. -- **Page-specific vars** vary by type: - - **MD pages**: `page.vars.js` plus builder vars from frontmatter. - - **HTML pages**: `page.vars.js`. - - **JS pages**: exported `vars` plus `page.vars.js`. - -Global data is not a variable-resolution layer. -It is resolved separately and projected into each consumer's `data` argument according to `dataDeps`. - -### Watch mode - -Running `domstack --watch` or `domstack -w` performs an initial build, watches the source inputs, and serves `dest` with live reload. Use `domstack --watch-only` when another process serves the output. - -Watch mode coordinates three independent watchers: - -- **esbuild** uses `context.watch()` for global, layout, and page client bundles, styles, page-scoped Web Workers, and the site service worker. -- **chokidar** watches page, layout, template, generated-pages, variable, and settings modules. DOMStack uses the changed file and its dependency maps to choose a rebuild scope. -- **cpx2** watches static assets under `src` and directories supplied with `--copy`, copying or removing their destination files directly. - -> [!NOTE] -> The filenames below use `.ts` by default. You can also use `.js`, and TypeScript client bundles can use `.tsx`. See [Supported file types](#supported-file-types) for all available extensions. - -DOMStack uses these rebuild scopes: - -- **esbuild only**: esbuild updates an existing browser entry without rendering HTML. -- **Targeted page/template rebuild**: DOMStack renders only the affected source-backed pages or templates. -- **Targeted generated-pages rebuild**: DOMStack renders and reconciles only the outputs owned by affected `*.pages.ts` files. -- **Full page/template rebuild**: DOMStack renders every source-backed and generated page and every template without restarting esbuild. -- **Full rebuild**: DOMStack rediscovers the source tree, restarts esbuild, renders all pages and templates, and refreshes its dependency maps. - -Like templates, generated-pages modules rebuild when their own source or imported dependencies change. -When a targeted build recomputes global data, DOMStack compares top-level values with the previous successful build and adds only subscribers of changed keys to the rebuild set. - -#### What triggers what - -| Change | Rebuild scope | -|---|---| -| Existing `page.ts`, `page.html`, `page.md`, or adjacent `page.vars.ts` | That page, plus subscribers of any changed global-data keys | -| A module imported by a TypeScript page or `page.vars.ts` | Pages that depend on it, plus subscribers of any changed global-data keys | -| Existing `*.layout.ts` or a module it imports | Source-backed pages and generated-page owners using the affected layout | -| Existing `*.template.ts` or a module it imports | Affected templates | -| Existing `*.pages.ts` | Generated outputs owned by that file, then refresh dependency maps | -| A module imported by `*.pages.ts` | Generated outputs owned by the importing files, then refresh dependency maps | -| `markdown-it.settings.ts` | All source-backed Markdown pages, plus subscribers of any changed global-data keys | -| `global.data.ts` | Consumers subscribed to top-level keys whose values changed | -| `global.vars.ts` or `esbuild.settings.ts` | Full rebuild | -| `domstack-manifest.settings.ts` | No rebuild. The manifest pipeline is disabled in watch mode | -| Existing client, style, Web Worker, or service-worker entry | esbuild only | -| Static asset under `src` or a file under a `--copy` directory | cpx2 copies or removes the output directly | - -Adding or removing a file changes the set of discovered build inputs: - -| Added or removed file | Rebuild scope | -|---|---| -| Site `service-worker.ts` | Restart esbuild. No page rebuild | -| `global.client.ts` or `global.css` | Restart esbuild and rebuild all pages | -| Layout client or style | Restart esbuild and rebuild source-backed pages and generated-page owners using that layout | -| Page client, style, or Web Worker | Restart esbuild and rebuild that page | -| Any other page, layout, template, generated-pages, variable, or settings file | Full rebuild | - -When a full page/template rebuild or targeted generated-pages rebuild no longer claims an output from the previous successful build, DOMStack removes that obsolete page or template output from `dest` without touching outputs owned by unaffected files. - -#### Dependency tracking - -DOMStack uses [`@11ty/dependency-tree-typescript`](https://github.com/11ty/dependency-tree-typescript) to statically analyze ESM imports. It maintains maps for: - -- Layout dependencies, source-backed pages using each layout, and generated-page owner layout membership -- TypeScript pages and adjacent page-variable dependencies -- Template dependencies -- Generated-pages module dependencies -- Current esbuild entry points - -The maps are created after the initial build and refreshed after structural or generated-pages rebuilds. Dependency analysis is best-effort. When DOMStack cannot safely determine a targeted scope, it falls back to a broader rebuild or skips an unrelated changed module. - -esbuild tracks browser-entry dependencies independently. Changing a module imported by `client.ts` rebundles that entry without rendering page HTML. - -#### Stable entry filenames - -Watch mode uses stable filenames for esbuild entry outputs: - -```text -[dir]/[name] -``` - -Production builds use content-hashed entry filenames: - -```text -[dir]/[name]-[hash] -``` - -Shared chunks remain content-hashed in both modes: - -```text -chunks/[ext]/[name]-[hash] -``` - -Page HTML points to stable entry files during watch mode. esbuild can update an entry and its chunk imports without requiring DOMStack to render the page again. - -#### Manifest behavior - -Watch mode builds and rebundles the site service worker, but it does not finalize, return, or write the [DOMStack manifest](#domstack-manifest). Changes to `domstack-manifest.settings.ts` therefore do not trigger a watch rebuild. - -Use `domstack --serve` when testing manifest-driven cache behavior. It runs a one-shot build and serves the result without watch-mode filenames or live-reload HTML injection. Add `--domstackManifest` only when the service worker or test needs the public `domstack-manifest.json` file. - -#### Build serialization - -Chokidar events are serialized through a promise chain. Each page rebuild or esbuild restart completes before the next queued filesystem event is processed, preventing overlapping DOMStack rebuilds during rapid saves. - -## Design goals - -DOMStack aims to make building a website feel like working directly with the web platform, with a small set of dependable conventions layered on top. - -### Be simple and dependable - -- Be boring, work well, and make the developer's job easier. -- Prefer convention over configuration. Configuration should be optional and minimal. -- Combine proven tools into one coherent system instead of reimplementing them. -- Avoid clever hacks, speculative abstractions, and complexity that becomes permanent maintenance work. -- Do not over-correct bad input. Clear inputs should produce predictable outputs. - -### Build on the web platform - -- HTML is the source of truth, and strings are the interchange format between rendering tools. -- Let browsers handle links, navigation, documents, and URLs. Do not add magic behavior to `` or `` elements or require client-side routing. -- Treat pages as shallow applications: each page starts as a new document and a blank canvas. Shared client state is possible, but not assumed. -- Remain library-agnostic. A page or layout is a program, so it can use tagged templates, a rendering library, or any other approach that returns the expected output. - -### Make structure visible - -- The source directory structure should mirror the site's URL structure. -- Every page should have an obvious entrypoint and build to an `index.html` in its corresponding directory, enabling clean URLs and reliable relative links. -- Keep pages and their assets colocated. Do not require parallel directory trees with matching structures. -- Support both `page.md` and `README.md` entrypoints. `README.md` keeps a source tree navigable on Git hosts, while `page.md` is available when repository navigation is not a concern. - -### Keep build steps orthogonal - -- Page rendering, static copying, and CSS and JavaScript bundling should remain independent build steps. -- Treat bundling as an optimization over a source tree that stays close to directly runnable web content. -- Keep entry filenames stable and conventional so each build input has an obvious purpose. -- Design independent steps so they can run concurrently when possible and rebuild only the outputs they affect. - -### Use standard language tooling - -- Use standard file types and syntax rather than framework-specific extensions or editor plugins. -- Use real TC39 ESM and prefer standard `.ts` and `.js` modules with `"type": "module"` over compatibility escape hatches. -- Support TypeScript through Node.js type stripping and JavaScript through JSDoc. Leave static type checking to `tsc`. -- Encourage directly runnable source modules. Language servers, formatters, linters, and debuggers should work without understanding a DOMStack-specific language. - -### Prefer durable choices - -- Build for the platform that exists now instead of simulating predicted future standards. -- Benefit from passive improvements to browsers, JavaScript, TypeScript, and Node.js by staying close to their conventions. -- Adopt ecosystem trends only when they solve a concrete problem better than the existing platform. - -## FAQ - -Why DOMStack? - -: DOMStack is named after the [DOM (Document Object Model)](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) and the concept of stacking technologies together to build websites. It represents the layering of HTML, CSS, and JavaScript in a cohesive build system and its emphasis of using what we have rather than inventing brand new ideas or concepts. Also since I had to replace a Wallace and Gromit reference, it could maybe also double as a [cheeky](https://youtu.be/tiJ4ffGZ7cM?t=77) homage to Node's former legend `substack`. - -How does `domstack` relate to [`top-bun`](https://www.npmjs.com/package/top-bun)? - -: `top-bun` is the former name of `domstack` and was named after the bakery in Wallace & Gromit's [A Matter of Loaf and Death 🍞](https://www.youtube.com/watch?v=zXBmZLmfQZ4) which my kids were watching at the time. The project and package were renamed to DOMStack and `@domstack/static` in v11. See the [`top-bun` to DOMStack migration guide](./docs/v11-migration.md) when updating an older project. The `bun` project took off -and hosed the projects chances at SEO! - -How does `domstack` relate to [`sitedown`](https://ghub.io/sitedown) - -: `top-bun` used to be called `siteup` which is sort of like "markup", which is related to "markdown", which inspired the project `sitedown` to which `domstack` is a spiritual off-shoot of. Put a folder of web documents in your `domstack` build system, and generate a website. `domstack` is definitely it's own thing now though! - - -Is this for real? - -: Yes! The frontend space is crowded and brutal, and full of repeat ideas. DOMStack started and will remain as an opensource-for-one project and my goal is to explore ideas that I haven't seen manifest in ways I would like to see elsewhere. -Usage and contribution is encouraged and welcome and appreciated of course. I already consider the project a success for the goals I set out to achieve with it and don't plan to growth hack it at all. - -## Project status - -DOMStack is actively developed and currently available as a v12 prerelease. Its core feature set includes: - -- Markdown, HTML, and TypeScript pages -- Layouts with colocated styles and client bundles -- Global, layout, and page-scoped variables -- Centralized global data processing -- Generated pages and templates -- Static assets and additional copy directories -- Progressive watch rebuilds with dependency tracking -- TypeScript, JavaScript, and client-bundle TSX support -- Page-scoped Web Workers and a site service worker -- The DOMStack build manifest -- A built-in development server powered by [`@domstack/sync`][domstack-sync] - -See the [GitHub roadmap](https://github.com/users/bcomnes/projects/3/) for planned work, or the [changelog](CHANGELOG.md) for completed changes. Issues, ideas, and examples of sites built with DOMStack are welcome. - -## Links - -- [CHANGELOG](CHANGELOG.md) -- [CONTRIBUTING](CONTRIBUTING.md) -- [Dependencies](dependencygraph.svg) -- [fragtml docs][fragtml-docs] - -## License +## License [MIT](LICENSE) - -[htm]: https://github.com/developit/htm -[fragtml]: https://www.npmjs.com/package/fragtml -[fragtml-docs]: https://github.com/bcomnes/fragtml#readme -[preact]: https://preactjs.com/ -[domstack-sync]: https://www.npmjs.com/package/@domstack/sync -[hb]: https://handlebarsjs.com -[esbuild]: http://esbuild.github.io -[neocities-img]: https://img.shields.io/website/https/domstack.neocities.org?label=neocities&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAGhlWElmTU0AKgAAAAgABAEGAAMAAAABAAIAAAESAAMAAAABAAEAAAEoAAMAAAABAAIAAIdpAAQAAAABAAAAPgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAAueefIAAACC2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlBob3RvbWV0cmljSW50ZXJwcmV0YXRpb24+MjwvdGlmZjpQaG90b21ldHJpY0ludGVycHJldGF0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8dGlmZjpDb21wcmVzc2lvbj4xPC90aWZmOkNvbXByZXNzaW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4Kpl32MAAABzBJREFUWAnFVwtwnFUV/v5//31ks5tsE9I8moS0iWETSNKUVpBKDKFQxtrCUIpacHQEGYk16FQHaZ3ajjqjOGWqOKUyMCl2xFoKhQJDBQftpOnAmDZoOyRNjCS1SdO8H5vXPv7rd/7NZvIipQjjmfn23Me555x77rnnv6sppTT8H0n/tG1rmlZIVBG+eW1JBD4t0GA8cYZQcS7ncXL7bFuYPfBJ9mlwtxg3bJoSTvx0tn7LAU48IJNE3GyBj9unrlJC2XRt4vGvLFGGrkXYDxEl03WyDyfRRoiHrxOfiBPU85bovPezi5pHnlmhHq5IsaLAXHhltgPXi+A0VE8X+Dht6lov+uw2rf/8nmIlDjQ+fp1yO/SYnaKYXoOC5QSu8trgddnND7rHv0EvOymwTcbnI867OZ5PLCOKiUIijQgS54nPE3hsfXog2WNY2Z+V5MDXVifjd3/ths/jquL0QyIj9EdC3V6UoLr25KurU73D0ieOEIniKbkc063EduLPRDcR2828/DOpzrbBp0ut3UsEBMe3X2PJuhw2sWHplgjkEViyyBGM93gcf3kkxVP2hNZ1sWfoLg7/jbttJC8jMgiLHHYj4EuIb81I9gQLM92O0iyH+9pUlZSdGDHCJjA0biI/zZ3NxIstsfjKpfFYmROHutYxDwduIo6JAxI6LIq3cSmtpCSg9jF3UsXuix2tHb3L7YZevHRx/FBZvrNzTaEnLTfFQHaSna6CSrghjbVMJzRbtC1KFqC1xT5xAFdnZdxPMcsBS1wpDLHhEoWpiXbj3R8mZ1zoT0Caz677PE4fdDunJYIzd2UtvoKfWwq9+PnRiwgMDd5RX/PGVRIBixLjbNNKpQaP1wO/NzYb47ON0yEzAhUJQjOYJhKFy9DybDcyk+y40DeSdOz5J+5h7CBAxDQdl1k7d5rGHWW74Cz/GdM0gQGSWrMwxTl0VBRSlnSmoblMjIel0zkgN+gKSDFl7G7YMm+C4d8Ix4pvQ4XGPpKC8snQ/vPfvYXiwPuy6tylK3RAFokTpuU/NF8u08dAzbkA/nCylyVeBOanJawJQpcGxjMkB04QdzS0j5ujQVNntZK5BSkwYaIvEEZmQgjm4AeweTOguRah4ZKJdbubeZwKaYl23HptNNQxZeMhE0fqBrDthXZraHTCtKydlF73cFhv67l8FGRnm55sQcGjZ/GTI50IN75kKdMTsywnzMmtj4XmhuDRP13Ag8+2YnA0GrVgWDFmwFld10dN03TXNg2jIMNlKfywn//0BXGyKWBNv904isj5GqjhdmjeJSjMzUDttmUYChpYnS+1ZiY9+IUUrCvxIS/Nic/tbAiOBBkBltoeGn9PRA+c6Jm5Yp5edrIDlWsWw09Ht23IgBrvQ+i9Zy1JcaKE1+zmZTp0c240i7LiwJIPXdPACMnmw9ZriOV2Czu/ES3v7izAdZlx0rw8SQLy/jtu/AEmstfhTP3fcUPRUkS6ziB0eh/M/hZovCkx6ugP4ccvtuO1+gGMMI9IfbGM289j6JSRY/8YEIbmSxM4enoA+2t60MuEm0NyA2xOuL5UDaPgXjQ0NODmW27DgVeOw5a3Dq6Nh2DLWcMnyOjU0v6RME63jloJOjnYZ0VAOozCb8kq4506fG4bOgZCU1fphe/m4osliZNrokwFA3Cs/A7sq6qsgU0bN+LwS9GE9Pv9cLvd8Ofn4Zl7wlC9zXRWSnmUnqvpDVY+1yZ38WgsAjKzX34kNF1DYeQtduLOFT4ceSRvjnFEQrClFMK2/FsIBALYu3evZfw2mxe/Yj1obGzExY4OfPmr98Hu38QCOSGqp+j3tT3RLAZek0SwiMlYxyjIFu6WgX3fzMGNufKonYd49kNGOspLrkdTUxMikQhS4r34tZGDZObEHkccdu3chQ0bNiDc/OoMBQdqe/HOv0aSONhBHJ5yYFLqR+QVoYjyPcT7+mJVLsZ5n988O4gTvHrfX5uKMimjzOJEewhbt25FZ2cnWlpaUF1djdcTR1A6NoH24BiC/E4IKSaiyMuX9OVT/Xh4f5tkn0R+Czc9MOdZzokHLGmuiLPr8qqViqKchqYObcmNvnCeLlajz9+uzGCAOpTiNVabN2+25ETWMAxVV1enzPEBS254X5GqWpsmHwqRkfP4OpdF8y/WmM4psJ3HIVuYMr7n/qwZz6uRp/xq4uQvuSxK4sTBgwfVjh07VH19veInWnW9+j11uDJdlebEj0zqaiC/gSum/gxN3QJOzCA6sIIDv2D0KlhdrWS9Jt2F9aU+FKQ7eeYKi3kaSaur4C29j98lE4P9XWg59z5OnXgDb7/1pvlOY7c5EbYKjug+RFTSeJ90pmi6N/O1KbiKeIqOtJFPhXl6m87OGae8hPoU8SSxaj7dMvahEeCiGUQjcm/LiHLCT8hbUsaGCKk2wqWWNxHykD1LA13kC9JHdmBBLf/D5H8By9d+IkwR5NMAAAAASUVORK5CYII= diff --git a/bin.js b/bin.js index b2d99a01..51dd27da 100755 --- a/bin.js +++ b/bin.js @@ -83,6 +83,10 @@ const options = { type: 'boolean', help: 'watch and build the src folder without serving', }, + verbose: { + type: 'boolean', + help: 'show debug logs, including the build tree and individual copy operations', + }, serve: { type: 'boolean', help: 'build once and serve the destination directory without watching', @@ -222,7 +226,7 @@ domstack eject actions: opts.copy = copyPaths.map(p => resolve(cwd, p)) } - const logger = createDomStackLogger() + const logger = createDomStackLogger(argv['verbose'] ? 'debug' : 'info') opts.logger = logger const domStack = new DomStack(src, dest, opts) /** @type {BsInstance | null} */ @@ -256,8 +260,9 @@ domstack eject actions: if (!argv['watch'] && !argv['watch-only']) { try { const results = await domStack.build() - logger.info(tree(generateTreeData(cwd, src, dest, results))) + logger.debug(tree(generateTreeData(cwd, src, dest, results))) logWarnings(logger, results?.warnings) + logger.info(`Built ${relative(cwd, src) || '.'} β†’ ${relative(cwd, dest) || '.'}`) logger.info('\nBuild Success!\n\n') if (argv['serve']) { buildServer = await createServer({ @@ -277,7 +282,7 @@ domstack eject actions: } } if ('results' in err) delete err.results - logger.error(inspect(err, { depth: 999, colors: true })) + logger.error(formatDiagnostic(err, Boolean(process.stdout.isTTY))) logger.error('\nBuild Failed!\n\n') process.exit(1) } @@ -285,7 +290,7 @@ domstack eject actions: await domStack.watch({ serve: !argv['watch-only'], onInitialBuild: (initialResults) => { - logger.info(tree(generateTreeData(cwd, src, dest, initialResults))) + logger.debug(tree(generateTreeData(cwd, src, dest, initialResults))) logWarnings(logger, initialResults?.warnings) }, }) @@ -315,12 +320,29 @@ function logWarnings (logger, warnings) { if ('message' in warning) { logger.warn(` ${warning.message}`) } else { - logger.warn(inspect(warning, { depth: 999, colors: true })) + logger.warn(formatDiagnostic(warning, Boolean(process.stdout.isTTY))) } } } +/** + * Keep nested causes, locations, and every diagnostic visible in CLI output. + * @param {unknown} value + * @param {boolean} colors + */ +function formatDiagnostic (value, colors) { + return inspect(value, { + depth: null, + maxArrayLength: null, + maxStringLength: null, + colors, + }) +} + run().catch(err => { - console.error(new Error('Unhandled domstack error', { cause: err })) + console.error(formatDiagnostic( + new Error('Unhandled domstack error', { cause: err }), + Boolean(process.stderr.isTTY) + )) process.exit(1) }) diff --git a/browser-tests/docs-diagrams.spec.js b/browser-tests/docs-diagrams.spec.js new file mode 100644 index 00000000..7022ad7c --- /dev/null +++ b/browser-tests/docs-diagrams.spec.js @@ -0,0 +1,21 @@ +import { resolve } from 'node:path' +import { expect, test, websiteOptions } from './support.js' + +test.use({ + siteSrc: resolve(import.meta.dirname, '..'), + siteOptions: websiteOptions, +}) + +test('the built documentation loads Mermaid and renders diagrams without errors', async ({ page, siteURL }) => { + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto(`${siteURL}/docs/implementation/`) + const diagrams = page.locator('.mermaid') + await expect(diagrams.first()).toBeVisible() + for (const diagram of await diagrams.all()) { + await expect(diagram.locator('svg')).toBeVisible() + // Mermaid may render an error SVG without throwing a JavaScript error. + await expect(diagram.locator('.error-icon')).toHaveCount(0) + } + expect(errors).toEqual([]) +}) diff --git a/browser-tests/docs-navigation.spec.js b/browser-tests/docs-navigation.spec.js new file mode 100644 index 00000000..dacdf2b6 --- /dev/null +++ b/browser-tests/docs-navigation.spec.js @@ -0,0 +1,163 @@ +import { resolve } from 'node:path' +import { expect, test, websiteOptions } from './support.js' + +test.use({ + siteSrc: resolve(import.meta.dirname, '..'), + siteOptions: websiteOptions, +}) + +test('an index section link navigates and selects the matching sidebar entry', async ({ page, siteURL }) => { + await page.setViewportSize({ width: 1500, height: 900 }) + await page.goto(`${siteURL}/docs/`) + await page.locator('.docs-index a[href="layouts/#declaring-nested-layouts"]').click() + await expect(page).toHaveURL(`${siteURL}/docs/layouts/#declaring-nested-layouts`) + const nav = page.getByRole('navigation', { name: 'Documentation', exact: true }) + await expect(nav.locator('a[aria-current="page"]')).toHaveText('Layouts') + await expect(nav.locator('a[aria-current="location"]')).toHaveText('Declaring nested layouts') +}) + +test('disclosure arrows animate around a stable center and respect reduced motion', async ({ page, siteURL }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await page.setViewportSize({ width: 1500, height: 900 }) + await page.goto(`${siteURL}/docs/layouts/`) + const summary = page.locator('.docs-navigation summary').filter({ has: page.getByRole('link', { name: 'CLI', exact: true }) }) + const arrowStyle = () => summary.locator('.docs-navigation-chevron').evaluate(element => { + const style = getComputedStyle(element) + return { + width: parseFloat(style.width), + height: parseFloat(style.height), + origin: style.transformOrigin, + transform: style.transform, + duration: style.transitionDuration, + + } + }) + const closed = await arrowStyle() + expect(closed.duration).toBe('0.2s') + expect(closed.width).toBe(16) + expect(closed.height).toBe(16) + const bounds = await summary.boundingBox() + await summary.click({ position: { x: bounds.width - 16, y: bounds.height / 2 } }) + await expect(summary.locator('..')).toHaveAttribute('open', '') + await expect.poll(async () => (await arrowStyle()).transform).not.toBe(closed.transform) + const opened = await arrowStyle() + expect(opened.width).toBe(closed.width) + expect(opened.height).toBe(closed.height) + expect(opened.origin).toBe(closed.origin) + expect(opened.transform).not.toBe(closed.transform) + + await page.emulateMedia({ reducedMotion: 'reduce' }) + await expect.poll(async () => (await arrowStyle()).duration).toBe('0s') +}) + +test('global bundles and cookbook subpages are linked from the documentation', async ({ page, siteURL }) => { + await page.setViewportSize({ width: 1500, height: 900 }) + await page.goto(`${siteURL}/docs/`) + await page.locator('.docs-index a[href="global-bundles/"]').click() + await expect(page.locator('.docs-content > h1')).toHaveText('Global bundles') + await expect(page.locator('.docs-navigation a[aria-current="page"]')).toHaveText('Global bundles') + + await page.goto(`${siteURL}/docs/cookbook/`) + const recipes = await page.locator('.docs-content > ul > li > a').evaluateAll(links => links.map(link => ({ + href: link.href, + title: link.textContent, + }))) + expect(recipes).toHaveLength(4) + for (const recipe of recipes) { + const response = await page.goto(recipe.href) + expect(response.status()).toBe(200) + await expect(page.locator('.docs-content > h1')).toHaveText(recipe.title) + await expect(page.locator('.docs-navigation a[aria-current="page"]')).toHaveText(recipe.title) + await expect(page.locator('.docs-navigation nav > ul > li > details[open] > summary')).toHaveText('Recipes') + await expect(page.locator('.docs-navigation nav > ul > li > details[open] > ul > li > a[aria-current="page"]')).toHaveText(recipe.title) + await page.getByRole('link', { name: 'All recipes', exact: true }).click() + await expect(page).toHaveURL(`${siteURL}/docs/cookbook/`) + } +}) + +test('mobile navigation manages focus, section links, and return to desktop', async ({ page, siteURL }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${siteURL}/docs/layouts/`) + const toggle = page.getByRole('button', { name: 'Open documentation menu' }) + const menu = page.getByRole('dialog', { name: 'Documentation', exact: true }) + const close = menu.getByRole('button', { name: 'Close documentation menu' }) + await toggle.focus() + await page.keyboard.press('Enter') + await expect(menu).toBeVisible() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(close).toBeFocused() + // Opening the modal must prevent focus from escaping into the page. + await toggle.evaluate(el => el.focus()) + await expect(close).toBeFocused() + await page.keyboard.press('Escape') + await expect(menu).toBeHidden() + await expect(toggle).toBeFocused() + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await toggle.click() + await menu.getByRole('link', { name: 'Layout module exports', exact: true }).click() + await expect(menu).toBeHidden() + await expect(page).toHaveURL(`${siteURL}/docs/layouts/#layout-module-exports`) + await expect(page.locator('#layout-module-exports')).toBeFocused() + expect(await page.locator('html').evaluate(el => getComputedStyle(el).overflow)).not.toBe('hidden') + + // Resizing an open menu must restore navigation without leaving a modal or + // scroll lock behind. The restored links must still work. + await toggle.click() + await page.setViewportSize({ width: 1500, height: 900 }) + await expect(menu).toBeHidden() + await expect(toggle).toBeHidden() + const nav = page.getByRole('navigation', { name: 'Documentation', exact: true }) + await expect(nav).toBeVisible() + await expect(nav).toHaveCount(1) + expect(await page.locator('html').evaluate(el => getComputedStyle(el).overflow)).not.toBe('hidden') + await nav.getByRole('link', { name: 'CLI', exact: true }).click() + await expect(page).toHaveURL(`${siteURL}/docs/cli/`) +}) + +test('migration links support pointer navigation', async ({ page, siteURL }) => { + await page.goto(`${siteURL}/docs/migrations/`) + for (const version of ['v12', 'v11']) { + await page.locator('.docs-content > ul').getByRole('link', { name: `${version} migration`, exact: true }).click() + await expect(page).toHaveURL(`${siteURL}/docs/migrations/${version}-migration.html`) + await page.getByRole('link', { name: 'All migrations', exact: true }).click() + await expect(page).toHaveURL(`${siteURL}/docs/migrations/`) + } +}) + +test.describe('without JavaScript', () => { + test.use({ javaScriptEnabled: false }) + + test('migration guides are nested and linked through the migrations page', async ({ page, siteURL }) => { + await page.setViewportSize({ width: 1500, height: 900 }) + await page.goto(`${siteURL}/docs/`) + await page.locator('.docs-index > ul > li > a[href="migrations/"]').focus() + await page.keyboard.press('Enter') + await expect(page.locator('.docs-content > h1')).toHaveText('Migrations') + for (const version of ['v12', 'v11']) { + await page.locator('.docs-content > ul').getByRole('link', { name: `${version} migration`, exact: true }).focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(`${siteURL}/docs/migrations/${version}-migration.html`) + await expect(page.locator('.docs-content > h1')).toHaveText(`${version} migration`) + await expect(page.locator('.docs-navigation a[aria-current="page"]')).toHaveText(`${version} migration`) + await expect(page.locator('.docs-navigation nav details[open] > summary')).toHaveText('Migrations') + await expect(page.locator('.docs-breadcrumb').getByRole('link', { name: 'migrations', exact: true })).toHaveAttribute('href', './') + await page.getByRole('link', { name: 'All migrations', exact: true }).focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(`${siteURL}/docs/migrations/`) + } + }) + + test('the server-rendered index and mobile navigation remain usable', async ({ page, siteURL }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${siteURL}/docs/`) + await page.locator('.docs-index a[href="layouts/#declaring-nested-layouts"]').click() + await expect(page).toHaveURL(`${siteURL}/docs/layouts/#declaring-nested-layouts`) + await expect(page.getByRole('button', { name: 'Open documentation menu' })).toBeHidden() + const nav = page.locator('.docs-navigation') + await expect(nav).toBeVisible() + await nav.getByRole('link', { name: 'CLI', exact: true }).focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(`${siteURL}/docs/cli/`) + }) +}) diff --git a/browser-tests/docs-scroll.spec.js b/browser-tests/docs-scroll.spec.js new file mode 100644 index 00000000..f07b510f --- /dev/null +++ b/browser-tests/docs-scroll.spec.js @@ -0,0 +1,196 @@ +import { resolve } from 'node:path' +import { expect, test, websiteOptions } from './support.js' + +test.use({ + siteSrc: resolve(import.meta.dirname, '..'), + siteOptions: websiteOptions, +}) + +// Wait for native smooth scrolling as well as browsers that restore instantly. +async function waitForScrollToSettle (page) { + await page.evaluate(() => new Promise(resolve => { + let timer + const finish = () => { + removeEventListener('scroll', reset) + resolve() + } + const reset = () => { + clearTimeout(timer) + timer = setTimeout(finish, 200) + } + addEventListener('scroll', reset, { passive: true }) + reset() + })) +} + +test('the breadcrumb spans only the docs column and keeps its text aligned while sticky', async ({ page, siteURL }) => { + await page.goto(`${siteURL}/docs/pages/`) + for (const width of [390, 1100, 1500, 2000]) { + await page.setViewportSize({ width, height: 900 }) + for (const top of [0, 900]) { + await page.evaluate(top => scrollTo({ top, behavior: 'instant' }), top) + const geometry = await page.evaluate(() => { + const shell = document.querySelector('.docs-shell').getBoundingClientRect() + const content = document.querySelector('.docs-content') + const style = getComputedStyle(content) + const bounds = content.getBoundingClientRect() + const breadcrumb = document.querySelector('.docs-breadcrumb').getBoundingClientRect() + const text = document.querySelector('.docs-breadcrumb ol').getBoundingClientRect() + return { + left: breadcrumb.left, + expectedLeft: bounds.left + parseFloat(style.borderLeftWidth), + right: breadcrumb.right, + expectedRight: shell.right, + textLeft: text.left, + expectedTextLeft: bounds.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft), + top: breadcrumb.top, + stickyTop: parseFloat(getComputedStyle(document.querySelector('.docs-breadcrumb')).top), + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + } + }) + expect(Math.abs(geometry.left - geometry.expectedLeft)).toBeLessThan(1) + expect(Math.abs(geometry.right - geometry.expectedRight)).toBeLessThan(1) + expect(Math.abs(geometry.textLeft - geometry.expectedTextLeft)).toBeLessThan(1) + expect(geometry.overflow).toBe(false) + if (top) expect(Math.abs(geometry.top - geometry.stickyTop)).toBeLessThan(1) + } + } +}) + +test('reading position updates the URL, sidebar, and breadcrumb without navigating', async ({ page, siteURL }) => { + test.setTimeout(30_000) + await page.setViewportSize({ width: 1500, height: 900 }) + const base = `${siteURL}/docs/pages/?reading=test` + await page.goto(`${base}#page-styles`) + await page.evaluate(async () => { + await document.fonts.ready + }) + await waitForScrollToSettle(page) + await page.evaluate(() => { + history.replaceState({ preserved: true }, '', location.href) + document.getElementById('docs-content').focus({ preventScroll: true }) + }) + const length = await page.evaluate(() => history.length) + const section = page.locator('.docs-navigation a[aria-current="location"]') + const breadcrumb = page.locator('.docs-breadcrumb-section a') + await expect(section).toHaveText('Page Styles') + await expect(breadcrumb).toHaveText('Page Styles') + await expect(breadcrumb).toHaveAttribute('href', '#page-styles') + await expect(breadcrumb).toHaveAttribute('aria-current', 'location') + await expect(page.locator('.docs-breadcrumb [aria-current]')).toHaveCount(1) + const itemStyles = await page.locator('.docs-breadcrumb li > a').evaluateAll(links => links.map(link => { + const style = getComputedStyle(link) + const bounds = link.getBoundingClientRect() + return { + color: style.color, + font: style.font, + decoration: style.textDecorationLine, + top: bounds.top, + inset: bounds.left - link.parentElement.getBoundingClientRect().left, + } + })) + const original = itemStyles[1] + const added = itemStyles.at(-1) + expect(added.color).toBe(original.color) + expect(added.font).toBe(original.font) + expect(added.decoration).toBe(original.decoration) + expect(added.top).toBeCloseTo(original.top, 1) + expect(added.inset).toBeCloseTo(original.inset, 1) + + const scrollToHeading = async locator => { + return locator.evaluate(heading => { + const offset = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) + scrollTo({ top: scrollY + heading.getBoundingClientRect().top - offset + 2, behavior: 'instant' }) + return scrollY + }) + } + const y = await scrollToHeading(page.locator('#page-client-bundles')) + await expect(page).toHaveURL(`${base}#page-client-bundles`) + await expect(section).toHaveText('Page client bundles') + await expect(breadcrumb).toHaveText('Page client bundles') + expect(await page.evaluate(() => ({ + y: scrollY, length: history.length, state: history.state, focus: document.activeElement.id, + }))).toEqual({ y, length, state: { preserved: true }, focus: 'docs-content' }) + + // A real wheel scroll across the boundary also selects the preceding section. + await page.mouse.move(1400, 400) + await page.mouse.wheel(0, -100) + await expect(page).toHaveURL(`${base}#page-styles`) + await expect(section).toHaveText('Page Styles') + + // h4 has a URL of its own but keeps the parent h3 selected in the shared ToC. + const deeper = page.locator('#docs-content h4').filter({ hasText: '.tsx' }) + const id = await deeper.getAttribute('id') + await scrollToHeading(deeper) + await expect(page).toHaveURL(`${base}#${encodeURIComponent(id)}`) + await expect(section).toHaveText('Page client bundles') + await expect(breadcrumb).toHaveText('.tsx') + await expect(breadcrumb).toHaveAttribute('href', `#${encodeURIComponent(id)}`) + + // Scrolling upwards selects the preceding section, then clears the fragment. + await scrollToHeading(page.locator('#page-styles')) + await expect(page).toHaveURL(`${base}#page-styles`) + await expect(section).toHaveText('Page Styles') + await page.evaluate(() => scrollTo({ top: 0, behavior: 'instant' })) + await expect(page).toHaveURL(base) + await expect(section).toHaveCount(0) + await expect(breadcrumb).toHaveCount(0) + await expect(page.locator('.docs-breadcrumb [aria-current="page"]')).toHaveText('pages') + expect(await page.evaluate(() => history.length)).toBe(length) +}) + +test('long breadcrumb headings are safe text and truncate on mobile without growing the bar', async ({ page, siteURL }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`${siteURL}/docs/pages/`) + const bar = page.locator('.docs-breadcrumb') + const initialHeight = (await bar.boundingBox()).height + const title = 'A long section heading with markup, detailed explanations, and many additional words' + await page.locator('#page-styles').evaluate((heading, title) => { + heading.textContent = title + location.hash = heading.id + }, title) + const breadcrumb = page.locator('.docs-breadcrumb-section a') + await expect(breadcrumb).toHaveText(title) + await expect(breadcrumb).toHaveAttribute('title', title) + await expect(breadcrumb.locator('em')).toHaveCount(0) + expect(await breadcrumb.evaluate(link => link.scrollWidth > link.clientWidth)).toBe(true) + expect((await bar.boundingBox()).height).toBeCloseTo(initialHeight, 1) + expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)).toBe(false) +}) + +test('explicit anchors and Back/Forward win over pending reading-position updates', async ({ page, siteURL }) => { + test.setTimeout(30_000) + await page.goto(`${siteURL}/docs/layouts/#declaring-nested-layouts`) + await page.evaluate(async () => { + await document.fonts.ready + }) + await waitForScrollToSettle(page) + const first = page.url() + const breadcrumb = page.locator('.docs-breadcrumb-section a') + await expect(breadcrumb).toHaveText('Declaring nested layouts') + // Schedule a reading-position update, then navigate before its timer fires. + await page.locator('#layout-variables').evaluate(async heading => { + heading.scrollIntoView({ behavior: 'instant' }) + await new Promise(resolve => requestAnimationFrame(resolve)) + }) + // A link near the bottom cannot necessarily align its heading with the top. + await page.locator('.docs-navigation a[href="./#custom-layout-renderers"]').click() + const second = `${siteURL}/docs/layouts/#custom-layout-renderers` + await expect(page).toHaveURL(second) + await expect(breadcrumb).toHaveText('Custom layout renderers') + await waitForScrollToSettle(page) + await page.waitForTimeout(450) + await expect(page).toHaveURL(second) + await page.goBack() + await expect(page).toHaveURL(first) + await expect(breadcrumb).toHaveText('Declaring nested layouts') + await waitForScrollToSettle(page) + await page.waitForTimeout(450) + await expect(page).toHaveURL(first) + await page.goForward() + await expect(page).toHaveURL(second) + await expect(breadcrumb).toHaveText('Custom layout renderers') + await waitForScrollToSettle(page) + await page.waitForTimeout(450) + await expect(page).toHaveURL(second) +}) diff --git a/browser-tests/support.js b/browser-tests/support.js index 8fd7777d..44be3247 100644 --- a/browser-tests/support.js +++ b/browser-tests/support.js @@ -4,6 +4,12 @@ import { createServer } from 'node:http' import { extname, resolve, sep } from 'node:path' import { testBuild } from '../index.js' +// Exercise the website's real exclusions, rather than maintaining a second list. +const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) +export const websiteOptions = { + ignore: pkg.scripts['build:domstack'].match(/--ignore (\S+)/)[1].split(','), +} + const fixtureSrc = resolve(import.meta.dirname, '../test-cases/general-features/src') const contentTypes = new Map([ ['.css', 'text/css; charset=utf-8'], @@ -13,8 +19,10 @@ const contentTypes = new Map([ ]) export const test = base.extend({ - siteURL: async ({ context }, use) => { - const build = await testBuild(fixtureSrc) + siteSrc: [fixtureSrc, { option: true }], + siteOptions: [{}, { option: true }], + siteURL: async ({ context, siteSrc, siteOptions }, use) => { + const build = await testBuild(siteSrc, siteOptions) const publicDir = build.dest const server = createServer(async (request, response) => { try { diff --git a/declaration.tsconfig.json b/declaration.tsconfig.json index 95ed7a6f..16032129 100644 --- a/declaration.tsconfig.json +++ b/declaration.tsconfig.json @@ -9,5 +9,6 @@ "exclude": [ "**/*.test.js", "test-cases/**/*", + "site/**/*", ] } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..d76d7c0d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +--- +layout: docs +dataDeps: + - docsIndexHtml +--- + +# Documentation + +[DOMStack](../) builds websites from ordinary HTML, Markdown, CSS, and JavaScript files. +Start with [Core concepts](../#core-concepts), then follow [Installation and first build](../#installation-and-first-build). +Use these references as your project grows. + + +{{{ data.docsIndexHtml }}} diff --git a/docs/about/README.md b/docs/about/README.md new file mode 100644 index 00000000..af9bb3e1 --- /dev/null +++ b/docs/about/README.md @@ -0,0 +1,122 @@ +--- +layout: docs +docsOrder: 150 +handlebars: false +--- + +# About + +DOMStack is a static site builder centered on ordinary files, standard web technologies, and a small set of conventions. +These design goals explain its approach and the tradeoffs behind its features. + +## Table of Contents + +[[toc]] + +## Design goals + +DOMStack aims to make building a website feel like working directly with the web platform, with a small set of dependable conventions layered on top. + +### Be simple and dependable + +- Be boring, work well, and make the developer's job easier. +- Prefer convention over configuration. + Configuration should be optional and minimal. +- Combine proven tools into one coherent system instead of reimplementing them. +- Avoid clever hacks, speculative abstractions, and complexity that becomes permanent maintenance work. +- Do not over-correct bad input. + Clear inputs should produce predictable outputs. + +### Build on the web platform + +- HTML is the source of truth, and strings are the interchange format between rendering tools. +- Let browsers handle links, navigation, documents, and URLs. + Do not add magic behavior to `` or `` elements or require client-side routing. +- Treat pages as shallow applications: each page starts as a new document and a blank canvas. + Shared client state is possible, but not assumed. +- Remain library-agnostic. + A page or layout is a program, so it can use tagged templates, a rendering library, or any other approach that returns the expected output. + +### Make structure visible + +- The source directory structure should mirror the site's URL structure. +- Every page should have an obvious entrypoint and build to an `index.html` in its corresponding directory, enabling clean URLs and reliable relative links. +- Keep pages and their assets colocated. + Do not require parallel directory trees with matching structures. +- Support both `page.md` and `README.md` entrypoints. + `README.md` keeps a source tree navigable on Git hosts, while `page.md` is available when repository navigation is not a concern. + +### Keep build steps orthogonal + +- Page rendering, static copying, and CSS and JavaScript bundling should remain independent build steps. +- Treat bundling as an optimization over a source tree that stays close to directly runnable web content. +- Keep entry filenames stable and conventional so each build input has an obvious purpose. +- Design independent steps so they can run concurrently when possible and rebuild only the outputs they affect. + +### Use standard language tooling + +- Use standard file types and syntax rather than framework-specific extensions or editor plugins. +- Use real TC39 ESM and prefer standard `.ts` and `.js` modules with `"type": "module"` over compatibility escape hatches. +- Support TypeScript through Node.js type stripping and JavaScript through JSDoc. + Leave static type checking to `tsc`. +- Encourage directly runnable source modules. + Language servers, formatters, linters, and debuggers should work without understanding a DOMStack-specific language. + +### Prefer durable choices + +- Build for the platform that exists now instead of simulating predicted future standards. +- Benefit from passive improvements to browsers, JavaScript, TypeScript, and Node.js by staying close to their conventions. +- Adopt ecosystem trends only when they solve a concrete problem better than the existing platform. + +## FAQ + +Why DOMStack? + +: DOMStack is named after the [DOM (Document Object Model)](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) and the concept of stacking technologies together to build websites. +It represents the layering of HTML, CSS, and JavaScript in a cohesive build system and its emphasis of using what we have rather than inventing brand new ideas or concepts. +Also since I had to replace a Wallace and Gromit reference, it could maybe also double as a [cheeky](https://youtu.be/tiJ4ffGZ7cM?t=77) homage to Node's former legend `substack`. + +How does `domstack` relate to [`top-bun`](https://www.npmjs.com/package/top-bun)? + +: `top-bun` is the former name of `domstack` and was named after the bakery in Wallace & Gromit's [A Matter of Loaf and Death 🍞](https://www.youtube.com/watch?v=zXBmZLmfQZ4) which my kids were watching at the time. +The project and package were renamed to DOMStack and `@domstack/static` in v11. +See the [`top-bun` to DOMStack migration guide](../migrations/v11-migration.md) when updating an older project. +The `bun` project took off +and hosed the projects chances at SEO! + +How does `domstack` relate to [`sitedown`](https://ghub.io/sitedown) + +: `top-bun` used to be called `siteup` which is sort of like "markup", which is related to "markdown", which inspired the project `sitedown` to which `domstack` is a spiritual off-shoot of. +Put a folder of web documents in your `domstack` build system, and generate a website. +`domstack` is definitely it's own thing now though! + + +Is this for real? + +: Yes! +The frontend space is crowded and brutal, and full of repeat ideas. +DOMStack started and will remain as an opensource-for-one project and my goal is to explore ideas that I haven't seen manifest in ways I would like to see elsewhere. +Usage and contribution is encouraged and welcome and appreciated of course. +I already consider the project a success for the goals I set out to achieve with it and don't plan to growth hack it at all. + +## Project status + +DOMStack is actively developed and currently available as a v12 prerelease. +Its core feature set includes: + +- Markdown, HTML, and TypeScript pages +- Layouts with colocated styles and client bundles +- Global, layout, and page-scoped variables +- Centralized global data processing +- Generated pages and templates +- Static assets and additional copy directories +- Progressive watch rebuilds with dependency tracking +- TypeScript, JavaScript, and client-bundle TSX support +- Page-scoped Web Workers and a site service worker +- The DOMStack build manifest +- A built-in development server powered by [`@domstack/sync`][domstack-sync] + +See the [GitHub roadmap](https://github.com/users/bcomnes/projects/3/) for planned work, or the [changelog](../../CHANGELOG.md) for completed changes. +Issues, ideas, and examples of sites built with DOMStack are welcome. + +[domstack-sync]: https://www.npmjs.com/package/@domstack/sync diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 00000000..fae661a4 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,78 @@ +--- +layout: docs +docsOrder: 120 +handlebars: false +--- + +# API + +Import `DomStack` from `@domstack/static` to build sites from your own Node.js scripts. +For automated tests, `testBuild()` provides a temporary output directory and a cleanup helper. + +## Table of Contents + +[[toc]] + +## Programmatic builds + +Use the named `DomStack` export for a one-shot build with an explicit source directory, destination directory, and optional build options. +With your site in `./src` and additional files to copy in `./static`, save this as `build.mjs` and run `node build.mjs` from your project directory: + +```js +/** @import { DomStackOpts } from '@domstack/static/types.js' */ +import { DomStack } from '@domstack/static' + +/** @type {DomStackOpts} */ +const options = { + copy: ['./static'], +} + +try { + const site = new DomStack('./src', './public', options) + const results = await site.build() + + for (const warning of results.warnings) { + console.warn(warning) + } + + console.log('Built site in ./public') +} catch (error) { + console.error(error) + process.exitCode = 1 +} +``` + +`new DomStack(src, dest, opts)` accepts filesystem path strings, with relative paths resolved from the current working directory, including `opts.copy` paths. +`build()` creates the destination as needed, writes the site output, and resolves with a `Results` object containing discovery data, build-step results, and warnings. +Build failures reject the promise, so the example reports the error and sets a nonzero exit code. +The `DomStackOpts` and `Results` types are available from the type-only `@domstack/static/types.js` entry. + +## Test builds + +Use the top-level `testBuild` helper to build into a temporary directory from tests without managing setup and cleanup yourself. + +```js +import { test } from 'node:test' +import assert from 'node:assert' +import { testBuild } from '@domstack/static' + +test('site output', async () => { + const build = await testBuild('./src') + + try { + const html = await build.readOutput('index.html') + assert.match(html, /Hello/) + } finally { + await build.cleanup() + } +}) +``` + +`testBuild(src, opts)` creates a temporary destination directory, runs `new DomStack(src, dest, opts).build()`, and returns `{ dest, results, readOutput, cleanup }`. +Options are passed through to `DomStack`, including `copy` paths. + +See these repository tests for complete usage: + +- [`test-build-helper/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/test-build-helper/index.test.js) tests temporary output, `readOutput()`, copied directories, and cleanup. +- [`default-layout/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/default-layout/index.test.js) uses `testBuild()` for a focused output assertion. +- [`generated-pages/index.test.js`](https://github.com/bcomnes/domstack/blob/master/test-cases/generated-pages/index.test.js) uses it with generated pages, global data, and templates. diff --git a/docs/assets/README.md b/docs/assets/README.md new file mode 100644 index 00000000..24fb878c --- /dev/null +++ b/docs/assets/README.md @@ -0,0 +1,74 @@ +--- +layout: docs +docsOrder: 50 +handlebars: false +--- + +# Static assets + +DOMStack copies static assets and explicitly included directories into your site without bundling or rendering them. +Browser JavaScript and CSS are separate build inputs: see [Global bundles](../global-bundles/), [page assets](../pages/#page-styles), and [layout assets](../layouts/#layout-styles). +Build-tool configuration and site-wide variables are documented separately in [Settings](../settings/). + +## Table of Contents + +[[toc]] + +## Static assets + +All static assets in the `src` directory are copied 1:1 to the destination directory using [cpx2](https://github.com/bcomnes/cpx2). +Files ending in `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.css`, `.html`, or `.md` are reserved for DOMStack processing and are not copied as static assets. + +### `--copy` directories + +You can specify directories to copy into your `dest` directory using the `--copy` flag. +Everything in those directories will be copied as-is into the destination, including js, css, html and markdown, preserving the internal directory structure. + +> [!NOTE] +> `--copy` intentionally accepts directories, not individual files. +Place a file in a directory whose structure encodes its desired destination path. +To copy multiple directories, repeat the flag: `domstack --copy oldsite --copy archived-docs`. + +> [!WARNING] +> DOMStack does not detect conflicts between copied directories and other build output. +If multiple inputs produce the same destination path, the result is undefined. + +Copy folders must live **outside** of the `dest` directory. +Copy directories can be in the src directory allowing for nested builds. +In this case they are added to the ignore glob and ignored by the rest of `domstack`. + +> [!NOTE] +> When using the programmatic `DomStack` constructor, `copy` entries may be relative or absolute paths. +Relative paths are resolved from the current working directory, matching the CLI `--copy` behavior, before being stored in `domstack.opts.copy` and passed to the copy build step. +> +> ```typescript +> const site = new DomStack('src', 'public', { +> copy: ['./legacy-site', '/srv/shared-docs'], +> }) +> ``` + +The intention of this feature is to include legacy or archived site content without asking DOMStack to process or modify it. +In general, static content should live in your primary `src` directory, but keeping older content in a separate, unprocessed directory can make it easier to merge into the final build. + +For example: + +``` +src/... +oldsite/ +β”œβ”€β”€ client.js +β”œβ”€β”€ hello.html +└── styles/ + └── globals.css +``` + +After build: + +``` +src/... +oldsite/... +public/ +β”œβ”€β”€ client.js +β”œβ”€β”€ hello.html +└── styles/ + └── globals.css +``` diff --git a/docs/cli/README.md b/docs/cli/README.md new file mode 100644 index 00000000..6d785e85 --- /dev/null +++ b/docs/cli/README.md @@ -0,0 +1,78 @@ +--- +layout: docs +docsOrder: 10 +handlebars: false +--- + +# CLI + +Use `domstack` (or its shorter alias, `dom`) to build a site, watch for changes, or preview production output. +The options below control source and destination directories, asset copying, and the development server. + +## Table of Contents + +[[toc]] + +## Usage + +```console +$ domstack --help +Usage: domstack [options] + + Example: domstack --src website --dest public + + --src, -s path to source directory (default: "src") + --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 + --verbose show debug logs, including the build tree and individual copy operations + --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 +domstack (v12.0.0) +``` + +`domstack` builds a `src` directory into a `dest` directory (default: `public`). + +Normal output summarizes builds, static asset startup, and server URLs. +Use `--verbose` to include the build tree and individual copy operations. +Build failures retain their full diagnostics at either verbosity level. + +- 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 --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 a devtool. +It's 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. +It can be used outside of this context, but it works best within it. + +## Ejecting the defaults + +The `--eject` (or `-e`) flag extracts DOMStack's default layout, global CSS, and client-side JavaScript into your source directory. +This allows you to fully customize these files while maintaining the same functionality. + +When you run `domstack --eject`, it will: + +1. + Create a default root layout file at `layouts/root.layout.js` (or `.mjs` depending on your package.json type) +2. + Create a default global CSS file at `globals/global.css` +3. + Create a default client-side JavaScript file at `globals/global.client.js` +4. + Add the necessary dependencies to your package.json: + - mine.css + - fragtml + - highlight.js + +It is recommended to eject early in your project so that you can customize the root layout as you see fit, and decouple yourself from potential unwanted changes in the default layout as new versions of DOMStack are released. + +[domstack-sync]: https://www.npmjs.com/package/@domstack/sync diff --git a/docs/cookbook/README.md b/docs/cookbook/README.md new file mode 100644 index 00000000..edde149c --- /dev/null +++ b/docs/cookbook/README.md @@ -0,0 +1,15 @@ +--- +layout: docs +docsOrder: 130 +handlebars: false +--- + +# Recipes + +These recipes combine DOMStack features to solve common site-building tasks. +Use them as starting points for nested layouts, feeds, archive pages, and redirects. + +- [Compose nested layouts](./nested-layouts/) with `parentLayout` declarations, focused data subscriptions, and inherited assets. +- [Generate RSS and JSON feeds](./feeds/) from the ten most recent blog pages. +- [Generate yearly blog index pages](./yearly-blog-indexes/) from date-sorted source pages. +- [Generate redirect pages from page metadata](./redirect-pages/) to preserve old URLs. diff --git a/docs/cookbook/feeds/README.md b/docs/cookbook/feeds/README.md new file mode 100644 index 00000000..463b8f97 --- /dev/null +++ b/docs/cookbook/feeds/README.md @@ -0,0 +1,129 @@ +--- +layout: docs +docsOrder: 132 +docsParent: /docs/cookbook/ +handlebars: false +--- + +# Generate RSS and JSON feeds + +[All recipes](../) + +Use `global.data.ts` to inspect and render source pages, then let a feed template subscribe to the prepared records. + +The following example generates an [RSS](https://www.rssboard.org) and [JSON Feed](https://www.jsonfeed.org) from the 10 most recent date-sorted pages using the `blog` layout and the AsyncIterator template type. +It uses [`renderInnerPage()`](../../data/#rendering-page-content) while global data is computed, so the template never receives the page graph. +See the [blog example's `global.data.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/global.data.ts) and [`feeds.template.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/feeds.template.ts) for a working implementation. + +```typescript +// src/global.data.ts +import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' + +export interface FeedItem { + datePublished: string + title: string + urlPath: string + contentHtml: string +} + +export interface GlobalData { + feedItems: FeedItem[] +} + +export type FeedsTemplateData = Pick + +const globalData: AsyncGlobalDataFunction = async ({ pages }) => { + const posts: typeof pages = [] + for (const page of pages) { + if (page.pageInfo.path.startsWith('blog/') && page.vars.layout === 'blog') { + posts.push(page) + } + } + + posts.sort((a, b) => new Date(b.vars.publishDate).valueOf() - new Date(a.vars.publishDate).valueOf()) + + const feedItems: FeedItem[] = [] + for (const page of posts.slice(0, 10)) { + feedItems.push({ + datePublished: String(page.vars.publishDate), + title: String(page.vars.title), + urlPath: page.pageInfo.url, + contentHtml: String(await page.renderInnerPage()), + }) + } + + return { feedItems } +} + +export default globalData +``` + +```typescript +// src/feeds.template.ts +import jsonfeedToAtom from 'jsonfeed-to-atom' +import type { DataDeps, TemplateAsyncIterator } from '@domstack/static/types.js' +import type { FeedsTemplateData } from './global.data.js' + +interface TemplateVars { + title: string; + layout: string; + siteName: string; + homePageUrl: string; + authorName: string; + authorUrl: string; + authorImgUrl?: string; + siteDescription: string; + language: string; +} + +export const dataDeps = ['feedItems'] satisfies DataDeps + +const feedsTemplate: TemplateAsyncIterator = async function * ({ + vars: { + siteName, + siteDescription, + homePageUrl, + authorName, + authorUrl, + authorImgUrl, + }, + data, +}) { + const items = [] + for (const item of data.feedItems) { + items.push({ + date_published: item.datePublished, + title: item.title, + url: `${homePageUrl}${item.urlPath}`, + id: `${homePageUrl}${item.urlPath}#${item.datePublished}`, + content_html: item.contentHtml, + }) + } + + const jsonFeed = { + version: 'https://jsonfeed.org/version/1', + title: siteName, + home_page_url: homePageUrl, + feed_url: `${homePageUrl}/feed.json`, + description: siteDescription, + author: { + name: authorName, + url: authorUrl, + avatar: authorImgUrl + }, + items, + } + + yield { + content: JSON.stringify(jsonFeed, null, ' '), + outputName: './feeds/feed.json' + } + + yield { + content: jsonfeedToAtom(jsonFeed), + outputName: './feeds/feed.xml' + } +} + +export default feedsTemplate +``` diff --git a/docs/cookbook/nested-layouts/README.md b/docs/cookbook/nested-layouts/README.md new file mode 100644 index 00000000..58587199 --- /dev/null +++ b/docs/cookbook/nested-layouts/README.md @@ -0,0 +1,96 @@ +--- +layout: docs +docsOrder: 131 +docsParent: /docs/cookbook/ +handlebars: false +--- + +# Compose nested layouts + +[All recipes](../) + +This recipe uses the [explicit `parentLayout` declaration](../../layouts/#declaring-nested-layouts) described in the layout API. +Pages select their innermost layout with `vars.layout`. +A layout can export a static `parentLayout` name to let DOMStack wrap it in another layout. + +```typescript +// article.layout.ts +import { html, raw, render } from 'fragtml' +import type { LayoutFunction } from '@domstack/static/types.js' +import type { RootLayoutVars } from './root.layout.ts' + +export const parentLayout = 'root' +export const vars = { showSidebar: true } + +const articleLayout: LayoutFunction = ({ children }) => { + return render(html`
${raw(children)}
`) +} + +export default articleLayout +``` + +```typescript +// posts/example/page.ts +export const vars = { layout: 'article', title: 'A post' } +export default () => '

Hello from the post.

' +``` + +DOMStack renders `root(article(page()))`. +A root layout omits `parentLayout`; child layouts can name any discovered layout, including the bundled `root`. +Names are the same filename-derived names used by `vars.layout`, not import paths. +Missing parents, invalid parent exports, and cycles fail the build with the offending layout or chain. + +All renderers receive the same resolved vars, page metadata, worker URLs, and asset lists. +Vars merge from outermost to innermost layout, followed by page vars and builder/frontmatter vars. +Layout `vars.layout` does not select a parent; only the named `parentLayout` export establishes nesting. +Async layouts are awaited at every step, and intermediate values pass through unchanged until the final result is serialized. +Each parent must accept the kind of children its immediate child returns. + +## Data subscriptions in nested layouts + +Each layout declares only the data it reads. +Keep focused consumer types beside their producer in `global.data.ts`: + +```typescript +// global.data.ts +export type RootLayoutData = { navigation: { title: string, url: string }[] } +export type ArticleLayoutData = { recentPosts: { title: string, url: string }[] } +export type GlobalData = RootLayoutData & ArticleLayoutData +``` + +```typescript +// root.layout.ts +import type { DataDeps } from '@domstack/static/types.js' +import type { RootLayoutData } from './global.data.ts' + +export const vars = { + dataDeps: ['navigation'] satisfies DataDeps, +} +``` + +```typescript +// article.layout.ts +import type { DataDeps } from '@domstack/static/types.js' +import type { ArticleLayoutData } from './global.data.ts' + +export const parentLayout = 'root' +export const vars = { + dataDeps: ['recentPosts'] satisfies DataDeps, +} +``` + +These declaration snippets accompany each layout's render function. +The root receives `data.navigation`, and the article receives `data.recentPosts`. +A page using `article` rebuilds when either key changes, but it receives neither key unless it declares its own subscription. +Only put a subscription in a shared root when every descendant genuinely uses that data through the root. + +## Nested layout client bundles and styles + +DOMStack includes each ancestor's own style and client entry automatically. +The order is defaults β†’ globals β†’ outer layouts β†’ inner layouts β†’ page assets. +For example, a post using `article` receives `root.layout.css` before `article.layout.css`. +Do not also import the parent's layout CSS or client from the child: doing both duplicates its contents or execution. + +Watch mode uses the resolved chain for source-backed and generated pages. +Changing a parent layout or one of its imported helpers rebuilds descendant pages, and changing the chain updates those relationships after a successful build. +Existing asset edits use esbuild's watcher; adding or removing a layout asset updates the affected pages' asset lists. diff --git a/docs/cookbook/redirect-pages/README.md b/docs/cookbook/redirect-pages/README.md new file mode 100644 index 00000000..c926ff4e --- /dev/null +++ b/docs/cookbook/redirect-pages/README.md @@ -0,0 +1,146 @@ +--- +layout: docs +docsOrder: 134 +docsParent: /docs/cookbook/ +handlebars: false +--- + +# Generate redirect pages from page metadata + +[All recipes](../) + +See the working [blog example directory](https://github.com/bcomnes/domstack/tree/master/examples/blog/), [`redirects.pages.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/redirects.pages.ts), and [`redirect.layout.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/layouts/redirect.layout.ts). + +Sites migrating from another platform often need redirect pages for old URLs that no longer exist. +Keep that history on the current page with `redirectFrom` metadata instead of maintaining a separate old/new mapping: + +```md +--- +title: Current Post +redirectFrom: + - /2020/old-slug/ + - /blog/original-title/ +--- + + +# Current Post +``` + +Collect the metadata in `global.data.ts`. +The current page's URL becomes the redirect target automatically: + +```typescript +// src/global.data.ts +function collectRedirects (pages) { + const redirects = [] + const redirectOwners = new Map() + + for (const page of pages) { + const redirectFrom = page.vars.redirectFrom + if (redirectFrom === undefined) continue + + const source = page.pageInfo.pageFile.relname + if (!Array.isArray(redirectFrom)) throw new TypeError(`redirectFrom on "${source}" must be an array`) + + for (const from of redirectFrom) { + if (typeof from !== 'string') throw new TypeError(`redirectFrom entries on "${source}" must be strings`) + if (from.trim() !== from || !from.startsWith('/') || from.startsWith('//')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": expected a same-origin URL path`) + if (from.includes('?') || from.includes('#') || from.includes('\\') || from.split('/').some(part => part === '.' || part === '..')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": unsupported URL path`) + + const existingSource = redirectOwners.get(from) + if (existingSource) throw new Error(`redirectFrom "${from}" is declared by both "${existingSource}" and "${source}"`) + + redirectOwners.set(from, source) + redirects.push({ from, to: page.pageInfo.url }) + } + } + + return redirects +} + +export default function globalData ({ pages }) { + return { redirects: collectRedirects(pages) } +} +``` + +Validation happens while the destination page is still known, so malformed or duplicate metadata reports the page that declared it. +The pages factory then consumes the validated collection and renders each old location through a reusable redirect layout: + +```typescript +// src/redirects.pages.ts +function redirectOutputName (from) { + if (!from.startsWith('/') || from.startsWith('//')) throw new Error(`redirectFrom must be a same-origin URL path: ${from}`) + if (from.includes('?') || from.includes('#')) throw new Error(`redirectFrom must not include a query or fragment: ${from}`) + + const relativePath = from.slice(1) + if (relativePath.length === 0) return 'index.html' + return relativePath.endsWith('/') ? `${relativePath}index.html` : relativePath +} + +export const dataDeps = ['redirects'] + +export default function redirectsPages ({ data }) { + const pages = [] + + for (const { from, to } of data.redirects) { + pages.push({ + outputName: redirectOutputName(from), + vars: { + layout: 'redirect', + title: 'Redirecting...', + redirectTo: to, + }, + }) + } + + return pages +} +``` + +```typescript +// src/redirect.layout.ts + +import { html, render } from 'fragtml' + +export default function redirectLayout ({ vars }) { + return render(html` + + + + + + ${vars.title} + + +

Redirecting to ${vars.redirectTo}

+ +`) +} +``` + +`redirectFrom` contains old same-origin public URL paths. +`redirectOutputName()` converts directory URLs such as `/2020/old-slug/` to `2020/old-slug/index.html`. +DOMStack's generated-output validation still rejects escaping paths such as `..`. +The redirect target comes from the current page's normalized `pageInfo.url`, so moving the page again only requires retaining its previous URLs in that page's metadata. +`fragtml` escapes interpolated values by default, including attribute values and link text. + +**SEO note:** Meta-refresh is a client-side redirect. +Search engines may not treat it as a permanent 301 redirect. +For static hosting platforms that support server-side redirects, you can instead generate a `_redirects` file (Netlify, Cloudflare Pages) or `vercel.json` (Vercel) using the object template type: + +```typescript +// src/redirects-netlify.txt.template.ts +// Generates a _redirects file for Netlify / Cloudflare Pages. + +export const dataDeps = ['redirects'] + +export default function ({ data }) { + return { + outputName: '_redirects', + content: data.redirects.map(({ from, to }) => `${from} ${to} 301`).join('\n'), + } +} +``` + +Both approaches can coexist and consume the same `global.data.ts` redirect collection. +Copying a directory that contains a hand-crafted `_redirects` file via `--copy` is also an option when you prefer to manage redirects outside the build. diff --git a/docs/cookbook/yearly-blog-indexes/README.md b/docs/cookbook/yearly-blog-indexes/README.md new file mode 100644 index 00000000..40ad5e60 --- /dev/null +++ b/docs/cookbook/yearly-blog-indexes/README.md @@ -0,0 +1,115 @@ +--- +layout: docs +docsOrder: 133 +docsParent: /docs/cookbook/ +handlebars: false +--- + +# Generate yearly blog index pages + +[All recipes](../) + +Global data centralizes collection and grouping once, then generated pages turn those records into pages. +See the working [blog example directory](https://github.com/bcomnes/domstack/tree/master/examples/blog/), [`global.data.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/global.data.ts), [`blog-indexes.pages.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/blog-indexes.pages.ts), and [`year-index.layout.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/layouts/year-index.layout.ts). + +First, collect source-backed pages whose layout is `post`, validate and normalize their publish dates, sort them newest-first, and group them into yearly `blogIndexes`: + +```typescript +// src/global.data.ts +import type { + AsyncGlobalDataFunction, + GlobalDataFunctionParams, +} from '@domstack/static/types.js' + +export interface BlogPost { + path: string + title: string + publishDate: string +} + +export interface BlogIndex { + year: number + posts: BlogPost[] +} + +export interface GlobalData { + blogIndexes: BlogIndex[] +} + +export type BlogIndexesPagesData = Pick + +type SourcePageVars = { layout?: string, title?: unknown, publishDate?: unknown } + +function collectBlogPosts (pages: GlobalDataFunctionParams['pages']): BlogPost[] { + return pages + .filter(page => page.vars.layout === 'post') + .map(page => { + const value = page.vars.publishDate + if (typeof value !== 'string' && !(value instanceof Date)) { + throw new TypeError(`Post "${page.pageInfo.path}" needs a publishDate`) + } + + const publishDate = new Date(value.valueOf()) + if (Number.isNaN(publishDate.valueOf())) { + throw new TypeError(`Post "${page.pageInfo.path}" has an invalid publishDate`) + } + + return { + path: page.pageInfo.path, + title: String(page.vars.title ?? 'Untitled'), + publishDate: publishDate.toISOString(), + } + }) + .sort((a, b) => b.publishDate.localeCompare(a.publishDate)) +} + +const globalData: AsyncGlobalDataFunction = async ({ pages }) => { + const postsByYear = new Map() + + for (const post of collectBlogPosts(pages)) { + const year = new Date(post.publishDate).getUTCFullYear() + postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]) + } + + const blogIndexes = [...postsByYear] + .map(([year, posts]) => ({ year, posts })) + .sort((a, b) => b.year - a.year) + + return { blogIndexes } +} + +export default globalData +``` + +Then subscribe to `blogIndexes` and create one `blog//index.html` page per group using the `year-index` layout: + +```typescript +// src/blog-indexes.pages.ts +import type { DataDeps, PagesFunction } from '@domstack/static/types.js' +import type { BlogIndexesPagesData, BlogPost } from './global.data.js' + +type YearIndexPageVars = { + layout: 'year-index' + title: string + posts: BlogPost[] +} + +export const dataDeps = ['blogIndexes'] satisfies DataDeps + +const blogIndexes: PagesFunction< + YearIndexPageVars, + string, + Record, + BlogIndexesPagesData +> = ({ data }) => + data.blogIndexes.map(({ year, posts }) => ({ + outputName: `blog/${year}/index.html`, + vars: { + layout: 'year-index', + title: String(year), + posts, + }, + })) + +export default blogIndexes +``` diff --git a/docs/data/README.md b/docs/data/README.md new file mode 100644 index 00000000..ef137a73 --- /dev/null +++ b/docs/data/README.md @@ -0,0 +1,319 @@ +--- +layout: docs +docsOrder: 80 +handlebars: false +--- + +# Data + +Collect shared values from source pages in `global.data.ts`, then let each page, layout, or generator subscribe to exactly the values it needs. +This pipeline powers indexes, navigation, feeds, and other derived content without giving every renderer access to the entire page collection. +For output definitions, see [Generation](../generation/); for ordinary configuration defaults, see [Settings](../settings/#global.vars.ts). + +## Table of Contents + +[[toc]] + +## Global data + +The `global.data.ts` file is an optional file that can live anywhere in your `src` tree. +The first one found wins and duplicates warn. +It runs **once per build**, after [source-backed pages](../pages/#page-files) are initialized and before generated-page factories run. + +> [!NOTE] +> `global.data.js` works too. +See [Supported file types](../typescript/#supported-file-types) for all available extensions. + +For data that aggregates across multiple pages β€” like blog indexes, sitemaps, recent-post lists, or RSS feed content β€” use `global.data.ts`. +It is the only public build hook that receives the source-backed `PageData[]` collection. +It returns an object of named, top-level values that downstream consumers can explicitly subscribe to. + +```typescript +// src/global.data.ts +import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' +import { html, render } from 'fragtml' + +export type GlobalData = { + blogPostsHtml: string +} + +export type ArchiveData = Pick + +const buildGlobalData: AsyncGlobalDataFunction = async ({ pages }) => { + const blogPosts: typeof pages = [] + for (const page of pages) { + if (page.vars.layout === 'blog' && page.vars.publishDate) { + blogPosts.push(page) + } + } + blogPosts.sort((a, b) => new Date(b.vars.publishDate).valueOf() - new Date(a.vars.publishDate).valueOf()) + + const entries = [] + for (const page of blogPosts.slice(0, 5)) { + entries.push(html` +
  • + + ${page.vars.title} + +
  • + `) + } + + const blogPostsHtml = render(html` +
      + ${entries} +
    + `) + + return { blogPostsHtml } +} + +export default buildGlobalData +``` + +## Data subscriptions + +The returned object is not merged into `vars`. +A page or layout declares the keys it needs through `dataDeps`, then reads those keys from the separate `data` argument: + +```md + +--- +dataDeps: + - blogPostsHtml +--- + +## [Blog](./blog/) + +{{{ data.blogPostsHtml }}} +``` + +HTML pages declare the same field in an adjacent `page.vars.ts` file: + +```typescript +// src/archive/page.vars.ts +export default { + dataDeps: ['blogPostsHtml'], +} +``` + +TypeScript pages and layouts can put the declaration in their `vars` export: + +```typescript +import type { DataDeps, PageFunction } from '@domstack/static/types.js' +import type { ArchiveData } from './global.data.js' + +export const vars = { + dataDeps: ['blogPostsHtml'] satisfies DataDeps, +} + +const archivePage: PageFunction, string, ArchiveData> = ({ data }) => + `

    Archive

    ${data.blogPostsHtml}` + +export default archivePage +``` + +Keep these focused consumer contracts beside the complete global-data type so pages and layouts can import a meaningful name instead of reconstructing a `Pick` selection. +`DataDeps` checks declaration names against that contract and accepts readonly arrays, including `as const` tuples. +The declaration is still required at runtime; a TypeScript type alone does not subscribe a renderer. + +For `*.template.ts` and `*.pages.ts` files, export `dataDeps` as a named module export because those files do not have consumer vars: + +```typescript +export const dataDeps = ['blogPostsHtml'] + +export default function archiveTemplate ({ data }) { + return data.blogPostsHtml +} +``` + +`dataDeps` is build metadata and is removed from the resolved `vars` object. +The page receives the union of its own frontmatter, page-vars, and builder declarations. +Each layout receives only its own `vars.dataDeps`, not its parent's or the page's data. +For output invalidation, DOMStack unions the page's declarations with those of every layout in its resolved `parentLayout` chain. +Children do not repeat ancestor declarations, and a parent's subscriptions cannot be cleared by a child's empty declaration. +When one layout calls another layout function directly, the composing layout must declare every global-data key the composed rendering needs. + +**Key properties of `global.data.ts`:** + +- **Centralizes page collation and processing.** Collect, filter, group, sort, and render source pages once, then expose purpose-built values instead of the page graph itself. +- Receives source-backed `PageData[]` with resolved `.vars` (global, layout, page, and builder vars), `.pageInfo` (path, type, etc.), `.styles`, `.scripts`, and more. + Generated pages do not exist yet. +- Gives pages, layouts, templates, and page factories only their declared top-level keys through `data`. +- Keeps global data separate from ordinary `vars`, so derived values cannot silently collide with page or layout configuration. +- Runs inside the worker process (same as all other dynamic imports) to avoid ESM caching issues. +- Skipped entirely if no `global.data.*` file exists β€” zero overhead. +- In watch mode, DOMStack fingerprints each top-level returned value and rebuilds only consumers subscribed to changed keys. +- Editing `global.data.*` or one of its statically imported helpers recomputes data; a shared helper also rebuilds its direct page, layout, template, and factory consumers. +- Values composed of JSON-safe primitives, arrays, and plain objects get stable fingerprints; opaque values such as functions, class instances, maps, sets, or cycles conservatively invalidate their subscribers on every page build. +- A declaration naming a missing key fails the build, and access to an existing but undeclared key throws a focused error. + +Subscription failures use `DomStackDataError` with code `DOM_STACK_ERROR_DATA`. +Its `dataDependency` metadata identifies the consumer, optional key, and reason: `INVALID_DECLARATION`, `MISSING_KEY`, `UNDECLARED_KEY`, or `NOT_READY`. +The subtype and metadata survive worker transport inside the build's aggregate errors. +After a failed watch build, the next page build retries the complete page phase before returning to incremental routing. + +## Global data types + +`GlobalDataFunction` accepts synchronous or asynchronous implementations; `AsyncGlobalDataFunction` specifically requires a promise. +In both types, `T` describes the named data returned by `global.data.ts`: + +```typescript +// src/global.data.ts +import type { GlobalDataFunction } from '@domstack/static/types.js' + +type DerivedData = { + pageCount: number + pageUrls: string[] +} + +const globalData: GlobalDataFunction = ({ pages }) => { + return { + pageCount: pages.length, + pageUrls: pages.map(page => page.pageInfo.url), + } +} + +export default globalData +``` + +Use `AsyncGlobalDataFunction` instead when the implementation needs to await rendering, network requests, or other asynchronous work. +For typed source input, use `GlobalDataFunction` or its async counterpart. +Helpers can accept `GlobalDataFunctionParams['pages']` without recovering types from the full global-data result. + +## Global data caveats + +Source-page introspection must not mutate resolved variables or depend on the data it is still computing. +The following rules keep that build order explicit. + +> [!CAUTION] +> `page.vars` is a cached, shallow-frozen object containing the resolved variable cascade. +Treat it as read-only. +Create a new object when you need to add or replace values. + +```typescript +// src/global.data.ts +// Do not mutate the resolved page variables. +page.vars.slug = createSlug(page.vars.title) + +// Create a new object instead. +const derivedVars = { + ...page.vars, + slug: createSlug(page.vars.title), +} +``` + +> [!WARNING] +> Accessing `page.vars` throws when that page failed to initialize, such as when a page-variable module contains a syntax error, missing dependency, or runtime error. +Fix the underlying page initialization failure rather than treating missing variables as valid data. + +> [!NOTE] +> Raw Markdown is not exposed as `page.vars.content`. +Markdown variables include frontmatter-derived values such as `title`. +Call `readMarkdownContent()` when you need the source body. + +```typescript +// src/global.data.ts +const markdownSources = await Promise.all( + pages + .filter(page => page.pageInfo.type === 'md') + .map(async page => ({ + path: page.pageInfo.path, + markdown: await page.readMarkdownContent(), + })) +) +``` + +> [!TIP] +> `global.data.ts` can call `renderInnerPage()` because it runs after source-backed page initialization has been attempted. +> The same initialization caveat applies. + +```typescript +// src/global.data.ts +const renderedPages = await Promise.all( + pages.map(async page => ({ + path: page.pageInfo.path, + html: await page.renderInnerPage(), + })) +) +``` + +Global-data computation cannot read the `data` values it is still producing. +If a source page declares data dependencies, attempting to render it from `global.data.ts` fails rather than creating a hidden cycle. + +See [Rendering page content](#rendering-page-content) for rendering semantics and performance guidance. + +## Page data and introspection + +Page functions and layouts, including those rendering generated pages, receive metadata for the current page through `page`. +Only `global.data.ts` receives the collection of source-backed `PageData` instances. +This is the intentional boundary between source-page introspection and downstream rendering. + +```typescript +// src/example/page.ts +export default function examplePage ({ page }) { + console.log(page.url) + return '' +} +``` + +Generated-page factories do not receive `PageData`. +They consume values explicitly returned by `global.data.ts` instead. + +### Page metadata + +The current `page` is a `PageInfo` object with the following properties: + +- `type`: The page type (`md`, `html`, or `js`). +- `path`: The source-relative directory path for the page. +- `url`: The canonical URL path, such as `/blog/my-post/` for index pages or `/blog/loose-page.html` for loose pages. +- `outputName`: The final output filename. +- `outputRelname`: The destination-relative output path. +- `pageFile`: Source-file path details. +- `pageStyle`: File information when the page has a page style. +- `clientBundle`: File information when the page has a client bundle. +- `pageVars`: File information when the page has an adjacent page-variable file. +- `generated`: Metadata about the `*.pages.ts` file that created a generated page, or `undefined` for a source-backed page. + +Each `PageData` entry supplied to `global.data.ts` exposes this object as `page.pageInfo`. +Combine `page.pageInfo.url` with a `siteUrl` from `global.vars.ts` to build an absolute URL: `` `${vars.siteUrl}${page.pageInfo.url}` ``. +The [RSS and JSON feed recipe](../cookbook/feeds/) uses this pattern for feed item URLs. + +### Rendering page content + +Each `PageData` instance passed to `global.data.ts` exposes two methods for accessing rendered output. +This is useful when derived data needs to embed a page's content, such as the [`global.data.ts`](https://github.com/bcomnes/domstack/blob/master/examples/blog/src/global.data.ts) implementation used by the [RSS and JSON feed recipe](../cookbook/feeds/). + +- `await page.renderInnerPage()` returns the page's inner render output as produced by its builder, without a layout wrapper applied. + This is often an HTML string, such as Markdown rendered to HTML, but the type depends on the page builder. +- `await page.renderFullPage()` returns the complete page output with its layout applied. + +Both methods are async, and rendering errors propagate and fail the build. +While `global.data.ts` is resolving, `renderInnerPage()` is allowed if the page itself has no subscriptions, even when its layouts subscribe to data. +`renderFullPage()` requires the page and its entire layout chain to be unsubscribed at that stage, because derived data does not exist yet. + +### Rendering many pages + +Use [`global.data.ts`](#global-data) to pre-render content shared by multiple downstream pages or templates. +This centralizes the work and makes the result available through an explicit subscription: + +```typescript +// src/global.data.ts +import type { AsyncGlobalDataFunction } from '@domstack/static/types.js' + +const globalData: AsyncGlobalDataFunction = async ({ pages }) => { + const entries = await Promise.all( + pages.map(async page => [ + page.pageInfo.path, + await page.renderInnerPage() + ] as const) + ) + + return { renderedPagesByPath: Object.fromEntries(entries) } +} + +export default globalData +``` + +Rendering performed inside `global.data.ts` cannot use the derived values that the same file is still computing. +After `global.data.ts` returns, consumers receive only the values named by their `dataDeps` declarations. diff --git a/docs/example-projects/README.md b/docs/example-projects/README.md new file mode 100644 index 00000000..092af670 --- /dev/null +++ b/docs/example-projects/README.md @@ -0,0 +1,58 @@ +--- +layout: docs +docsOrder: 20 +handlebars: false +--- + +# Examples + +These projects show how DOMStack's file conventions work in complete sites. +Start with the basic example for a tour, or choose a project that demonstrates the feature you need. + +## Table of Contents + +[[toc]] + +## Bundled examples + +A collection of examples can be found in the [`./examples`](https://github.com/bcomnes/domstack/tree/master/examples) folder: + +- [`basic`](https://github.com/bcomnes/domstack/tree/master/examples/basic) β€” A broad tour of Markdown, HTML, and TypeScript pages, nested pages and layouts, variables, styles, client bundles, and static assets. +- [`blog`](https://github.com/bcomnes/domstack/tree/master/examples/blog) β€” A blog with derived global data, generated archive pages, redirects, nested layouts, and feed templates. +- [`css-modules`](https://github.com/bcomnes/domstack/tree/master/examples/css-modules) β€” Using CSS Modules from page code alongside global and page styles. +- [`default-layout`](https://github.com/bcomnes/domstack/tree/master/examples/default-layout) β€” Building a Markdown site with DOMStack's built-in default layout and no custom layout. +- [`esbuild-settings`](https://github.com/bcomnes/domstack/tree/master/examples/esbuild-settings) β€” Customizing the browser build through `esbuild.settings`. +- [`markdown-settings`](https://github.com/bcomnes/domstack/tree/master/examples/markdown-settings) β€” Customizing Markdown rendering with `markdown-it.settings` and Markdown-it plugins. +- [`nested-dest`](https://github.com/bcomnes/domstack/tree/master/examples/nested-dest) β€” Using the project root as `src` while writing the built site to a nested `public` directory. +- [`preact-isomorphic`](https://github.com/bcomnes/domstack/tree/master/examples/preact-isomorphic) β€” Rendering with Preact on the server and mounting page-scoped Preact and JSX in the browser. +- [`react`](https://github.com/bcomnes/domstack/tree/master/examples/react) β€” Configuring React and TypeScript for a page-scoped TSX client. +- [`static-mpa-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-offline) β€” A static multi-page app with DOMStack manifests, an offline fallback, precaching, and custom service-worker caching policies. +- [`static-mpa-workbox-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-workbox-offline) β€” The offline static MPA pattern implemented with Workbox routing, strategies, and precaching. +- [`string-layouts`](https://github.com/bcomnes/domstack/tree/master/examples/string-layouts) β€” Writing layouts that return plain HTML strings instead of using the default renderer. +- [`tailwind`](https://github.com/bcomnes/domstack/tree/master/examples/tailwind) β€” Integrating Tailwind CSS through an esbuild plugin. +- [`type-stripping`](https://github.com/bcomnes/domstack/tree/master/examples/type-stripping) β€” Using Node.js type stripping for TypeScript pages and layouts, plus a page-scoped TSX client. +- [`uhtml-isomorphic`](https://github.com/bcomnes/domstack/tree/master/examples/uhtml-isomorphic) β€” Rendering with `uhtml-isomorphic` on the server and mounting or hydrating UI in the browser. +- [`worker-example`](https://github.com/bcomnes/domstack/tree/master/examples/worker-example) β€” Bundling and communicating with page-scoped JavaScript and TypeScript Web Workers. + +To run an example: + +```bash +$ git clone git@github.com:bcomnes/domstack.git +$ cd domstack +# install the root package and all example workspaces +$ npm i +# build one example workspace +$ npm --workspace @domstack/basic-example run build +``` + +## External examples + +Here are some additional external examples of larger domstack projects. +If you have a project that uses domstack and could act as a nice example, please PR it to the list! + +- [Blog Example](https://github.com/bcomnes/bret.io/) - A personal blog written with DOMStack +- [Isomorphic Static/Client App](https://github.com/hifiwi-fi/breadcrum.net/tree/master/packages/web/client) - Pages build from client templates and hydrate on load. +- [Zero-Conf Markdown Docs](https://github.com/bcomnes/deploy-to-neocities/blob/70b264bcb37fca5b21e45d6cba9265f97f6bfa6f/package.json#L38) - A npm package with markdown docs, transformed into a website without any any configuration + +(Did you make a cool DOMStack website that is open source? +PR it to the list!) diff --git a/docs/generation/README.md b/docs/generation/README.md new file mode 100644 index 00000000..03831595 --- /dev/null +++ b/docs/generation/README.md @@ -0,0 +1,381 @@ +--- +layout: docs +docsOrder: 90 +handlebars: false +--- + +# Generation + +Create output from code when a source file per page is not the right fit. +Generated pages use DOMStack's normal page and layout pipeline, while templates write arbitrary files such as feeds, JSON, or text. +Both can subscribe to shared values prepared by the [data pipeline](../data/). + +| Use | Choose | Result | +|---|---|---| +| Archives, tag indexes, or HTML redirects with page variables and layouts | `*.pages.ts` | One or more DOMStack pages | +| Feeds, sitemaps, JSON, text, or fully controlled output | `*.template.ts` | One or more files, without layout wrapping | +| An ordinary page with its own source directory and browser assets | [Page files](../pages/#page-files) | A source-backed page | + +## Table of Contents + +[[toc]] + +## Generated pages + +Generated-pages files create one or more DOMStack pages from a central `*.pages.*` module. +Unlike templates, generated pages use the normal page and layout pipeline: each definition supplies page variables and children, which DOMStack renders through the selected layout. +Use generated pages for data-driven output such as blog index pages or HTML redirects derived from frontmatter. + +Generated-pages files use the `*.pages.ts` suffix. + +> [!NOTE] +> Wherever you see `*.pages.ts` being used, you can also use `*.pages.js`. +Type checking is supported in both file types. +See [Supported file types](../typescript/#supported-file-types) for all available extensions. + +### Generated-pages exports + +Like [variable providers](../pages/#variable-providers), generated-page factories may be synchronous or asynchronous. +Unlike variable providers, they return page definitions and may produce multiple results. + +A generated-pages module can default-export: + +| Export | Use when | +|---|---| +| One `GeneratedPageDefinition` object | The module always creates one page | +| An array of definitions | The module always creates a fixed set of pages and needs no build context | +| A normal or `async` function | Definitions depend on global vars, declared global data, or pages-file metadata | +| An async iterable, usually returned by `async function*` | Pages are discovered incrementally or the total is not known in advance | + +Static objects and arrays do not receive factory parameters. + +#### One page definition + +Export one object when the module always creates a single page: + +```ts +// src/about.pages.ts +export default { + outputName: 'about/index.html', + vars: { layout: 'root', title: 'About' }, + children: '

    About this site

    ', +} +``` + +#### Page definition array + +Export an array when the module always creates a fixed set of pages: + +```ts +// src/legal.pages.ts +export default [ + { + outputName: 'terms/index.html', + vars: { layout: 'legal', title: 'Terms' }, + children: 'Terms of service', + }, + { + outputName: 'privacy/index.html', + vars: { layout: 'legal', title: 'Privacy' }, + children: 'Privacy policy', + }, +] +``` + +#### Synchronous factory + +Export a function when definitions depend on declared global data or shared variables: + +```ts +// src/tag-indexes.pages.ts +export const dataDeps = ['tagIndex'] + +export default function tagIndexes ({ data }) { + return Object.entries(data.tagIndex).map(([tag, posts]) => ({ + outputName: `tags/${tag}/index.html`, + vars: { layout: 'tag-index', title: `Posts tagged ${tag}`, posts }, + })) +} +``` + +For a complete two-stage factory example, see [Generate yearly blog index pages](../cookbook/yearly-blog-indexes/). + +#### Asynchronous factory + +Export an async function when creating definitions requires asynchronous work: + +```ts +// src/team.pages.ts +import { readFile } from 'node:fs/promises' + +export default async function teamPages () { + const members = JSON.parse( + await readFile(new URL('./data/team.json', import.meta.url), 'utf8') + ) + + return members.map(member => ({ + outputName: `team/${member.slug}/index.html`, + vars: { layout: 'profile', title: member.name, member }, + })) +} +``` + +#### Async iterable + +Export an async generator when pages should be yielded incrementally: + +```ts +// src/archive.pages.ts +export const dataDeps = ['blogYears'] + +export default async function * archivePages ({ data }) { + for (const year of data.blogYears) { + yield { + outputName: `blog/${year}/index.html`, + vars: { layout: 'archive', year }, + } + } +} +``` + +### Generated-pages factory parameters + +Functions receive one object with: + +| Parameter | Contents | +|---|---| +| `vars` | Default and global vars. | +| `data` | Only the top-level values named by the module's `dataDeps` export. | +| `pagesFile` | Information about the current file. `name` is the filename without its `.pages.*` suffix, `path` is its source-relative directory, and `pagesFile` contains the underlying file information. | + +Factories do not receive raw source or generated `PageData` collections. +Put page-collection logic in [`global.data.ts`](../data/#global-data), return a focused serializable value, and subscribe to its key from the factory. +This keeps factories downstream of source discovery without exposing generation order or creating page-generation cycles. + +### Generated page definitions + +| Field | Behavior | +|---|---| +| `outputName` | Output path relative to the pages file's directory. It must name a file, must not be absolute or contain `..` segments, and cannot end in a path separator. Defaults to `/index.html`. | +| `vars` | Page-level vars merged with the normal default, global, layout, and builder vars. | +| `children` | Optional static child content or inline `PageFunction` rendered before the layout. | +| `draft` | When `true`, the page is omitted unless the CLI uses `--drafts` or a programmatic build uses `buildDrafts: true`. | + +Generated pages use [global bundles](../global-bundles/) and [layout assets](../layouts/#layout-styles). +They do not have page-local `style.css`, `client.js`, or worker entries because they do not have their own source-page directory. + +### Generated-pages types + +Use `GeneratedPageDefinition` to type an individual definition. +`T` is the generated page's variables type, `U` is its children type, which defaults to `string`, and `D` is the declared data shape for inline page functions: + +```ts +// src/terms.pages.ts +import type { GeneratedPageDefinition } from '@domstack/static/types.js' + +type LegalPageVars = { + layout: string + title: string +} + +const terms: GeneratedPageDefinition = { + outputName: 'terms/index.html', + vars: { layout: 'legal', title: 'Terms' }, + children: 'Terms of service', +} + +export default terms +``` + +Use `PagesFunction` for normal functions, async functions, and async generators: + +- `T` is the variables type added to each generated page. +- `U` is the generated children type (defaults to `string`). +- `V` is the default and global vars type received by the factory. +- `D` is the global-data shape declared by the factory. + +```ts +// src/archive.pages.ts +import type { PagesFunction } from '@domstack/static/types.js' + +type ArchiveVars = { layout: string, year: number } +type ArchiveData = { blogYears: number[] } + +export const dataDeps = ['blogYears'] + +const archivePages: PagesFunction, ArchiveData> = async function * ({ data }) { + for (const year of data.blogYears) { + yield { + outputName: `blog/${year}/index.html`, + vars: { layout: 'archive', year }, + } + } +} + +export default archivePages +``` + +For metadata-driven redirects, see the cookbook recipe [Generate redirect pages from page metadata](../cookbook/redirect-pages/). + +## Templates + +Template files let you write any kind of file type to the `dest` folder while customizing the contents with global vars and explicitly subscribed global data. +Template files can be located anywhere in the `src` directory. +For a complete feed-generation recipe, see [Generate RSS and JSON feeds](../cookbook/feeds/). + +Template files look like: + +```bash +name-of-template.txt.template.ts +${name-portion}.template.ts +``` + +Template files are `.ts` files that default-export one of the following sync/async functions: + +> [!NOTE] +> Wherever you see `.template.ts` being used, you can also use `.template.js`. +Type checking is supported in both file types. +See [Supported file types](../typescript/#supported-file-types) for all available extensions. + +### Simple string template + +A function that returns a string. +The `name-of-template.txt` portion of the template file name becomes the file name of the output file. + +```typescript +// name-of-template.txt.template.ts +import type { TemplateFunction } from '@domstack/static/types.js' + +interface TemplateVars { + foo: string; + testVar: string; +} + +const simpleTemplate: TemplateFunction = async ({ + vars: { + foo, + testVar + } +}) => { + return `Hello world + +This is just a file with access to global vars: ${foo}` +} + +export default simpleTemplate +``` + +### Object template + +A function that returns a single object with a `content` and `outputName` entries. +The `outputName` overrides the name portion of the template file name. + +```typescript +import type { TemplateFunction } from '@domstack/static/types.js' + +interface TemplateVars { + foo: string; +} +export default async ({ + vars: { foo } +}) => ({ + content: `Hello world + +This is just a file with access to global vars: ${foo}`, + outputName: './single-object-override.txt' +}) +``` + +### Object array template + +A function that returns an array of objects with a `content` and `outputName` entries. +This template file generates more than one file from a single template file. + +```typescript +import type { TemplateFunction } from '@domstack/static/types.js' + +interface TemplateVars { + foo: string; + testVar: string; +} + +const objectArrayTemplate: TemplateFunction = async ({ + vars: { + foo, + testVar + } +}) => { + return [ + { + content: `Hello world + +This is just a file with access to global vars: ${foo}`, + outputName: 'object-array-1.txt' + }, + { + content: `Hello world again + +This is just a file with access to global vars: ${testVar}`, + outputName: 'object-array-2.txt' + } + ] +} + +export default objectArrayTemplate +``` + +### AsyncIterator template + +An [AsyncIterator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator) that `yields` objects with `content` and `outputName` entries. + +```typescript +import type { TemplateAsyncIterator } from '@domstack/static/types.js' + +interface TemplateVars { + foo: string; + testVar: string; +} + +const templateIterator: TemplateAsyncIterator = async function * ({ + vars: { + foo, + testVar + } +}) { + // First item + yield { + content: `Hello world + +This is just a file with access to global vars: ${foo}`, + outputName: 'yielded-1.txt' + } + + // Second item + yield { + content: `Hello world again + +This is just a file with access to global vars: ${testVar}`, + outputName: 'yielded-2.txt' + } +} + +export default templateIterator +``` + +Templates receive only global vars, their declared global `data`, and metadata for the current template. +Use [`global.data.ts`](../data/#global-data) to turn source-page collections into values a template can subscribe to. + +### Choosing a template return type + +Use the simplest return type that fits your needs: + +| Return type | Multiple outputs | Custom output path | Buffers the output set | Use when | +|---|---|---|---|---| +| String | No | No (derived from template filename) | β€” | Single file, output path derived from template filename | +| Object | No | Yes | β€” | Single file with a custom output path | +| Array | Yes | Yes | Yes | Fixed set of output files known at build time | +| AsyncIterator | Yes | Yes | No | Dynamic or unknown number of outputs, or when outputs should be yielded incrementally without buffering the full set | + +Start with a string return and only switch to a more complex type when you need what it provides. +All template forms can do async work (string, object, and array all support `async` functions). +Choose AsyncIterator specifically when the number of output files is not known until the template runs, or when you want to stream outputs one at a time rather than building the full list in memory first. diff --git a/docs/global-bundles/README.md b/docs/global-bundles/README.md new file mode 100644 index 00000000..208637d0 --- /dev/null +++ b/docs/global-bundles/README.md @@ -0,0 +1,83 @@ +--- +layout: docs +docsOrder: 60 +handlebars: false +--- + +# Global bundles + +Global client and CSS entry files are bundled with esbuild and included on every page. +Use [page bundles](../pages/#page-client-bundles) for one page and [layout bundles](../layouts/#layout-client-bundles) for pages sharing a layout. +Files copied without processing are documented in [Static assets](../assets/), while build-tool configuration belongs in [Settings](../settings/). + +## Table of Contents + +[[toc]] + +## Global entry files + +Global scripts and styles can live anywhere in the `src` directory. +Global browser assets preserve their source-relative directory when built into `dest`. +For example, `src/assets/global.css` produces an output such as `dest/assets/global-[hash].css`. + +Only one file may match each global filename pattern. +When DOMStack discovers a duplicate, it keeps the first file it found, skips the duplicate, and reports a warning. +Define each global file once rather than relying on discovery order. + +> [!NOTE] +> Wherever this section uses `.ts`, you can also use `.js`. +Type checking is supported in both file types. +See [Supported file types](../typescript/#supported-file-types) for all available extensions. + +## Global client bundles + +`global.client.ts` is a script bundle that is included on every page. +It provides an easy way to inject analytics or other small scripts that every page should have. +Try to minimize what you put in here. + +> [!NOTE] +> Use `global.client.tsx` when the global client bundle contains JSX. +You can also use `global.client.jsx`. +See [Supported file types](../typescript/#supported-file-types) for all available extensions and [`.tsx` client bundles](../pages/#tsx) for JSX configuration. + +```typescript +console.log('I run on every page in the site!') +``` + +## Global styles + +`global.css` is a global stylesheet that every page will use. +Any styles that need to be on every single page should live here. +Importing CSS from `npm` modules works well here. + +### Optional cascade layers + +The bundled default stylesheet imports mine.css's main rules in its low-priority `mine` layer and its optional layout and syntax styles in `domstack.default`. +Normal unlayered styles in your project override those defaults, so custom stylesheets do not have to use cascade layers. + +For projects that prefer explicit layers, each stylesheet can declare only its own optional scope: + +```css +/* global.css */ +@layer domstack.global { + /* Site-wide rules */ +} +``` + +```css +/* article.layout.css */ +@layer domstack.layout { + /* Layout rules */ +} +``` + +```css +/* style.css */ +@layer domstack.page { + /* Page rules */ +} +``` + +DOMStack loads default, global, layout, and page stylesheets in that order, which gives these layers the same low-to-high precedence when they are used. +A global stylesheet does not need to enumerate the layout or page layers. +This is a recommended organization pattern, not a requirement. diff --git a/docs/implementation/README.md b/docs/implementation/README.md new file mode 100644 index 00000000..0249ca49 --- /dev/null +++ b/docs/implementation/README.md @@ -0,0 +1,334 @@ +--- +layout: docs +docsOrder: 140 +handlebars: false +--- + +# Implementation + +DOMStack coordinates page rendering, asset bundling, and file copying in a staged build. +This guide explains the tools involved, the order of each phase, and how watch mode decides what to rebuild. + +## Table of Contents + +[[toc]] + +## Build tools + +`domstack` bundles the best tools for every technology in the stack: + +- `js` and `css` is bundled with [`esbuild`](https://github.com/evanw/esbuild). +- `md` is processed with [markdown-it](https://github.com/markdown-it/markdown-it). +- static files are processed with [cpx2](https://github.com/bcomnes/cpx2). +- `ts` support via native typestripping in Node.js and esbuild. +- `jsx/tsx` support via esbuild. + +These tools are treated as implementation details, but they may be exposed more in the future. +The idea is that they can be swapped out for better tools in the future if they don't make it. + +## Build process flow + +The one-shot builder discovers inputs using the shared file conventions, then records outputs from each build phase. +The service worker is built last so manifest hooks can provide its build-time constants. +Side-by-side blocks run in parallel; their arrows join before the next phase starts. +On narrow screens, scroll a diagram horizontally to keep its labels readable. + +
    +flowchart TD
    +  accTitle: One-shot build
    +  accDescr: Discover inputs, run three asset tasks in parallel, build pages, then finalize the manifest and service worker before returning results.
    +  IDENTIFY["`**identifyPages()**
    +Find pages, layouts, templates
    +Find globals and settings`"]
    +  PREPARE["`**Prepare destination**
    +Resolve manifest options
    +Start parallel asset tasks`"]
    +  ESBUILD["`**buildEsbuild()**
    +Bundle browser JS and CSS
    +Record outputs`"]
    +  STATIC["`**buildStatic()**
    +Copy static files if enabled
    +Record outputs`"]
    +  COPY["`**buildCopy()**
    +Copy extra directories
    +Record outputs`"]
    +  PAGES["`**buildPages()**
    +Start a fresh page worker
    +Render pages and templates
    +Apply layouts; record outputs`"]
    +  FINALIZE["`**Finalize build**
    +Reconcile manifest if enabled
    +Run hooks; build service worker`"]
    +  RESULTS["`**Return results**
    +Discovery and build reports
    +Manifest and warnings
    +Write manifest JSON if requested`"]
    +  IDENTIFY --> PREPARE
    +  PREPARE --> ESBUILD & STATIC & COPY
    +  ESBUILD & STATIC & COPY --> PAGES
    +  PAGES --> FINALIZE --> RESULTS
    +
    + +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 and record their outputs: + - JavaScript and CSS bundling via esbuild + - Static file copying (when enabled) + - Additional directory copying (from `--copy` options) +4. **Page building** - Initializes source-backed pages, derives global data, generates pages, and renders pages and templates with their declared data subscriptions +5. **Manifest reconciliation** - When enabled, normalizes recorded outputs, hashes file contents, filters entries, computes a stable manifest version, and runs manifest hooks +6. **Service-worker building** - Bundles the site service worker using any defines returned by manifest hooks; this output is not included in the already reconciled manifest +7. **Return results** - Writes manifest JSON only when requested and returns the build results + +This architecture allows for efficient parallel processing of independent tasks while maintaining the correct build order dependencies. +The diagrams show successful execution; discovery and build errors stop later phases. + +### buildPages() detail + +Each `buildPages()` call starts a fresh worker so server-side modules can be reloaded between watch builds. +Within that worker, source-backed pages are initialized before global data is computed. +Generated pages are downstream consumers of that data, not inputs to its producer. +The three source-page lanes show the work performed for each page type, not three separate concurrency pools. + +
    +flowchart TD
    +  accTitle: Page worker stages
    +  accDescr: Initialize Markdown, HTML, and JS or TS source pages in parallel. Derive global data from those pages, generate additional pages, then render pages and templates in parallel.
    +  RESOLVE["`**Resolve once**
    +Defaults and global vars
    +Layouts and parent chains`"]
    +  INIT["`**Parallel source-page init**
    +Concurrency: min(CPUs, 24)`"]
    +  subgraph MD["Markdown page task"]
    +    direction TB
    +    MD_VARS["Resolve page.vars"]
    +    MD_READ["`**mdBuilder()**
    +Read Markdown
    +Title and frontmatter`"]
    +    MD_VARS --> MD_READ
    +  end
    +  subgraph HTML["HTML page task"]
    +    direction TB
    +    HTML_VARS["Resolve page.vars"]
    +    HTML_READ["`**htmlBuilder()**
    +Read HTML source`"]
    +    HTML_VARS --> HTML_READ
    +  end
    +  subgraph JS["JS / TS page task"]
    +    direction TB
    +    JS_VARS["Resolve page.vars"]
    +    JS_READ["`**jsBuilder()**
    +Import page module
    +Read exported vars`"]
    +    JS_VARS --> JS_READ
    +  end
    +  BIND["`**Finish each page init**
    +Merge vars and bind layouts
    +Collect assets and dataDeps`"]
    +  DATA["`**global.data**
    +Receive initialized source PageData[]
    +Derive shared data`"]
    +  SUBSCRIBE["`**Select declared data**
    +Project dataDeps for consumers
    +In watch: expand invalidated filters`"]
    +  GENERATE["`**Run pages-file factories**
    +Consume declared data
    +Define generated pages`"]
    +  GENERATED_INIT["`**Initialize generated pages**
    +Resolve vars, layouts, and assets
    +Bind declared data`"]
    +  subgraph RENDER["Parallel rendering Β· shared concurrency budget"]
    +    direction TB
    +    PAGE_RENDER["`**pageWriter()**
    +Render source and generated pages
    +Wrap layouts inner to outer
    +Write outputs`"]
    +    TEMPLATE_RENDER["`**templateBuilder()**
    +Render selected templates
    +Use declared data
    +Write outputs`"]
    +  end
    +  REPORT["`**Return page-build results**
    +Outputs, errors, and layout reports
    +Subscriptions in watch mode`"]
    +  RESOLVE --> INIT
    +  INIT --> MD & HTML & JS
    +  MD & HTML & JS --> BIND
    +  BIND --> DATA --> SUBSCRIBE --> GENERATE --> GENERATED_INIT
    +  GENERATED_INIT --> RENDER --> REPORT
    +
    + +Selected `*.pages.*` factories produce definitions that go through the same page initialization as source-backed pages. +Each page, layout, template, and pages-file factory receives only the global-data keys it declares through `dataDeps`. +Layout subscriptions contribute to page invalidation, but each layout still receives its own data projection while rendering. +Page initialization uses a concurrency limit of `min(CPUs, 24)`. +The final page and template rendering queues run in parallel, splitting that concurrency budget between them. + +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). +- **Layout vars** - Optional `export const vars` from the resolved layout chain, merged outermost to innermost. +- **Page-specific vars** vary by type: + - **MD pages**: `page.vars.js` plus builder vars from frontmatter. + - **HTML pages**: `page.vars.js`. + - **JS pages**: exported `vars` plus `page.vars.js`. + +Global data is not a variable-resolution layer. +It is resolved separately and projected into each consumer's `data` argument according to `dataDeps`. + +## Watch mode + +Running `domstack --watch` or `domstack -w` performs an initial build, watches the source inputs, and serves `dest` with live reload. +Use `domstack --watch-only` when another process serves the output. + +Watch mode coordinates three independent watchers: + +- **esbuild** uses `context.watch()` for global, layout, and page client bundles, styles, page-scoped Web Workers, and the site service worker. +- **chokidar** watches page, layout, template, generated-pages, variable, and settings modules. + DOMStack uses the changed file and its dependency maps to choose a rebuild scope. +- **cpx2** watches static assets under `src` and directories supplied with `--copy`, copying or removing their destination files directly. + +Chokidar events pass through a pure planner before any rebuild executes. +The planner reads an explicit snapshot of discovery, dependency maps, and the previous page-build outcome; it does not perform I/O or mutate that state. +`DomStack` owns the watch session, serializes events, executes plans, and releases its watchers, esbuild context, and server on shutdown. + +
    +flowchart TD
    +  accTitle: Watch planning and execution
    +  accDescr: Serialized Chokidar events produce a pure watch plan. DOMStack executes it, replans after bundle discovery when needed, and retains the last successful routing after page-build errors.
    +  EVENT["Chokidar event"] --> QUEUE["Serialize in the watch session"]
    +  subgraph PLANNING["Pure planning Β· no I/O"]
    +    direction TB
    +    CLASSIFY["Shared file conventions"]
    +    PLAN["`**planWatchEvent()**
    +Inspect watch snapshot`"]
    +    CLASSIFY --> PLAN
    +  end
    +  QUEUE --> PLANNING
    +  PLANNING --> EXECUTE{"Execute plan"}
    +  EXECUTE -->|Skip| SKIP["No page rebuild"]
    +  EXECUTE -->|Full| FULL["`Rediscover inputs
    +Restart esbuild`"]
    +  EXECUTE -->|Restart| RESTART["`Rediscover bundle entries
    +Restart esbuild`"]
    +  RESTART --> BUNDLE["`**planBundleChange()**
    +Use refreshed discovery
    +Keep successful layout routing`"]
    +  BUNDLE --> EXECUTE
    +  EXECUTE -->|Pages| PAGES["`**buildPages()**
    +Full or filtered page phase`"]
    +  FULL --> PAGES
    +  PAGES --> SUCCESS{"Page build succeeded?"}
    +  SUCCESS -->|Yes| SAVE["`Reconcile owned outputs
    +Refresh routing and subscriptions`"]
    +  SUCCESS -->|No| RETAIN["`Keep successful ownership and routing
    +Require a full page retry`"]
    +
    + +Bundle replanning returns only a page plan or a skip; it does not restart esbuild again. +After a page-build failure, the next page-producing plan retries the complete page phase rather than trusting incremental filters. +Manifest-settings changes and service-worker entry additions or removals retain their intentional page-phase skips. +The one-shot manifest pipeline is not part of watch execution. + +> [!NOTE] +> The filenames below use `.ts` by default. +You can also use `.js`, and TypeScript client bundles can use `.tsx`. +See [Supported file types](../../docs/typescript/#supported-file-types) for all available extensions. + +DOMStack uses these rebuild scopes: + +- **esbuild only**: esbuild updates an existing browser entry without rendering HTML. +- **Targeted page/template rebuild**: DOMStack renders only the affected source-backed pages or templates. +- **Targeted generated-pages rebuild**: DOMStack renders and reconciles only the outputs owned by affected `*.pages.ts` files. +- **Full page/template rebuild**: DOMStack renders every source-backed and generated page and every template without restarting esbuild. +- **Full rebuild**: DOMStack rediscovers the source tree, restarts esbuild, renders all pages and templates, and refreshes its dependency maps. + +Like templates, generated-pages modules rebuild when their own source or imported dependencies change. +When a targeted build recomputes global data, DOMStack compares top-level values with the previous successful build and adds only subscribers of changed keys to the rebuild set. + +### What triggers what + +| Change | Rebuild scope | +|---|---| +| Existing `page.ts`, `page.html`, `page.md`, or adjacent `page.vars.ts` | That page, plus subscribers of any changed global-data keys | +| A module imported by a TypeScript page or `page.vars.ts` | Pages that depend on it, plus subscribers of any changed global-data keys | +| Existing `*.layout.ts` or a module it imports | Source-backed pages and generated-page owners using the affected layout | +| Existing `*.template.ts` or a module it imports | Affected templates | +| Existing `*.pages.ts` | Generated outputs owned by that file, then refresh dependency maps | +| A module imported by `*.pages.ts` | Generated outputs owned by the importing files, then refresh dependency maps | +| `markdown-it.settings.ts` | All source-backed Markdown pages, plus subscribers of any changed global-data keys | +| `global.data.ts` | Consumers subscribed to top-level keys whose values changed | +| `global.vars.ts` or `esbuild.settings.ts` | Full rebuild | +| `domstack-manifest.settings.ts` | No rebuild. The manifest pipeline is disabled in watch mode | +| Existing client, style, Web Worker, or service-worker entry | esbuild only, unless the same module also has server-side consumers | +| Static asset under `src` or a file under a `--copy` directory | cpx2 copies or removes the output directly | + +Adding or removing a file changes the set of discovered build inputs: + +| Added or removed file | Rebuild scope | +|---|---| +| Site `service-worker.ts` | Restart esbuild. No page rebuild | +| `global.client.ts` or `global.css` | Restart esbuild and rebuild all pages | +| Layout client or style | Restart esbuild and rebuild source-backed pages and generated-page owners using that layout | +| Page client, style, or Web Worker | Restart esbuild and rebuild that page | +| Any other page, layout, template, generated-pages, variable, or settings file | Full rebuild | + +When a full page/template rebuild or targeted generated-pages rebuild no longer claims an output from the previous successful build, DOMStack removes that obsolete page or template output from `dest` without touching outputs owned by unaffected files. + +### Dependency tracking + +DOMStack uses [`@11ty/dependency-tree-typescript`](https://github.com/11ty/dependency-tree-typescript) to statically analyze ESM imports. +It maintains maps for: + +- Layout dependencies, source-backed pages using each layout, and generated-page owner layout membership +- TypeScript pages and adjacent page-variable dependencies +- Template dependencies +- Generated-pages module dependencies +- Current esbuild entry points + +The maps are created after the initial build and refreshed after successful page builds and structural rediscovery. +Layout routing and generated-output ownership use reports from successful page builds. +Dependency analysis is best-effort. +When DOMStack cannot safely determine a targeted scope, it falls back to a broader rebuild or skips an unrelated changed module. + +esbuild tracks browser-entry dependencies independently. +Changing a module imported only by `client.ts` rebundles that entry without rendering page HTML. +When a module has both browser and server-side consumers, the planner unions the server-side consumers rather than skipping the page phase. + +### Stable entry filenames + +Watch mode uses stable filenames for esbuild entry outputs: + +```text +[dir]/[name] +``` + +Production builds use content-hashed entry filenames: + +```text +[dir]/[name]-[hash] +``` + +Shared chunks remain content-hashed in both modes: + +```text +chunks/[ext]/[name]-[hash] +``` + +Page HTML points to stable entry files during watch mode. esbuild can update an entry and its chunk imports without requiring DOMStack to render the page again. + +### Manifest behavior + +Watch mode builds and rebundles the site service worker, but it does not finalize, return, or write the [DOMStack manifest](../../docs/workers/#domstack-manifest). +Changes to `domstack-manifest.settings.ts` therefore do not trigger a watch rebuild. + +Use `domstack --serve` when testing manifest-driven cache behavior. +It runs a one-shot build and serves the result without watch-mode filenames or live-reload HTML injection. +Add `--domstackManifest` only when the service worker or test needs the public `domstack-manifest.json` file. + +### Build serialization + +Chokidar events are serialized through a promise chain. +Each page rebuild or esbuild restart completes before the next queued filesystem event is processed, preventing overlapping DOMStack rebuilds during rapid saves. diff --git a/docs/implementation/client.ts b/docs/implementation/client.ts new file mode 100644 index 00000000..80b35627 --- /dev/null +++ b/docs/implementation/client.ts @@ -0,0 +1,24 @@ +/// + +import mermaid from 'mermaid' + +mermaid.initialize({ + startOnLoad: true, + // Keep explicit line breaks and parallel lanes instead of shrinking a wide + // graph to fit the article. The page stylesheet provides horizontal scrolling. + markdownAutoWrap: false, + htmlLabels: false, + flowchart: { + useMaxWidth: false, + curve: 'linear', + nodeSpacing: 24, + rankSpacing: 36, + padding: 12, + wrappingWidth: 320, + subGraphTitleMargin: { top: 8, bottom: 16 }, + }, + themeVariables: { + fontFamily: 'system-ui, sans-serif', + lineColor: 'currentColor' + } +}) diff --git a/docs/implementation/style.css b/docs/implementation/style.css new file mode 100644 index 00000000..18d6e7f3 --- /dev/null +++ b/docs/implementation/style.css @@ -0,0 +1,36 @@ +@layer domstack.page { + .mermaid { + max-inline-size: 100%; + overflow-x: auto; + padding: 1rem; + border: 1px solid var(--site-border); + border-radius: 0.4rem; + background: var(--background); + box-shadow: none; + } + + .mermaid > svg { + display: block; + /* Preserve Mermaid's intrinsic dimensions so text stays readable. */ + max-inline-size: none; + max-width: none; + margin-inline: auto; + } + + /* Mermaid embeds ID-scoped theme rules in each SVG. Override only its + surfaces and labels so diagrams follow the site's live light/dark theme. */ + .mermaid .node :is(rect, polygon, circle, path), + .mermaid .edgeLabel rect { + fill: var(--background) !important; + stroke: var(--site-muted) !important; + } + + .mermaid .cluster rect { + fill: color-mix(in srgb, var(--text) 3%, var(--background)) !important; + stroke: var(--site-muted) !important; + } + + .mermaid text { + fill: var(--text) !important; + } +} diff --git a/docs/layouts/README.md b/docs/layouts/README.md new file mode 100644 index 00000000..7f887c52 --- /dev/null +++ b/docs/layouts/README.md @@ -0,0 +1,375 @@ +--- +layout: docs +docsOrder: 40 +handlebars: false +--- + +# Layouts + +Layouts wrap page content in shared HTML and can contribute variables, data subscriptions, styles, and browser code. +Use a single root layout for a simple site, or declare parent layouts to share structure across sections. +For a complete working example, see [Compose nested layouts](../cookbook/nested-layouts/). + +## Table of Contents + +[[toc]] + +## Selecting a layout + +Layouts are "outer page templates" that pages get rendered into. +You can define as many as you want, and they can live anywhere in the `src` directory. + +Layouts are named `${layout-name}.layout.ts` where `${layout-name}` becomes the name of the layout. +Layouts should have a unique name, and layouts with duplicate names result in a build error. + +> [!NOTE] +> Wherever you see `.layout.ts` being used, you can also use `.layout.js`. +Type checking is supported in both file types. +See [Supported file types](../typescript/#supported-file-types) for all available extensions. + +Example layout file names: + +```bash +src/layouts/root.layout.ts # this layout is referenced as 'root' +src/other-layouts/article.layout.ts # this layout is referenced as 'article' +``` + +DOMStack ships a default `root` layout, so defining one in your `src` directory is optional, though recommended. +Owning your own root layout will make DOMStack updates easier, and give you more control over your site. + +All pages have a `layout` variable that defaults to `root`. +If you set the `layout` variable to a different name, pages will build with a layout matching the name you set to that variable. + +The following markdown page would be rendered using the `article` layout. + +```md +--- +layout: 'article' +title: 'My Article Title' +--- + +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. +Filenames determine layout names, but nesting is an explicit module declaration, not a directory or import convention. + +## Layout module exports + +DOMStack recognizes these exports from a layout module: + +| Export | Required | Contract | +| --- | --- | --- | +| `default` | Yes | A synchronous or asynchronous [layout render function](#layout-render-function). | +| `vars` | No | An object, or a sync/async function returning an object, providing [layout defaults](#layout-variables). | +| `parentLayout` | No | A non-empty string naming the immediate outer layout; see [Declaring nested layouts](#declaring-nested-layouts). | + +## Declaring nested layouts + +Declare a parent with a named `parentLayout` export in the child layout module: + +```ts +// src/layouts/article.layout.ts +import type { LayoutFunction } from '@domstack/static/types.js' + +export const parentLayout = 'root' + +const articleLayout: LayoutFunction, string, string> = ({ children }) => { + return `
    ${children}
    ` +} + +export default articleLayout +``` + +`parentLayout` is a layout name, not a file path or imported function. +For example, `'root'` resolves the discovered `root.layout.ts` or `root.layout.js`, wherever it lives under `src`, or DOMStack's bundled root when no custom root exists. +Names are matched exactly, using the same filename-derived names as the page's `layout` variable. + +Omit `parentLayout` (or export `undefined`) when the layout has no parent; DOMStack does not automatically wrap a selected non-root layout in `root`. + +DOMStack renders the page, passes its result to `article`, then passes that result to `root`: `root(article(page()))`. +Each parent can declare another parent, forming a chain that ends at a layout without `parentLayout`. +Missing parents and cycles, including a layout naming itself, fail the build. + +Every render step is awaited, and each parent receives its immediate child's return value as `children` without intermediate string conversion. +The outermost result is converted to a string for HTML output. +All layouts receive the same final resolved page vars, metadata, and asset lists. +Layout defaults merge outermost-to-innermost before page overrides, and ancestor CSS/client entries are included automatically between global and page assets. +Watch mode tracks the resolved chain and each layout's static imports for source-backed and generated pages, updating those relationships after successful rebuilds. + +Each layout can also declare its own [global-data subscriptions](../data/#data-subscriptions) through `vars.dataDeps`. +DOMStack passes only those declared keys to that layout's `data` argument; a child does not receive its parent's data or need to repeat its declarations. +For rebuilds, the page depends on the union of its own subscriptions and every layout's subscriptions in the declared chain. +See [Data subscriptions in nested layouts](../cookbook/nested-layouts/#data-subscriptions-in-nested-layouts) for typed declarations and examples. + +See [Compose nested layouts](../cookbook/nested-layouts/) for a complete example and asset guidance. + +## Layout variables + +Layouts may also export an optional [`vars` variable provider](../pages/#variable-providers) containing defaults for pages that use the layout: + +```ts +export const vars = { + showSidebar: true, + pageType: 'article', +} +``` + +Layout vars are merged into the resolved variable cascade for pages using that layout. +Precedence is: + +```txt +page/frontmatter vars > page.vars.* > inner layout vars > outer layout vars > global.vars > domstack defaults +``` + +This makes layout vars useful for section-wide defaults while still letting individual pages override them. + +## Layout render function + +A layout's default export is an async or sync function that wraps its `children` in an outer template. +With nested layouts, `children` is the result of the immediately inner layout, or the page itself for the innermost layout. + +It is always passed a single object argument with the following entries. +See [Page data and introspection](../data/#page-data-and-introspection) for details about `page`, and [Global data](../data/#global-data) for `data`: + +- `vars`: The resolved page variable cascade, including domstack defaults, global vars, layout vars, page vars, and page builder vars/frontmatter. + Pages can customize layouts by overriding global or layout defaults. +- `data`: Only the top-level global-data keys declared by this layout through `vars.dataDeps`. +- `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`: The immediate child's render result: the page's content for the innermost layout, or the next inner layout's return value for a parent. +Markdown and HTML pages return strings; TypeScript pages and nested layouts may return other values. +- `page`: An object with metadata and other facts about the current page being rendered into the template. + +## The default `root.layout.ts` + +The default `root.layout.ts` is featured below, and is implemented with [`fragtml`][fragtml], though it could just be done with a template literal or any other template system that runs in Node.js. +See the [`fragtml` docs][fragtml-docs] for escaping, raw HTML, rendering, and fragment usage. + +`root.layout.ts` can live anywhere in the `src` directory. + +```typescript +import { html, raw, render } from 'fragtml' +import type { HtmlResult } from 'fragtml/types.js' +import type { LayoutFunction } from '@domstack/static/types.js' + +type RootLayoutVars = { + title: string, + siteName: string, + defaultStyle: boolean, + basePath?: string +} + +export const vars = { + defaultStyle: true, +} + +const defaultRootLayout: LayoutFunction = ({ + vars: { + title, + siteName = 'Domstack', + basePath, + /* defaultStyle = true Set this to false in global or page vars to disable the default style in the default layout */ + }, + scripts, + styles, + children, + data, + page, +}) => { + return render(html` + + + + + ${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName} + + + ${scripts + ? scripts.map(script => html``) + : null} + ${styles + ? styles.map(style => html``) + : null} + + +
    ${typeof children === 'string' ? raw(children) : children}
    + + + `) +} + +export default defaultRootLayout +``` + +If your `src` folder doesn't have a `root.layout.ts` file somewhere in it, `domstack` will use the default [`default.root.layout.js`](https://github.com/bcomnes/domstack/blob/master/lib/defaults/default.root.layout.js) file it ships. +The default `root` layout includes a special boolean variable called `defaultStyle` that lets you disable a default page style (provided by [mine.css](http://github.com/bcomnes/mine.css)) that it ships with. + +## Layout styles + +You can create a `${layout-name}.layout.css` next to any layout file. +While the layout file can live anywhere in `src`, the layout style must live next to the associated layout file. + +```css +/* /layouts/article.layout.css */ +.layout-specific-class { + color: blue; + + & .button { + color: purple; + } +} + +/* This layout style is included in every page rendered with the 'article' layout */ +``` +Layout styles are loaded on all pages that use that layout directly or through a `parentLayout` chain. +Layout styles are bundled with [`esbuild`][esbuild] and can bundle relative and `npm` css using css `@import` statements. + +DOMStack loads stylesheets in this order: optional defaults, global, outermost-to-innermost layouts, then page. +Under the normal [CSS cascade](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade), later styles take precedence when origin, importance, cascade layer, and specificity are otherwise equal. +This lets page styles override layout styles, and inner layout styles override outer layout styles. +See [Global bundles](../global-bundles/#optional-cascade-layers) for optional cascade-layer conventions. + +## Layout client bundles + +You can create a `${layout-name}.layout.client.ts` next to any layout file. +While the layout file can live anywhere in `src`, the layout client bundles must live next to the associated layout file. + +> [!NOTE] +> Use `${layout-name}.layout.client.tsx` when a layout client bundle contains JSX. +You can also use `.jsx`. +See [Supported file types](../typescript/#supported-file-types) for all available extensions and [`.tsx` client bundles](../pages/#.tsx) for JSX configuration. + +```typescript +/* /layouts/article.layout.client.ts */ + +console.log('I run on every page rendered with the \'article\' layout') + +/* This layout client is included in every page rendered with the 'article' layout */ +``` + +Layout client bundles are loaded on all pages that use that layout directly or through a `parentLayout` chain. +Layout client bundles are built with [`esbuild`][esbuild] and can bundle relative and `npm` modules using ESM `import` statements. + +## Layout types + +Layouts can be typed using `LayoutFunction` where: + +- `T` is the variables type +- `U` is the immediate child's render result, from a page or nested layout (defaults to `any`) +- `V` is the layout's return type (defaults to `string` for HTML output) +- `D` is the declared global-data shape (defaults to `Record`) + +```typescript +import type { LayoutFunction } from '@domstack/static/types.js' +import type { HtmlResult } from 'fragtml/types.js' +import { html, raw, render } from 'fragtml' + +type ArticleLayoutVars = { + title: string + showSidebar: boolean +} + +const articleLayout: LayoutFunction = ({ + vars, + children, +}) => { + return render(html` +
    +

    ${vars.title}

    + ${typeof children === 'string' ? raw(children) : children} + ${vars.showSidebar ? html`` : null} +
    + `) +} + +export default articleLayout +``` + +## Custom layout renderers + +DOMStack's bundled default layout uses [`fragtml`][fragtml] because the default template only needs safe string manipulation. +You can eject or replace that layout with any Node-compatible renderer that returns an HTML string. +The previous incumbent for this job was `htm/preact` with [`preact-render-to-string`](https://github.com/preactjs/preact-render-to-string). +That is still a good fit when your Node-side pages or layouts produce Preact VNodes, or when you want the same component model on the server and in browser bundles. +If you also want Preact or React in browser JSX/TSX bundles, configure that separately as described in [`.tsx`](../pages/#.tsx). + +```console +npm install htm preact preact-render-to-string +``` + +```js +/** + * @import { LayoutFunction } from '@domstack/static/types.js' + * @import { VNode } from 'preact' + */ +import { html } from 'htm/preact' +import { render } from 'preact-render-to-string' + +/** @type {LayoutFunction, string | VNode, string>} */ +export default function rootLayout ({ children, vars, scripts, styles }) { + return ` +${render(html` + + ${vars.title} + ${styles?.map(style => html``)} + ${scripts?.map(script => html``)} + + + ${typeof children === 'string' + ? html`
    ` + : html`
    ${children}
    `} + +`)}` +} +``` + +[`preact-render-to-string`](https://github.com/preactjs/preact-render-to-string) works, but it builds a virtual DOM tree just to serialize layout HTML. +For layouts that mostly combine strings and already-rendered page content, [`async-htm-to-string`](https://github.com/voxpelli/async-htm-to-string) keeps the familiar HTM tagged-template style while rendering directly to strings. +That can be a better-performing and more direct tool for server-only layout templates. +You can still use Preact for browser-side components and use `async-htm-to-string` for Node-side layout rendering. + +```console +npm install async-htm-to-string +``` + +```js +/** + * @import { LayoutFunction } from '@domstack/static/types.js' + */ +import { html, rawHtml } from 'async-htm-to-string' + +/** @type {LayoutFunction, string, Promise>} */ +export default async function rootLayout ({ children, vars, scripts, styles }) { + return await html` + + + ${vars.title} + ${styles?.map(style => html``)} + ${scripts?.map(script => html``)} + + +
    ${rawHtml(children)}
    + +` +} +``` + +Key differences from `htm/preact` and DOMStack's `fragtml` default: + +- **Attribute names are standard HTML.** +Use `class` and `for` rather than React aliases like `className` and `htmlFor`, which `async-htm-to-string` will output literally with no warning. +For attributes like `tabindex`, `tabIndex` is only a casing preference in HTML, but using standard lowercase keeps templates consistent. +- **Always `await` the `html` tag.** +The tag returns an object that resolves to a string asynchronously. +If you return it without `await` from a non-async function, or assign it where a string is expected, you will get `[object Object]` in the output with no error thrown. +Use `async function` and `await` the result. + +> [!CAUTION] +> `rawHtml()` bypasses HTML escaping and is equivalent to setting `innerHTML` directly. +Only use it with trusted HTML that you generated or sanitized yourself, such as the output of `await page.renderInnerPage()` or a trusted Markdown renderer. +`children` passed to a layout can be any type returned by a page function and may contain unsanitized content; always verify its source before passing it to `rawHtml()`. + +[fragtml]: https://www.npmjs.com/package/fragtml +[fragtml-docs]: https://github.com/bcomnes/fragtml#readme +[esbuild]: http://esbuild.github.io diff --git a/docs/migrations/README.md b/docs/migrations/README.md new file mode 100644 index 00000000..772224c5 --- /dev/null +++ b/docs/migrations/README.md @@ -0,0 +1,12 @@ +--- +layout: docs +docsOrder: 160 +--- + +# Migrations + +Use these guides when upgrading an existing DOMStack site. +If you are migrating from `top-bun`, follow the v11 guide before applying the v12 changes. + +- [v12 migration](v12-migration.md): Upgrade from DOMStack v11 to v12. +- [v11 migration](v11-migration.md): Migrate from `top-bun` to DOMStack v11. diff --git a/docs/v11-migration.md b/docs/migrations/v11-migration.md similarity index 95% rename from docs/v11-migration.md rename to docs/migrations/v11-migration.md index e19d7cff..36624f45 100644 --- a/docs/v11-migration.md +++ b/docs/migrations/v11-migration.md @@ -1,6 +1,17 @@ -# Migration Guide: top-bun β†’ domstack +--- +layout: docs +docsOrder: 20 +docsParent: /docs/migrations/ +docsPageOnly: true +--- + +# v11 migration + +[All migrations](./) -This guide covers all breaking changes introduced in the `next` branch relative to `master`, documenting what needs to change when migrating from `top-bun` to `domstack` (`@domstack/static`). +Migrate a `top-bun` project to DOMStack v11 (`@domstack/static`) using this historical guide. +It covers the package rename and the accompanying changes to commands, types, and file conventions. +For v12, apply the [v12 migration](v12-migration.md) afterward. ## Table of Contents @@ -190,7 +201,9 @@ Two new filenames are now recognized and processed by domstack. If you have exis ### `global.data.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`) -Now treated as the global data aggregation file. Its default export is called with `{ pages }` after source-backed pages are initialized and before generated-page factories run. See [section 7](#7-postvars-removed--globaldatajs) above. +Now treated as the global data aggregation file. +Its default export is called with `{ pages }` after source-backed pages are initialized and before generated-page factories run. +See [section 7](#7.-postvars-removed-%E2%86%92-global.data.js) above. ### `markdown-it.settings.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`) diff --git a/docs/v12-migration.md b/docs/migrations/v12-migration.md similarity index 94% rename from docs/v12-migration.md rename to docs/migrations/v12-migration.md index 0a870f04..a4e26239 100644 --- a/docs/v12-migration.md +++ b/docs/migrations/v12-migration.md @@ -1,4 +1,13 @@ -# Migration Guide: domstack v12 +--- +layout: docs +docsOrder: 10 +docsParent: /docs/migrations/ +docsPageOnly: true +--- + +# v12 migration + +[All migrations](./) This guide covers breaking and notable changes when moving from domstack v11 to v12. @@ -13,17 +22,19 @@ Then apply the v12 changes below. ## Runtime requirements -DOMStack v12 supports Node.js 22 and Node.js 24 or newer: +DOMStack v12 supports Node.js 22.18+ within the 22.x release line, and Node.js 24 or newer: ```json { "engines": { - "node": "^22.0.0 || >=24.0.0" + "node": "^22.18.0 || >=24.0.0" } } ``` -Node.js 23 satisfied v11's `>=22` engine range but is not supported by v12. Move development, CI, and deployment environments to Node.js 22 LTS or Node.js 24+ before upgrading. +Node.js 23 satisfied v11's `>=22` engine range but is not supported by v12. +Move development, CI, and deployment environments to Node.js 22.18+ within the 22.x release line, or Node.js 24+ before upgrading. +The minimum matches `@domstack/sync` and enables native TypeScript type stripping without an experimental flag. --- @@ -122,7 +133,7 @@ Layouts can now export a static `parentLayout` name instead of importing and inv Pages still select the innermost layout through `vars.layout`. `parentLayout` is an optional named string export, not a field in `vars`, an import path, or a callback. Omitting it leaves the selected layout without a parent; a non-root layout is not automatically wrapped by `root`. -See the [layout module reference](../README.md#layout-module-exports) and [nested-layout declaration contract](../README.md#declaring-nested-layouts) for name resolution, validation, rendering order, and rebuild behavior. +See the [layout module reference](../layouts/#layout-module-exports) and [nested-layout declaration contract](../layouts/#declaring-nested-layouts) for name resolution, validation, rendering order, and rebuild behavior. ```ts // article.layout.ts @@ -369,7 +380,9 @@ Update any prerelease-based code that reads global data from `vars` or accepts ` - `renderInnerPage()` renders page content without its layout - `renderFullPage()` renders the complete page -The resolved `page.vars` object is cached and shallow-frozen. Treat it as read-only rather than mutating it during collection processing. See [Page data and introspection](../README.md#page-data-and-introspection) for examples and rendering guidance. +The resolved `page.vars` object is cached and shallow-frozen. +Treat it as read-only rather than mutating it during collection processing. +See [Page data and introspection](../data/#page-data-and-introspection) for examples and rendering guidance. --- @@ -384,7 +397,9 @@ Most Markdown pages require no source changes. Sites should compare rendered out - Depend on exact generated HTML in CSS, tests, or content transforms - Use definition lists or unusual YAML frontmatter values -The alert plugin provides markup, not site-specific presentation. Import its styles or provide equivalent rules if you use alert blocks. See [Markdown settings](../README.md#markdown-itsettingsts) for DOMStack's default plugin list and override API. +The alert plugin provides markup, not site-specific presentation. +Import its styles or provide equivalent rules if you use alert blocks. +See [Markdown settings](../settings/#markdown-it.settings.ts) for DOMStack's default plugin list and override API. --- @@ -418,7 +433,7 @@ They do not have page-local `style.css`, `client.ts`, or `*.worker.ts` assets be Factories receive global vars, their declared global data, and metadata for their own `*.pages.ts` file. They do not receive raw source-backed or generated pages. Likewise, `results.siteData.pages` remains source discovery data and does not include generated pages. -See [Generated Pages](../README.md#generated-pages) for all export forms, types, and lifecycle details. +See [Generated pages](../generation/#generated-pages) for all export forms, types, and lifecycle details. --- @@ -444,7 +459,9 @@ test('builds the home page', async () => { }) ``` -This is additive. Existing tests that construct `DomStack` directly can continue to do so. See [Programmatic test builds](../README.md#programmatic-test-builds) for the complete return shape and repository examples. +This is additive. +Existing tests that construct `DomStack` directly can continue to do so. +See [Programmatic test builds](../api/#test-builds) for the complete return shape and repository examples. --- @@ -562,7 +579,7 @@ This lets DOMStack inject the finalized `manifest.version` into `/service-worker ## Migration checklist -- [ ] Run development, CI, and deployment builds on Node.js 22 LTS or Node.js 24+. Do not use Node.js 23. +- [ ] Run development, CI, and deployment builds on Node.js 22.18+ within the 22.x release line, or Node.js 24+. Do not use Node.js 23. - [ ] 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 use the new `--serve` preview, keep it separate from watch modes and use `--port` only with `--serve`. diff --git a/docs/pages/README.md b/docs/pages/README.md new file mode 100644 index 00000000..b3868307 --- /dev/null +++ b/docs/pages/README.md @@ -0,0 +1,406 @@ +--- +layout: docs +docsOrder: 30 +handlebars: false +--- + +# Pages + +A page combines source content with a layout, variables, and optional browser assets. +This guide explains how to arrange those files and how DOMStack turns them into HTML at matching URLs. + +## Table of Contents + +[[toc]] + +## Page files + +Pages are named directories inside `src` with **one of** the following page files: + +- `md` pages are [CommonMark](https://commonmark.org) markdown pages, with an optional [YAML](https://yaml.org) front-matter block. +- `html` pages are an inner [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML) fragment that get inserted into the page layout. +- `ts` pages are [TypeScript](https://developer.mozilla.org/en-US/docs/Glossary/TypeScript) files that export a default function that resolves into an inner HTML fragment inserted into the page layout. + +> [!NOTE] +> A **source-backed page** is discovered directly from a page file in `src`, rather than created by a `*.pages.ts` module. +Source-backed pages exist before `global.data.ts` and [Generated pages](../generation/#generated-pages) run. + +Variables are available in all pages. +`md` and `html` pages support variable access via [handlebars][hb] template blocks. +`ts` pages receive variables as part of the argument passed to them. +See the [Variables](../../docs/pages/#variables) section for more info. + +Pages can define a special variable called [`layout`](../layouts/#selecting-a-layout) that determines which layout the page is rendered into. + +Because pages are just directories, they nest and structure naturally as a filesystem router. +Directories in the `src` folder that lack one of these special page files can exist alongside page directories and can be used to store co-located code or static assets without conflict. + +### `md` pages + +A `md` page looks like this on the filesystem: + +```bash +src/page-name/page.md +# or +src/page-name/README.md +# or +src/page-name/loose-md.md +``` + +- `md` pages have three types: a `page.md`, a `README.md`, or a loose `whatever-name-you-want.md` file. +- `page.md` and `README.md` files transform to an `index.html` at the same path. + When both exist in the same directory, `page.md` takes precedence over `README.md`. + `whatever-name-you-want.md` loose markdown files transform into `whatever-name-you-want.html` files at the same path in the `dest` directory. +- `md` pages can have [YAML](https://yaml.org/) [frontmatter](https://docs.github.com/en/contributing/writing-for-github-docs/using-yaml-frontmatter), with variables that are accessible to the page layout and handlebars template blocks when building. +- You can include HTML in markdown files, so long as you adhere to the allowable markdown syntax around html tags. +- `md` pages support [handlebars][hb] template placeholders. +- You can disable `md` page [handlebars][hb] processing by setting the `handlebars` variable to `false`. +- `md` pages support many [github flavored markdown features](https://github.com/bcomnes/domstack/blob/master/lib/build-pages/page-builders/md/get-md.js#L25-L36). + +An example of a `md` page: + +```markdown +--- +title: A title for a markdown page +favoriteColor: 'Blue' +--- + +Just writing about web development. + +## Favorite colors + +My favorite color is {{ vars.favoriteColor }}. +``` + +### `html` pages + +A `html` page looks like this: + +```bash +src/page-name/page.html +``` + +- `html` pages are named `page.html` inside an associated page folder. +- `html` pages are the simplest page type in `domstack`. + They let you build with raw html for when you don't want that page to have access to markdown features. + Some pages are better off with just raw `html`, and the rules with building `html` in a real `html` file are much more flexible than inside of a `md` file. +- `html` page variables can only be set in a `page.vars.ts` file inside the page directory. +- `html` pages support [handlebars][hb] template placeholders. +- You can disable `html` page [handlebars][hb] processing by setting the `handlebars` variable to `false`. + +An example `html` page: + +```html +

    Favorite frameworks

    +
      +
    • React
    • +
    • Vue
    • +
    • Svelte
    • + +
    • {{ vars.favoriteFramework }}
    • +
    +``` + +### `ts` pages + +A `ts` page looks like this: + +```bash +src/page-name/page.ts +``` + +> [!NOTE] +> Wherever you see `.ts` being used, you can also use `.js`. +Type checking is supported in both file types. +See [Supported file types](../../docs/typescript/#supported-file-types) for all available extensions. + +- `ts` pages consist of a named directory with a `page.ts` file that exports a default function returning the contents of the inner page. +- A `ts` page needs to `export default` a function (async or sync) that accepts a variables argument and returns a string of the inner HTML of the page, or any other type that your layout can accept. +- You can specify the return type using `PageFunction` where `T` is the variables type, `U` is the return type (defaults to `any`), and `D` is the declared global-data shape. +- A `ts` page can export a [`vars` variable provider](../../docs/pages/#variable-providers) that takes highest variable precedence when rendering the page. + `export vars` is similar to a `md` page's front matter. +- A `ts` page receives the standard `domstack` [Variables](../../docs/pages/#variables) set. +- There is no built-in Handlebars support in `ts` pages; however, you are free to use any template library that you can import. +- `ts` pages run in a Node.js context only. + +An example TypeScript page: + +```typescript +import type { PageFunction } from '@domstack/static/types.js' + +export const vars = { + favoriteCookie: 'Chocolate Chip with Sea Salt' +} + +const page: PageFunction = async ({ + vars +}) => { + return /* html */`
    +

    This is just some html.

    +

    My favorite cookie: ${vars.favoriteCookie}

    +
    ` +} + +export default page +``` + +It is recommended to use some level of template processing over raw string templates so that HTML is well-formed and variable values are properly escaped. +DOMStack's default layout uses [`fragtml`][fragtml], a safe-by-default HTML tagged template library. +Here is a more realistic TypeScript example that uses `fragtml` and an explicit global-data subscription. + + +```typescript +import { html } from 'fragtml' +import type { HtmlResult } from 'fragtml/types.js' +import type { PageFunction } from '@domstack/static/types.js' + +type BlogVars = { + favoriteCake: string +} + +type BlogData = { + blogYears: number[] +} + +export const vars = { + favoriteCake: 'Chocolate Cloud Cake', + dataDeps: ['blogYears'], +} + +const blogIndex: PageFunction = async ({ + vars: { favoriteCake }, + data, +}) => { + return html`
    +

    I love ${favoriteCake}!!

    + +
    ` +} + +export default blogIndex +``` + +### Page Styles + +You can create a `style.css` file in any page folder. +Page styles are loaded on just that one page. +You can import common use styles into a `style.css` page style using css [`@import`](https://developer.mozilla.org/en-US/docs/Web/CSS/@import) statements to re-use common css. +You can `@import` paths to other css files, or out of `npm` modules you have installed in your projects `node_modues` folder. +`css` page bundles are bundled using [`esbuild`][esbuild]. + +An example of a page `style.css` file: + +```css +/* /some-page/style.css */ +@import "some-npm-module/style.css"; +@import "../common-styles/button.css"; + +.some-page-class { + color: blue; + + & .button { + color: purple; + } +} +``` + +### Page client bundles + +You can create a `client.ts` file in any page folder. +Page bundles are client-side JavaScript bundles that are loaded on that one page only. +You can import common code and modules from relative paths, or `npm` modules out of `node_modules`. +Page client bundles are bundle-split with every other client-side entry point, so shared code is loaded efficiently. +Page bundles run in a browser context only; however, they can share carefully crafted code that also runs in a Node.js or layout context. +Page bundles are built using [`esbuild`][esbuild]. + +An example of a page `client.ts` file: + +```typescript +/* /some-page/client.ts */ +import { funnyLibrary } from 'funny-library' +import { someHelper } from '../helpers/foo.ts' + +await someHelper() +await funnyLibrary() +``` + +#### `.tsx` + +Client bundles support [`.tsx`](https://www.typescriptlang.org/docs/handbook/jsx.html) through [esbuild's JSX transform](https://esbuild.github.io/content-types/#jsx). + +> [!NOTE] +> Wherever you see `.tsx` being used for a client bundle, you can also use [`.jsx`](https://facebook.github.io/jsx/). +Type checking is supported in both file types. +See [Supported file types](../../docs/typescript/#supported-file-types) for all available extensions. + +> [!IMPORTANT] +> `.tsx` and `.jsx` are supported only in client bundles. +JSX syntax is unavailable in page files, layouts, templates, settings, and anything else that runs in the Node.js context. + +DOMStack does not include a JSX runtime by default. +Install the runtime you want and configure it with `esbuild.settings`. +[Preact][preact] is the recommended JSX runtime for DomStack because it is small, browser-focused, and works well with page-scoped client bundles. +See the [preact-isomorphic](https://github.com/bcomnes/domstack/tree/master/examples/preact-isomorphic/) and [react](https://github.com/bcomnes/domstack/tree/master/examples/react/) examples for complete projects. + +To use Preact in browser TSX bundles, add it to your project and opt into Preact's automatic JSX runtime: + +```console +npm install preact +``` + +```typescript +// src/esbuild.settings.ts +export default async function esbuildSettingsOverride (esbuildSettings) { + esbuildSettings.jsx = 'automatic' + esbuildSettings.jsxImportSource = 'preact' + + return esbuildSettings +} +``` + +If a dependency expects React, you can often swap React for `@preact/compat` with an npm package alias. +This installs `@preact/compat` into `node_modules/react`. +See [Simple TanStack Query in Preact](https://bret.io/blog/2026/simple-tanstack-query-in-preact/) for more details. + +```json +{ + "dependencies": { + "react": "npm:@preact/compat@^18.3.1" + } +} +``` + +React also works if your project needs React-specific APIs or ecosystem packages. +To use React in browser TSX bundles, add React to your project and opt into React's automatic JSX runtime: + +```console +npm install react react-dom +``` + +```typescript +// src/esbuild.settings.ts +export default async function esbuildSettingsOverride (esbuildSettings) { + esbuildSettings.jsx = 'automatic' + esbuildSettings.jsxImportSource = 'react' + + return esbuildSettings +} +``` + +### Page variable files + +Each page can also have an adjacent `page.vars.ts` file that default-exports a [variable provider](../../docs/pages/#variable-providers) containing page-specific variables. + +```typescript +// export an object +export default { + my: 'vars' +} + +// OR export a default function +export default () => { + return { my: 'vars' } +} + +// OR export a default async function +export default async () => { + return { my: 'vars' } +} +``` + +Page variable files have higher precedence than `global.vars.ts` variables, but lower precedence than frontmatter or `vars` exports from `ts` pages. +See [Variables](../../docs/pages/#variables) for the full variable cascade. + +### Draft pages + +A complete draft page can use the same colocated files as a published page: + +```text +src/ +└── blog/ + └── unpublished-post/ + β”œβ”€β”€ page.draft.md # Draft page content + β”œβ”€β”€ page.vars.ts # Page-specific variables + β”œβ”€β”€ client.ts # Page-specific browser code + └── style.css # Page-specific styles +``` + +If you add a `.draft.{md,html,ts}` suffix to any page type, the page is considered a draft page. +Draft pages are not built by default. +If you pass the `--drafts` flag when building or watching, the draft pages will be built. +When draft pages are omitted, they are completely ignored. + +Draft pages can be detected in layouts using the `page.draft === true` or `pages[n].draft === true` variable. +It is a good idea to display something indicating the page is a draft in your templates so you don't get confused when working with the `--drafts` flag. + +> [!NOTE] +> Static assets colocated with draft pages are still copied when drafts are excluded because static assets are processed independently from pages. + +Draft pages let you work on pages before they are ready and easily omit them from a build when deploying pages that are ready. + +## Variables + +Variables combine site-wide defaults with layout and page overrides. +The precedence is page/frontmatter vars, page variable files, inner-to-outer layout vars, global vars, then DOMStack defaults. +See [Settings](../settings/#global.vars.ts) for global defaults and [Layouts](../layouts/#layout-variables) for layout defaults. + +### Variable providers + +DOMStack accepts variable providers anywhere variables can be supplied. +A variable provider is an object or a sync/async function that returns an object. + +Object provider: + +```typescript +// src/global.vars.ts +export default { + siteName: 'My site' +} +``` + +Synchronous function provider: + +```typescript +// src/global.vars.ts +export default function vars () { + return { + siteName: 'My site' + } +} +``` + +Asynchronous function provider: + +```typescript +// src/global.vars.ts +export default async function vars () { + return { + siteName: 'My site' + } +} +``` + +Pages and layouts receive an object with the following parameters: + +- `vars`: An object with the variables of `global.vars.ts`, `page.vars.ts`, layout vars, and any frontmatter or `vars` exports from the page merged together. +- `data`: Only the top-level values selected from [`global.data.ts`](../data/#global-data) by this renderer's own `dataDeps` declarations. +- `page`: The current page's [`PageInfo` metadata](../data/#page-metadata). + +Template files receive a similar set of variables: + +- `vars`: An object with the variables from `global.vars.ts`. +- `data`: Only the top-level values selected from [`global.data.ts`](../data/#global-data) by the template's `dataDeps` named export. +- `template`: Information about the current template file. + +[fragtml]: https://www.npmjs.com/package/fragtml +[preact]: https://preactjs.com/ +[hb]: https://handlebarsjs.com +[esbuild]: http://esbuild.github.io diff --git a/docs/settings/README.md b/docs/settings/README.md new file mode 100644 index 00000000..0bce521a --- /dev/null +++ b/docs/settings/README.md @@ -0,0 +1,235 @@ +--- +layout: docs +docsOrder: 70 +handlebars: false +--- + +# Settings + +Use settings modules to define site-wide variables and customize DOMStack's JavaScript, CSS, and Markdown build tools. +These files can live anywhere under `src`; they configure the build and are not emitted as browser assets. +For shared scripts and styles, see [Global bundles](../global-bundles/). + +Only one file may match each global filename pattern. +When DOMStack discovers a duplicate, it keeps the first file it found, skips the duplicate, and reports a warning. +Define each global file once rather than relying on discovery order. + +Wherever this page uses `.ts`, you can also use `.js`. +See [Supported file types](../typescript/#supported-file-types) for all available extensions. + +## Table of Contents + +[[toc]] + +## `global.vars.ts` + +The `global.vars.ts` file should default-export a [variable provider](../pages/#variable-providers). +The variables in this file are available to all pages, unless the page sets a variable with the same key, taking a higher precedence. +These defaults are separate from the computed, explicitly subscribed values described in [Data](../data/). + +```typescript +export default { + siteName: 'The name of my website', + authorName: 'Mr. Wallace' +} +``` + +### `browser` variable + +`global.vars.ts` can uniquely export a [variable provider](../pages/#variable-providers) named `browser`. +These variables are made available in all client bundles. + +```typescript +export const browser = { + 'process.env.TRANSPORT': 'http', + 'process.env.HOST': 'localhost' +} +``` + +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.settings.ts) while also using the `browser` export will throw an error. +Use one or the other. + +## `esbuild.settings.ts` + +This is an optional file you can create anywhere. +It should export a default sync or async function that accepts a single argument (the esbuild settings object generated by domstack) and returns a modified build object. +Use this to customize the esbuild settings directly. + +Important esbuild settings you may want to set here are: + +- [target](https://esbuild.github.io/api/#target) - Set the `target` to make `esbuild` run a few small transforms on your CSS and JS code. +- [jsx](https://esbuild.github.io/api/#jsx) - Configure how esbuild transforms JSX and TSX. +- [jsxImportSource](https://esbuild.github.io/api/#jsx-import-source) - Set this when using an automatic JSX runtime such as React or Preact. +- [define](https://esbuild.github.io/api/#define) - Define compile-time constants for JS bundles. + Setting `define` here conflicts with the [`browser` export](#browser-variable) in `global.vars.ts` and throws an error if both are set. + +> [!WARNING] +> An invalid esbuild override can break DOMStack's browser build. +Preserve DOMStack's required build options unless you intentionally replace their behavior. + +Here is an example of using this file to polyfill Node.js built-ins in the browser bundle: + +```typescript +import { polyfillNode } from 'esbuild-plugin-polyfill-node' +// BuildOptions re-exported from esbuild +import type { BuildOptions } from '@domstack/static/types.js' + +const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise => { + esbuildSettings.plugins = [polyfillNode()] + return esbuildSettings +} + +export default esbuildSettingsOverride +``` + +### Default build behavior + +DOMStack passes its complete default `BuildOptions` into this function. +The default browser build: + +- Bundles ESM with code splitting enabled +- Emits source maps and an esbuild metafile +- Preserves source-relative directories through `outbase: src` +- Uses `[dir]/[name]-[hash]` for production entry files and stable `[dir]/[name]` filenames in watch mode +- Writes shared chunks to `chunks/[ext]/[name]-[hash]` +- Does not configure a JSX runtime + +Default asset loaders are: + +| Loader | Extensions | Behavior | +|---|---|---| +| `dataurl` | `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, `.webp`, `.avif` | Embeds the imported asset in its bundle | +| `file` | `.ico`, `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf` | Emits a separate file and returns its URL | + +> [!NOTE] +> Images imported by a client bundle are embedded regardless of their size by default. +Use the `file` loader when large images should remain separate files. + +The function's return value becomes the effective esbuild configuration. +Preserve DOMStack's build wiring, including `entryPoints`, `outdir`, and `outbase`, unless you intentionally replace that behavior. +Spread nested options such as `loader` when adding entries because replacing the object discards its existing defaults. +DOMStack preserves its reserved `define` values after the override runs. + +These options also form the basis of the [service-worker](../workers/#service-workers) build. +DOMStack replaces the service-worker entry point and filename and disables code splitting, while options such as plugins, loaders, `target`, and JSX configuration carry over. + +You can return a shallow copy that modifies the defaults when you only need a small change. +For example, this keeps DOMStack's default asset loaders and adds a custom loader for `.wasm` files: + +```typescript +import type { BuildOptions } from '@domstack/static/types.js' + +const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise => { + return { + ...esbuildSettings, + loader: { + ...esbuildSettings.loader, + '.wasm': 'file', + }, + } +} + +export default esbuildSettingsOverride +``` + +If you want full control, reset DOMStack's convenience defaults back to esbuild's defaults while preserving the required DOMStack build wiring (`entryPoints`, `outdir`, `outbase`, etc.). +From there, define only the settings you want: + +```typescript +import type { BuildOptions } from '@domstack/static/types.js' + +const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise => { + return { + ...esbuildSettings, + jsx: undefined, + jsxImportSource: undefined, + loader: { + '.png': 'file', + '.svg': 'text', + }, + } +} + +export default esbuildSettingsOverride +``` + +## `markdown-it.settings.ts` + +This is an optional file you can create anywhere. +It should export a default sync or async function that accepts a single argument (the markdown-it instance configured by domstack) and returns a modified markdown-it instance. +Use this to add custom markdown-it plugins or modify the parser configuration. +Here are some examples: + +```typescript +import markdownItContainer from 'markdown-it-container' +import markdownItPlantuml from 'markdown-it-plantuml' +import type { MarkdownIt } from 'markdown-it' + +const markdownItSettingsOverride = async (md: MarkdownIt) => { + // Add custom plugins + md.use(markdownItContainer, 'spoiler', { + validate: (params: string) => { + return params.trim().match(/^spoiler\s+(.*)$/) !== null + }, + render: (tokens: any[], idx: number) => { + const m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/) + if (tokens[idx].nesting === 1) { + return '
    ' + md.utils.escapeHtml(m[1]) + '\n' + } else { + return '
    \n' + } + } + }) + + md.use(markdownItPlantuml) + + return md +} + +export default markdownItSettingsOverride +``` + +```typescript +import markdownIt, { MarkdownIt } from 'markdown-it' +import myCustomPlugin from './my-custom-plugin' + +const markdownItSettingsOverride = async (md: MarkdownIt) => { + // Create a new instance with different settings + const newMd = markdownIt({ + html: false, // Disable HTML tags in source + breaks: true, // Convert \n to
    + linkify: false, // Disable auto-linking + }) + + // Add only the plugins you want + newMd.use(myCustomPlugin) + + return newMd +} + +export default markdownItSettingsOverride +``` + +By default, DOMStack ships with the following markdown-it plugins enabled: + +- [markdown-it](https://github.com/markdown-it/markdown-it) +- [markdown-it-footnote](https://github.com/markdown-it/markdown-it-footnote) +- [markdown-it-highlightjs](https://github.com/valeriangalliat/markdown-it-highlightjs) +- [markdown-it-emoji](https://github.com/markdown-it/markdown-it-emoji) +- [markdown-it-sub](https://github.com/markdown-it/markdown-it-sub) +- [markdown-it-sup](https://github.com/markdown-it/markdown-it-sup) +- [markdown-it-deflist](https://github.com/markdown-it/markdown-it-deflist) +- [markdown-it-ins](https://github.com/markdown-it/markdown-it-ins) +- [markdown-it-mark](https://github.com/markdown-it/markdown-it-mark) +- [markdown-it-abbr](https://github.com/markdown-it/markdown-it-abbr) +- [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists) +- [markdown-it-github-alerts](https://www.npmjs.com/package/markdown-it-github-alerts) +- [markdown-it-anchor](https://github.com/valeriangalliat/markdown-it-anchor) +- [markdown-it-attrs](https://github.com/arve0/markdown-it-attrs) +- [markdown-it-table-of-contents](https://github.com/cmaas/markdown-it-table-of-contents) diff --git a/docs/typescript/README.md b/docs/typescript/README.md new file mode 100644 index 00000000..da50c81e --- /dev/null +++ b/docs/typescript/README.md @@ -0,0 +1,193 @@ +--- +layout: docs +docsOrder: 100 +handlebars: false +--- + +# TypeScript + +Use TypeScript for pages, layouts, data modules, and browser code without adding a separate compilation step. +Node.js strips types from server-side modules, esbuild handles browser bundles, and `tsc` checks types separately. + +## Table of Contents + +[[toc]] + +## Runtime requirements + +- Use Node.js 22.18+ within the 22.x release line, or Node.js 24 and newer, as required by DOMStack v12. + These versions enable type stripping by default; no `NODE_OPTIONS` flag is needed. +- Seamlessly mix `.ts`, `.mts`, `.cts` files alongside `.js`, `.mjs`, `.cjs`. +- No explicit compilation step neededβ€”Node.js handles type stripping at runtime. +- Fully compatible with existing `domstack` file naming conventions. +- Anywhere DOMStack loads JS files, it can now load TS files. + +## Supported file types + +Anywhere you can use a `.js`, `.mjs`, or `.cjs` file in DOMStack, you can use the corresponding `.ts`, `.mts`, or `.cts` extension. + +> [!TIP] +> Prefer the regular `.ts` and `.js` extensions with [`"type": "module"`](https://nodejs.org/api/packages.html#type) in `package.json`. +Use the module-format escape-hatch extensions only when an individual file must override the package's module format. + +When running in a Node.js context, [type-stripping](https://nodejs.org/api/typescript.html#type-stripping) is used. +When running in a web client context, [esbuild](https://esbuild.github.io/content-types/#typescript) type stripping is used. +Type stripping provides 0 type checking, so be sure to set up `tsc` and `tsconfig.json` so you can catch type errors while editing or in CI. + +## Recommended `tsconfig.json` + +Install [@voxpelli/tsconfig](https://ghub.io/@voxpelli/tsconfig), which enables type checking in `.js` and `.ts` files and configures TypeScript for `--noEmit`. +Extend its Node.js 22 baseline with DOMStack's type-stripping and client-TSX settings: + +```jsonc +// tsconfig.json +{ + "extends": "@voxpelli/tsconfig/node22.json", + "compilerOptions": { + "skipLibCheck": true, + "jsx": "preserve", + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "public", + "coverage" + ] +} +``` + +## Using TypeScript with DOMStack types + +You can use `domstack`'s built-in types to strongly type your layout, page, and template functions. +Runtime values are imported from `@domstack/static`; types are imported from the dedicated `@domstack/static/types.js` entry. +The following types are available: + +```ts +// src/types.ts +import type { + // Type a synchronous or asynchronous layout default export + LayoutFunction, + // Require a layout default export to return a promise + AsyncLayoutFunction, + // Type a synchronous or asynchronous global.data.ts default export + GlobalDataFunction, + // Require a global.data.ts default export to return a promise + AsyncGlobalDataFunction, + // Type a synchronous or asynchronous TypeScript page function + PageFunction, + // Require a TypeScript page function to return a promise + AsyncPageFunction, + // Type a template that returns one or more buffered outputs + TemplateFunction, + // Type an async-generator template that yields outputs incrementally + TemplateAsyncIterator, + // Type a generated-pages factory in a *.pages.ts file + PagesFunction, + + // Describe one initialized entry in the pages collection + PageData, + // Describe metadata for the current page + PageInfo, + // Describe the current *.template.ts file + TemplateInfo, + // Describe the current *.pages.ts file + PagesFileInfo, + // Describe one page returned by a generated-pages module + GeneratedPageDefinition, + + // Type a helper that receives a layout function's arguments + LayoutFunctionParams, + // Type a helper that receives global.data.ts arguments + GlobalDataFunctionParams, + // Type a helper that receives a page function's arguments + PageFunctionParams, + // Type a helper that receives a template function's arguments + TemplateFunctionParams, + // Type a helper that receives a generated-pages factory's arguments + PagesFunctionParams, +} from '@domstack/static/types.js' +``` + +> [!NOTE] +> Use `PageFunction`, `LayoutFunction`, `TemplateFunction`, and `GlobalDataFunction` for ordinary synchronous or asynchronous implementations. +> Their `Async*` variants are available when a type must specifically require a promise return value, including JSDoc annotations directly on async functions. +> `PagesFunction` supports normal functions, `async` functions, and async generators. + +The function types are generic and accept variable shapes that you can develop and share between files. + +The data and parameter types (`PageData`, `PageInfo`, `TemplateInfo`, `PagesFileInfo`, `GeneratedPageDefinition`, and `*FunctionParams`) are useful when you want to annotate variables or helper functions that receive these objects without using the function types directly: + +```ts +// src/page-utils.ts +import type { GlobalDataFunctionParams, PageData, PageInfo } from '@domstack/static/types.js' + +function getPublishedPages({ pages }: GlobalDataFunctionParams): PageData[] { + return pages.filter((p: PageData) => { + const info: PageInfo = p.pageInfo + return !info.draft + }) +} +``` + +### Advanced type parameters + +`PageFunction`, `LayoutFunction`, `TemplateFunction`, and `PagesFunction` support additional type parameters for precise input, data, and return type control: + +**PageFunction** + +- `T` - The type of variables passed to the page (required) +- `U` - The return type of the page function (optional, defaults to `any`) +- `D` - The declared global-data shape (optional, defaults to `Record`) + +**LayoutFunction** + +- `T` - The type of variables passed to the layout (required) +- `U` - The type of content received from pages as `children` (optional, defaults to `any`) +- `V` - The return type of the layout function (optional, defaults to `string`) +- `D` - The declared global-data shape (optional, defaults to `Record`) + +**TemplateFunction** + +- `T` - The global vars passed to the template (required) +- `D` - The declared global-data shape (optional, defaults to `Record`) + +**PagesFunction** + +- `T` - The vars added to generated pages (optional, defaults to `Record`) +- `U` - The static children or inline page-function return type (optional, defaults to `string`) +- `V` - The default and global vars received by the pages factory (optional, defaults to `Record`) +- `D` - The factory's declared global-data shape (optional, defaults to `Record`) +- `P` - Inline pages' declared global-data shape (optional, defaults to `D` for convenience; set it independently when factory and page subscriptions differ) + +Each layout's input, output, and data types are independent of its parent and page. +DOMStack resolves layout names at runtime, so it cannot statically prove that two separately declared layout modules have compatible content types. +Manual function calls do receive normal TypeScript argument checking. + +This allows pages to return custom types (like VDOM or JSON), ensures layouts produce HTML strings, and keeps generated-page vars separate from the vars used to create them: + +```ts +// src/rendering-types.ts +// Define custom types +type VDOMNode = { + type: string + props: Record + children: Array +} + +// Page returns VDOM +const page: PageFunction<{title: string}, VDOMNode> = ({ vars }) => ({ + type: 'h1', + props: {}, + children: [vars.title] +}) + +// Layout accepts VDOM, returns HTML string +const layout: LayoutFunction<{site: string}, VDOMNode, string> = ({ children }) => { + const html = renderVDOM(children) // Convert VDOM to HTML + return `${html}` +} +``` diff --git a/docs/workers/README.md b/docs/workers/README.md new file mode 100644 index 00000000..5bd09dd6 --- /dev/null +++ b/docs/workers/README.md @@ -0,0 +1,398 @@ +--- +layout: docs +docsOrder: 110 +handlebars: false +--- + +# Workers + +Use page-scoped web workers to move work off the browser's main thread, and a site service worker to control requests and caching. +The DOMStack manifest provides an inventory of built files for service-worker policies and other tooling. + +## Table of Contents + +[[toc]] + +## Web workers + +You can easily write [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) for a page by adding a file called `${name}.worker.ts` or `${name}.worker.js` where `name` becomes the name of the worker filename in the `workers.json` file. +DOMStack will build these similarly to page `client.ts` bundles, and will even bundle split their contents with the rest of your site. + +``` +page-directory/ + β”œβ”€β”€ page.js + β”œβ”€β”€ client.js + β”œβ”€β”€ counter.worker.js # Worker with counter functionality + └── data.worker.js # Worker for data processing +``` + +To use a woker, load in a `./workers.json` file that is generated along with the worker bundle to get the final name of the worker entrypoint and then create a worker with that filename. + +```typescript +// First, fetch the workers.json to get worker paths in your client.ts +async function initializeWorkers() { + const response = await fetch('./workers.json'); + const workersData = await response.json(); + + // Initialize workers with the correct hashed filenames + const counterWorker = new Worker( + new URL(`./${workersData.counter}`, import.meta.url), + { type: 'module' } + ); + + // Use the worker + counterWorker.postMessage({ action: 'increment' }); + + counterWorker.onmessage = (e) => { + console.log(e.data); + }; + + return counterWorker; +} + +const worker = await initializeWorkers(); +``` + +See the [Web Workers Example](https://github.com/bcomnes/domstack/tree/master/examples/worker-example) for a complete implementation. + +## Service workers + +DOMStack has full native support for [service workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API). +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.ts +``` + +DOMStack produces: + +```txt +public/ +└── service-worker.js +``` + +> [!NOTE] +> Wherever `service-worker.ts` is used, you can also use `service-worker.js`. +Type checking is supported in both file types. +See [Supported file types](../../docs/typescript/#supported-file-types) for all available extensions. + +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 using the project’s [`esbuild.settings.ts`](../settings/#esbuild.settings.ts) configuration, 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 the service-worker URL and scope to browser bundles through esbuild `define` values: + +| Define | Value | +| --- | --- | +| `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 | + +Register the built service worker from your site client code, usually `global.client.ts`: + +```typescript +// src/globals/global.client.ts +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, + type: 'module', + updateViaCache: 'none' + }) +} +``` + +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. + +### Registration and Web App Manifests + +Browsers allow service-worker registration only in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), normally HTTPS in production or localhost during development. +The service-worker script must be served from the same origin as the page. +DOMStack emits it at the origin root so its default scope can cover the entire site. +Register it with `type: 'module'` because DOMStack builds the worker as ESM. + +A [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest) is not required to register or run a service worker. +Add one when the site also needs installable-app metadata such as its name, icons, start URL, display mode, and theme colors. +DOMStack does not generate this browser manifest. +Author it as a [static asset](../../docs/assets/#static-assets) and reference it from the document head: + +```html + + +``` + +See these complete examples: + +- [`static-mpa-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-offline/) uses DOMStack's manifest hooks with a custom service worker and registration lifecycle. +- [`static-mpa-workbox-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-workbox-offline/) implements the same offline MPA pattern with Workbox. + +> [!CAUTION] +> DOMStack does not clean `dest` before building. +Clean the destination before deployment, especially after removing or renaming a service worker, so an old `/service-worker.js` cannot remain publicly available. + +## DOMStack manifest + +The DOMStack manifest is build metadata for service workers, deployment tools, and other build-time integrations. +It is not a [Web App Manifest](../../docs/workers/#registration-and-web-app-manifests). +A Web App Manifest such as `site.webmanifest` can be generated independently with a [template](../generation/#templates). + +A generated manifest resembles: + +```jsonc +// public/domstack-manifest.json +{ + "$schema": "https://unpkg.com/@domstack/static@/lib/domstack-manifest/schema.json", + "version": "a1b2c3...", + "generatedAt": "2026-08-31T12:00:00.000Z", + "entries": [ + { + "outputRelname": "index.html", + "kind": "page", + "url": "/", + "revision": "d4e5f6...", + "bytes": 1240, + "contentType": "text/html; charset=utf-8", + "static": true, + "role": "navigation" + } + ], + "policy": { + "offlineFallbackUrl": "/offline/" + } +} +``` + +When enabled, DOMStack collects its emitted pages, templates, bundles, workers, copied files, and static assets into a normalized list of public outputs. +You can filter that list, expose selected page variables, attach application policy, and consume the finalized result from a hook or programmatic build. +The finalized manifest can be injected statically into your service worker or emitted as a standalone `domstack-manifest.json` file. + +> [!WARNING] +> The DOMStack manifest pipeline is an unstable preview feature. +This includes its schema, settings, hooks, policy and entry variables, and `process.env.DOMSTACK_MANIFEST_*` defines. +Pin `@domstack/static` to an exact version when building against this preview API. + +The manifest lifecycle is: + +1. + DOMStack collects and reconciles emitted outputs. +2. + Excludes and entry filters run, then selected page variables are attached. +3. + DOMStack finalizes the manifest entries, root policy, and deterministic version. +4. + `manifestBuilt` hooks receive the finalized manifest. +5. + DOMStack bundles the site service worker with any constants defined by the hooks. +6. + DOMStack optionally writes `domstack-manifest.json` and returns the manifest from programmatic builds. + +The site service worker is omitted from manifest entries. +This allows the finalized manifest version to be embedded in `/service-worker.js` without creating a circular content hash. + +### Enable the manifest + +The manifest pipeline is disabled by default. +Enable it with one of these configuration surfaces: + +| Configuration | Pipeline enabled | Writes `domstack-manifest.json` | +|---|---:|---:| +| One `domstack-manifest.settings.ts` file anywhere in `src` | Yes | No | +| `domstackManifest: true` | Yes | Yes | +| `domstackManifest: { ... }` | Yes | Only with `write: true` | +| CLI `--domstackManifest` | Yes | Yes | + +A settings file enables manifest reconciliation, hooks, and `results.domstackManifest` without requiring a public JSON file. +This is sufficient when a service worker receives its cache policy through an injected build constant. + +> [!NOTE] +> Wherever `domstack-manifest.settings.ts` is used, you can also use `domstack-manifest.settings.js`. +Type checking is supported in both file types. +See [Supported file types](../../docs/typescript/#supported-file-types) for all available extensions. + + +### Configure entries and policy + +Create one `domstack-manifest.settings.ts` file anywhere under `src`. +It can default-export an options object or a synchronous or asynchronous function that returns one. + +```typescript +// src/globals/domstack-manifest.settings.ts +import type { DomstackManifestOptions } from '@domstack/static/types.js' + +type PageVars = { + offline?: boolean + precache?: boolean +} + +type ManifestVars = Pick + +type ManifestPolicy = { + offlineFallbackUrl: string +} + +const settings = { + exclude: ['admin/**', '**/*.map'], + includeEntry: entry => entry.kind !== 'metadata', + manifestVars: ['offline', 'precache'], + policy: { + offlineFallbackUrl: '/offline/' + } +} satisfies DomstackManifestOptions< + ManifestPolicy, + ManifestVars, + PageVars +> + +export default settings +``` + +The main settings are: + +| Setting | Purpose | +|---|---| +| `exclude` | Ignore-style patterns matched against both `entry.url` and `entry.outputRelname` | +| `includeEntry(entry)` | A final synchronous or asynchronous predicate that returns `true` to retain an entry | +| `manifestVars` | An allowlist or per-entry transform that exposes selected resolved page variables | +| `policy` | A manifest-wide object or transform for application-defined policy | +| `hooks.manifestBuilt` | Hooks that consume the finalized manifest before the service worker is bundled | + +Only variables explicitly selected by `manifestVars` are copied into entries. +Arbitrary page variables are not exposed automatically. +`exclude` runs before `includeEntry(entry)`. + +The resulting manifest contains: + +- `version`: A deterministic digest that changes when retained cache-relevant entries or root policy change +- `generatedAt`: The build timestamp, which does not affect `version` +- `entries`: Included public outputs sorted by URL +- `policy`: Optional application-defined manifest-wide policy + +Useful entry fields include `url`, `revision`, `kind`, `bytes`, `contentType`, `integrity`, `urlRevisioned`, `static`, `role`, and explicitly selected `manifestVars`. +Import `DomstackManifest` and `DomstackManifestEntry` from `@domstack/static/types.js` when consuming these objects directly. + +### Manifest built hooks + +`hooks.manifestBuilt` runs after entries, policy, and version are finalized but before `/service-worker.js` is bundled. +Each hook receives: + +- `manifest`: The finalized manifest +- `dest`: The absolute destination directory +- `defineServiceWorkerConstant(name, value)`: Injects a JSON-serializable value into only the final service-worker bundle +- `writeFile(outputRelname, contents)`: Writes an additional file under `dest` + +Files written by a hook are not added back to the already-finalized manifest. +Prefer an injected constant when only the service worker needs the generated data. + +### Service worker integration + +A manifest hook can turn the normalized entries into a small application-specific cache policy: + +```typescript +// src/globals/domstack-manifest.settings.ts +import type { + DomstackManifestBuiltHookContext, + DomstackManifestOptions +} from '@domstack/static/types.js' + +export type CachePolicy = { + version: string + precacheEntries: Array<{ + url: string + revision: string | null + integrity?: string + }> +} + +function injectCachePolicy ( + context: DomstackManifestBuiltHookContext +): void { + const policy: CachePolicy = { + 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, + ...(entry.integrity ? { integrity: entry.integrity } : {}) + })) + } + + context.defineServiceWorkerConstant('__APP_CACHE_POLICY__', policy) +} + +const settings = { + hooks: { + manifestBuilt: [injectCachePolicy] + } +} satisfies DomstackManifestOptions + +export default settings +``` + +The service worker can then consume the injected value without fetching a public manifest at runtime: + +```typescript +// src/globals/service-worker.ts +import type { CachePolicy } from './domstack-manifest.settings.ts' + +declare const __APP_CACHE_POLICY__: CachePolicy + +const cachePolicy = __APP_CACHE_POLICY__ +``` + +Manifest-enabled builds also define: + +| Define | Value | +|---|---| +| `process.env.DOMSTACK_MANIFEST_ENABLED` | `"true"` for a manifest-enabled one-shot build and `"false"` otherwise | +| `process.env.DOMSTACK_MANIFEST_VERSION` | The finalized version inside `/service-worker.js`; `""` in other bundles | +| `process.env.DOMSTACK_MANIFEST_URL` | The conventional `/domstack-manifest.json` URL | + +`DOMSTACK_MANIFEST_URL` does not guarantee that the JSON file was written. +Fetch it only when `--domstackManifest`, `domstackManifest: true`, or `{ write: true }` enabled public output. + +> [!IMPORTANT] +> Watch mode still bundles the service worker, but it does not finalize, return, or write the DOMStack manifest. +Manifest hooks do not inject production cache policy in watch mode. +Use a one-shot build or `domstack --serve` to test manifest-driven service-worker behavior. + +`domstack --serve` runs a normal one-shot build and serves `dest` without watch-mode filenames or live-reload injection: + +```console +domstack --serve +domstack --serve --port 3001 +``` + +See the complete examples for production-oriented cache lifecycle behavior: + +- [`static-mpa-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-offline/) injects DOMStack manifest entries into a custom service worker. +- [`static-mpa-workbox-offline`](https://github.com/bcomnes/domstack/tree/master/examples/static-mpa-workbox-offline/) converts the finalized entries into Workbox precaching and routing policy. + +### Programmatic configuration + +Configure the manifest through the `DomStack` constructor when coordinating it with another build tool or script: + +```typescript +// scripts/build.ts +import { DomStack } from '@domstack/static' + +const site = new DomStack('src', 'public', { + domstackManifest: { + write: true, + exclude: ['admin/**', '**/*.map'] + } +}) + +const results = await site.build() +console.log(results.domstackManifest?.version) +``` diff --git a/examples/basic/README.md b/examples/basic/README.md index f15063f9..bff9a3bf 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -17,7 +17,7 @@ The basic example illustrates: ### Prerequisites -- Node.js 22.x or higher +- Node.js 22.18+ within the 22.x release line, or Node.js 24 or newer ### Installation diff --git a/examples/basic/package.json b/examples/basic/package.json index 9f0c9e6b..43e4f9b4 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@domstack/static": "file:../../.", - "fragtml": "^0.0.9", + "fragtml": "^0.0.10", "highlight.js": "^11.9.0", "mine.css": "^11.0.6" } diff --git a/examples/blog/package.json b/examples/blog/package.json index 680d464f..5a845399 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@domstack/static": "file:../../.", - "fragtml": "^0.0.9", + "fragtml": "^0.0.10", "mine.css": "^11.0.6" } } diff --git a/examples/default-layout/README.md b/examples/default-layout/README.md index 46928d5e..6ce08b40 100644 --- a/examples/default-layout/README.md +++ b/examples/default-layout/README.md @@ -17,7 +17,7 @@ When no layout is provided, DOMStack will: ### Prerequisites -- Node.js 22.x or higher +- Node.js 22.18+ within the 22.x release line, or Node.js 24 or newer ### Installation diff --git a/examples/string-layouts/README.md b/examples/string-layouts/README.md index 2ce6f4e0..e4c3d324 100644 --- a/examples/string-layouts/README.md +++ b/examples/string-layouts/README.md @@ -15,7 +15,7 @@ String layouts provide a straightforward approach to creating HTML templates wit ### Prerequisites -- Node.js 22.x or higher +- Node.js 22.18+ within the 22.x release line, or Node.js 24 or newer ### Installation diff --git a/examples/uhtml-isomorphic/README.md b/examples/uhtml-isomorphic/README.md index a0893909..281de472 100644 --- a/examples/uhtml-isomorphic/README.md +++ b/examples/uhtml-isomorphic/README.md @@ -16,7 +16,7 @@ uhtml-isomorphic is a lightweight library that provides the same API for both se ### Prerequisites -- Node.js 22.x or higher +- Node.js 22.18+ within the 22.x release line, or Node.js 24 or newer ### Installation diff --git a/index.js b/index.js index b6c7b9d5..5413ce8b 100644 --- a/index.js +++ b/index.js @@ -265,7 +265,7 @@ export class DomStack { // Start esbuild in watch mode (stable filenames, no hash) let esbuildContext try { - const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts) + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger }) esbuildContext = context } catch (err) { throw new Error('Error starting esbuild watch context', { cause: err }) @@ -300,7 +300,7 @@ export class DomStack { delete pageBuildResults.report.watchDependencies delete pageBuildResults.report.rebuiltPagesFilePaths buildLogger(report, this.#logger) - this.#logger.info('Initial JS, CSS and Page Build Complete') + this.#logger.debug('Initial JS, CSS and Page Build Complete') } catch (err) { if (!(err instanceof DomStackAggregateError)) throw new Error('Non-aggregate error thrown', { cause: err }) this.#pageBuildFailed = true @@ -364,8 +364,12 @@ export class DomStack { async #startCopyWatcher (source, signal, ignores = []) { const watcher = cpxWatch(source, this.#dest, { ignore: ignores }) this.#cpxWatchers.push(watcher) + let ready = false + let initialCopies = 0 watcher.on('copy', (/** @type{{ srcPath: string, dstPath: string }} */e) => { - this.#logger.info(`Copy ${e.srcPath} to ${e.dstPath}`) + if (!ready) initialCopies++ + this.#logger.debug(`Copy ${e.srcPath} to ${e.dstPath}`) + if (ready) this.#logger.info(`Static asset updated: ${e.srcPath}`) }) watcher.on('remove', (/** @type{{ path: string }} */e) => { this.#logger.info(`Remove ${e.path}`) @@ -385,7 +389,8 @@ export class DomStack { try { if (signal.aborted) return await promise - if (!signal.aborted) this.#logger.info('Copy watcher ready') + ready = true + if (!signal.aborted) this.#logger.info(`Static asset watcher ready (${initialCopies} initial copy operations)`) } finally { watcher.off('watch-ready', resolve) watcher.off('watch-error', reject) @@ -424,7 +429,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) await ensureDest(this.#dest, siteData) - const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts) + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger }) this.#esbuildContext = context this.#siteData = siteData @@ -498,7 +503,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`) await this.#esbuildContext.dispose() this.#esbuildContext = null } - const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts) + const { context } = await buildEsbuildWatch(this.#src, this.#dest, siteData, this.opts, { logger: this.#logger }) this.#esbuildContext = context this.#siteData = siteData const snapshot = this.#watchSnapshot() diff --git a/lib/build-esbuild/index.js b/lib/build-esbuild/index.js index fb8f5277..7dacd392 100644 --- a/lib/build-esbuild/index.js +++ b/lib/build-esbuild/index.js @@ -1,6 +1,7 @@ /** * @import { BuildStep, SiteData, DomStackOpts } from '../builder.js' * @import { DomstackManifestKind, DomstackManifestRecord } from '../domstack-manifest/index.js' + * @import { Logger as PinoLogger } from 'pino' */ import { writeFile } from 'fs/promises' @@ -14,6 +15,7 @@ import { isDomstackManifestEnabled, } from '../domstack-manifest/index.js' import { toPosix } from '../helpers/path.js' +import { createDomStackLogger } from '../logger.js' const __dirname = import.meta.dirname const DOM_STACK_DEFAULTS_PREFIX = 'domstack-defaults' @@ -501,15 +503,17 @@ function createDomstackDefines ({ opts, siteData, watch }) { * @param {string} dest * @param {SiteData} siteData * @param {DomStackOpts} opts - * @param {{ onEnd?: (result: esbuild.BuildResult) => void }} [watchOpts] + * @param {{ onEnd?: (result: esbuild.BuildResult) => void, logger?: PinoLogger }} [watchOpts] * @returns {Promise<{ context: DisposableBuildContext, outputMap: OutputMap, buildResults: esbuild.BuildResult, buildOpts: EsbuildBuildOptions }>} */ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = {}) { + const logger = watchOpts.logger ?? opts.logger ?? createDomStackLogger() const extendedBuildOpts = await createBrowserBuildOpts(src, dest, siteData, opts, { watch: true }) const browserWatch = await createWatchBuild({ buildOpts: extendedBuildOpts, dest, label: 'JS/CSS', + logger, onEnd: watchOpts.onEnd, shouldWriteMetafile: opts?.metafile !== false, }) @@ -519,7 +523,6 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = /** @type {esbuild.BuildContext[]} */ const contexts = [browserWatch.context] try { - await writeMetafile({ dest, result: initialResult, shouldWrite: opts?.metafile !== false }) const outputMap = applyBuildOutputMap({ dest, result: initialResult, siteData, src }) if (siteData.serviceWorker) { @@ -534,6 +537,7 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = buildOpts: serviceWorkerBuildOpts, dest, label: 'Service worker', + logger, shouldWriteMetafile: false, }) contexts.push(serviceWorkerWatch.context) @@ -564,12 +568,16 @@ export async function buildEsbuildWatch (src, dest, siteData, opts, watchOpts = * @param {esbuild.BuildOptions} params.buildOpts * @param {string} params.dest * @param {string} params.label + * @param {PinoLogger} params.logger * @param {(result: esbuild.BuildResult) => void | Promise} [params.onEnd] * @param {boolean} params.shouldWriteMetafile * @returns {Promise<{ context: esbuild.BuildContext, initialResult: esbuild.BuildResult }>} */ -async function createWatchBuild ({ buildOpts, dest, label, onEnd, shouldWriteMetafile }) { - let startedWatching = false +async function createWatchBuild ({ buildOpts, dest, label, logger, onEnd, shouldWriteMetafile }) { + const initial = Promise.withResolvers() + // Attach a rejection handler before watch() can deliver a failing initial build. + initial.promise.catch(() => {}) + let isInitialBuild = true const plugins = buildOpts.plugins ?? [] /** @type {esbuild.Plugin} */ @@ -577,16 +585,33 @@ async function createWatchBuild ({ buildOpts, dest, label, onEnd, shouldWriteMet name: `domstack-${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-')}-on-end`, setup (build) { build.onEnd(async result => { - if (result.errors.length > 0) { - console.error(`${label} rebuild failed:`) - for (const err of result.errors) { - console.error(' ', err.text) + const first = isInitialBuild + isInitialBuild = false + try { + if (result.errors.length > 0) { + const failure = Object.assign(new Error(`${label} build failed`), { + errors: result.errors.map(serializeEsbuildMessage), + warnings: result.warnings.map(serializeEsbuildMessage), + }) + if (first) { + initial.reject(failure) + return + } + logger.error({ errors: failure.errors, warnings: failure.warnings }, `${label} rebuild failed`) + } else { + if (result.warnings.length) { + logger.warn({ warnings: result.warnings.map(serializeEsbuildMessage) }, `${label} build warnings`) + } + await writeMetafile({ dest, result, shouldWrite: shouldWriteMetafile }) + if (first) logger.debug(`${label} initial build complete`) + else logger.info(`${label} rebuild complete`) } - } else { - console.log(`${label} rebuild complete.`) + if (first) initial.resolve(result) + else if (onEnd) await onEnd(result) + } catch (error) { + if (first) initial.reject(error) + else logger.error({ err: error }, `${label} rebuild processing failed`) } - await writeMetafile({ dest, result, shouldWrite: shouldWriteMetafile }) - if (startedWatching && onEnd) await onEnd(result) }) } } @@ -598,10 +623,8 @@ async function createWatchBuild ({ buildOpts, dest, label, onEnd, shouldWriteMet try { // @ts-ignore esbuild context() accepts same opts as build() context = await esbuild.context(contextOpts) - const initialResult = await context.rebuild() - await context.watch() - startedWatching = true + const initialResult = await initial.promise return { context, initialResult } } catch (err) { diff --git a/lib/defaults/default.root.layout.test.js b/lib/defaults/default.root.layout.test.js new file mode 100644 index 00000000..1ef4df97 --- /dev/null +++ b/lib/defaults/default.root.layout.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { load } from 'cheerio' +import { html, raw, render } from 'fragtml' +import defaultRootLayout from './default.root.layout.js' + +test('string and HtmlResult children preserve whitespace through the root layout', async () => { + const code = 'first line\n indented line\n\n\tlast line\n' + const contents = `
    ${code}
    ${code}
    ` + for (const children of [contents, html`
    ${raw(contents)}
    `]) { + const output = await defaultRootLayout({ + children, + vars: { title: '', siteName: 'Test', defaultStyle: true, basePath: '' }, + data: {}, + // This layout does not inspect page metadata. + page: /** @type {any} */ ({}), + }) + const $ = load(output) + assert.equal($('title').text(), '<Title> | Test', 'metadata remains escaped') + assert.equal($('main pre code').text(), code, 'fenced code preserves exact whitespace') + assert.equal($('main pre').eq(1).text(), code, 'raw pre preserves exact whitespace') + assert.equal($('main textarea').text(), code, 'textarea preserves exact whitespace') + assert.equal($('main').find('article').length, typeof children === 'string' ? 0 : 1) + assert.equal($('main').text(), load(typeof children === 'string' ? children : render(children)).text()) + } +}) diff --git a/package.json b/package.json index 64ee67f3..99a5df92 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "bin.d.ts", "bin.d.ts.map", "docs/**/*.md", - "global.css", + "site/globals/global.css", "index.js", "index.d.ts", "index.d.ts.map", @@ -45,18 +45,18 @@ "url": "https://github.com/bcomnes/domstack/issues" }, "engines": { - "node": "^22.0.0 || >=24.0.0" + "node": "^22.18.0 || >=24.0.0" }, "dependencies": { "@11ty/dependency-tree-typescript": "^1.0.0", - "@domstack/sync": "^0.0.6", + "@domstack/sync": "^0.0.8", "argsclopts": "^1.0.4", "async-folder-walker": "^3.0.5", "chokidar": "^5.0.0", "clean-deep": "^3.4.0", - "cpx2": "^9.0.2", + "cpx2": "^9.0.3", "esbuild": "^0.28.1", - "fragtml": "^0.0.9", + "fragtml": "^0.0.10", "handlebars": "^4.7.8", "highlight.js": "^11.9.0", "ignore": "^7.0.0", @@ -98,6 +98,7 @@ "cheerio": "^1.0.0-rc.10", "installed-check": "^11.0.0", "jsonfeed-to-atom": "^1.2.5", + "mermaid": "^11.17.2", "neostandard": "^0.13.0", "npm-run-all2": "^9.0.2", "preact": "^10.29.6", @@ -129,7 +130,7 @@ "clean:declarations-lib": "rm -rf $(find lib -type f -name '*.d.ts*' ! -name '*-types.d.ts')", "clean-node_modules": "rm -rf node_modules && rm -rf examples/*/node_modules", "build": "npm run clean && run-p build:*", - "build:domstack": "./bin.js --src . --ignore examples,test-cases,coverage,*.tsconfig.json,fonts", + "build:domstack": "./bin.js --src . --ignore examples,test-cases,coverage,*.tsconfig.json,fonts,/lib,/types,/scripts,/plans,/browser-tests,/test-results,/lcov.info,/tsconfig.json,/AGENTS.md,/AGENT.md,/agents.md", "build:declaration": "tsc -p declaration.tsconfig.json", "build:schema": "node scripts/domstack-manifest-schema.js", "watch": "npm run clean && run-p watch:*", diff --git a/plans/domstack-manifest.md b/plans/domstack-manifest.md deleted file mode 100644 index 2db9289b..00000000 --- a/plans/domstack-manifest.md +++ /dev/null @@ -1,217 +0,0 @@ -# Build Output Manifest - -## Status: Implemented unstable preview - -Domstack has an implemented build-output manifest preview. - -The manifest pipeline is build-time first. - -It exists primarily for `domstack-manifest.settings.*`, `hooks.manifestBuilt`, deployment metadata, auditing, and optional public `domstack-manifest.json` output. - -Service-worker integrations now prefer injected constants from `manifestBuilt` hooks instead of runtime-fetching a generated manifest or policy file. - -The API is still documented as preview-quality because service-worker and PWA use cases are actively shaping the final ergonomics. - -## Current implementation - -The implemented pipeline is: - -```txt -builder() - identifyPages() - ensureDest() - Promise.all( - buildEsbuild() -> output records for app bundles, excluding final /service-worker.js - buildStatic() -> output records - buildCopy() -> output records - ) - buildPages() -> output records for pages/templates - reconcileDomstackManifest({ dest, records }) -> manifest + conflict warnings - when a manifest consumer exists - run manifestBuilt hooks - build /service-worker.js with finalized manifest version and hook-defined constants - optionally write domstack-manifest.json -``` - -A manifest consumer exists when either: - -- a `domstack-manifest.settings.*` file is present -- explicit programmatic `domstackManifest` configuration is provided -- `--domstackManifest` is passed to the CLI - -`domstack-manifest.json` is not written by default. - -The CLI writes it only when `--domstackManifest` is passed. - -Programmatic builds return `results.domstackManifest` when a manifest consumer exists. - -## Service worker relationship - -Production service workers are built after the manifest is finalized. - -They are intentionally omitted from the manifest entries and version hash. - -This avoids a circular dependency where `/service-worker.js` would depend on `manifest.version` while also changing `manifest.version`. - -Instead, service workers receive: - -- `process.env.DOMSTACK_MANIFEST_VERSION` -- hook-defined constants from `context.defineServiceWorkerConstant()` - -When manifest-driven service-worker policy changes, the final `/service-worker.js` bytes change, and the browser's normal service-worker update lifecycle runs. - -Watch mode does not build a manifest. - -Watch mode still builds the site's `/service-worker.js`, but sets -`process.env.DOMSTACK_MANIFEST_ENABLED` to `"false"` and leaves the manifest version empty. - -Domstack does not automatically add unregister or cache-cleanup behavior. A site service worker can -use the disabled-manifest signal to implement that behavior, as the native offline example does. - -Watch mode disables esbuild splitting for the service-worker build so `/service-worker.js` remains -parseable during production-to-watch cleanup, even for older classic-worker registrations. - -## Manifest built hook API - -Status: implemented. - -`domstack-manifest.settings.*` and programmatic `domstackManifest.hooks` can register `manifestBuilt` hooks. - -Hooks receive the finalized manifest after entry reconciliation, filtering, manifest variables, root policy, and `manifest.version` are resolved. - -```ts -type DomstackManifestBuiltHookContext<Policy, ManifestVars> = { - dest: string - manifest: DomstackManifest<Policy, ManifestVars> - defineServiceWorkerConstant: (identifier: string, value: unknown) => void - writeFile: (outputRelname: string, contents: string | Uint8Array) => Promise<void> -} -``` - -`defineServiceWorkerConstant()` serializes `value` with `JSON.stringify()` and passes it to esbuild's `define` option for the final service-worker build. - -Use it for service-worker policy, Workbox precache data, or any other build-time data that should not require a runtime fetch. - -`writeFile()` writes custom generated artifacts into `dest`. - -Use it for deployment metadata or public files that intentionally need their own URL. - -## Current manifest shape - -Relevant public entry fields include: - -```ts -type DomstackManifestEntry<ManifestVars = Record<string, unknown>> = { - outputRelname: string - kind: DomstackManifestKind - url: string - revision: string | null - bytes: number | null - sourceRelname?: string - entryPoint?: string - pagePath?: string - pageUrl?: string - templatePath?: string - contentType?: string - integrity?: string - manifestVars?: ManifestVars - urlRevisioned?: boolean - static?: boolean - role?: string - page?: { - path: string - url: string - } -} -``` - -Relevant root fields include: - -```ts -type DomstackManifest<Policy, ManifestVars> = { - $schema: typeof DOMSTACK_MANIFEST_SCHEMA_ID - version: string - generatedAt: string - entries: DomstackManifestEntry<ManifestVars>[] - policy?: Policy -} -``` - -`version` is a SHA-256 hex digest derived from stable cache-relevant manifest data. - -It excludes `generatedAt` and excludes the final `/service-worker.js` output. - -## Manifest variables and policy - -`manifestVars` are selected from the resolved page variable cascade. - -The current cascade is: - -```txt -domstack defaults --> global vars --> global data --> layout vars --> page vars --> page builder vars/frontmatter -``` - -Later sources override earlier sources. - -Layouts can export `vars` with the same async/sync contract as page/global vars. - -`manifestVars` can be configured as an array of variable names or a transform function. - -Root `policy` is a single freeform object for the whole manifest. - -Per-entry policy is represented through selected `manifestVars`, not a separate per-entry policy object. - -## Stacked examples validating the preview - -Two examples added in the stacked follow-up changes validate the preview against native and Workbox -service-worker implementations. - -`examples/static-mpa-offline` demonstrates a domstack-native service worker. - -It injects manifest-shaped service-worker policy directly into `/service-worker.js` with `defineServiceWorkerConstant()`. - -The service worker consumes Domstack manifest entries directly and derives cache behavior from: - -- `entry.manifestVars` -- `entry.role` -- `entry.kind` -- `entry.revision` -- `entry.urlRevisioned` -- `entry.bytes` -- `entry.static` - -`examples/static-mpa-workbox-offline` demonstrates a Workbox service worker. - -It injects a Workbox-oriented policy constant into `/service-worker.js`. - -The service worker passes `policy.precacheManifest` to Workbox and maps app-specific runtime/network-only/fallback policy to Workbox routing and strategies. - -## Public schema artifact - -`lib/domstack-manifest/schema.json` is currently kept as a public validation/documentation artifact. - -Runtime/build behavior does not require loading this file. - -It is useful for users who opt into writing `domstack-manifest.json` and want editor or deployment validation. - -If the written manifest is de-emphasized further, this schema file could become optional or be replaced by docs-only schema publication. - -## Current non-goals - -- Do not auto-register service workers. -- Do not require Workbox. -- Do not require writing `domstack-manifest.json` for service-worker use. -- Do not encode app-specific API/data caching semantics in core manifest fields. -- Do not include final `/service-worker.js` in the manifest hash. - -## Potential follow-ups - -- Decide whether the external schema JSON remains a packaged artifact long term. -- Consider helper utilities for common service-worker policy derivations without making them mandatory. -- Consider a small registration helper that uses Domstack's service-worker URL/scope defines but leaves UI policy to the app. -- Continue validating downstream PWA use cases before stabilizing the preview API. diff --git a/plans/generated-pages.md b/plans/generated-pages.md deleted file mode 100644 index 40579e27..00000000 --- a/plans/generated-pages.md +++ /dev/null @@ -1,604 +0,0 @@ -# Generated Pages Files - -## Status: Implementation review β€” ready to land - -Plan for adding first-class generated page support in response to the redirect-page discussion in PR #253. - -## PR #253 implementation review - -Originally reviewed at commit `78d012e` on 2026-08-29. Follow-up fixes were completed during the review. - -### Verdict - -Ready to land. The worker boundary, generated-output lifecycle, watch behavior, error reporting, public data model, types, and documentation findings have been resolved. Generated pages remain close to regular pages while using a stable source-backed input set plus shared derived data from `global.data.*`. - -### Findings - -#### 1. Resolved: the documented blog-index example could not be sent back from the worker - -`README.md:1107-1117` places concrete `PageData` objects into `vars.posts`. Those objects contain functions such as resolved layout renderers. - -Every page's complete vars were added to its output record at `lib/build-pages/page-builders/page-writer.js:109-120`. The worker then tried to send that record back to the main thread at `lib/build-pages/worker.js:9-10`. Functions cannot be sent this way, so the documented example could write its HTML and then reject with a `DataCloneError`. - -Generated-page render errors have the same root problem. `lib/build-pages/index.js:560-563` sends the complete generated `PageInfo` as error context, including the function-valued `generated.children` stored at `lib/build-pages/index.js:274-278`. A useful render exception can therefore be replaced by an unclear worker-copy failure. - -Implemented resolution: - -- Generated pages remain regular `PageInfo` objects handled by the existing JS page builder. -- Rendering functions and complete vars remain available inside the page worker. -- Output records return a snapshot of page vars by copying each top-level value independently. Values that cannot be copied, such as `PageData[]`, are left out of the snapshot. -- Generated page error information omits `generated.vars` and `generated.children` before it is returned from the worker. -- Manifest allowlists and functions continue to use the returned page-vars snapshot in the main thread. -- Regression tests cover the README pattern with `PageData[]` in generated vars, generated render errors, allowlisted manifest vars, and function manifest transforms using post-render values. - -#### 2. Resolved: watch mode removes obsolete regular and generated page outputs - -One-shot builds assume an empty destination. Watch mode previously rebuilt the pages that currently existed but did not remove files written by pages that had disappeared. This affected regular pages too, although generated pages made it easier to encounter because changing one `*.pages.*` file can rename or remove many outputs. - -Implemented resolution: - -- The `DomStack` watch instance keeps a set of page output paths from the latest successful full page build. -- After the next successful full page build, it removes previous page files that are no longer claimed by any current page or template output. -- Regular and generated pages use the same cleanup because both emit normal `kind: 'page'` output records. -- Failed and filtered builds do not remove files or replace the saved set because their output lists are incomplete. -- The saved set is replaced after each successful cleanup, so memory use stays proportional to the current number of pages rather than growing across rebuilds. -- Cleanup resolves each recorded path inside the destination and refuses to remove the destination itself. -- Regression coverage includes a regular page removal plus generated output rename, definition removal, transition to `draft: true`, and deletion of the entire `*.pages.*` file. - -#### 3. Resolved: conflict detection is intentionally limited to page output paths - -The generated-pages design requires generated pages not to replace regular pages or other generated pages. The implementation meets that scope by checking concrete and generated page output paths before rendering. - -Templates can still target the same path as a regular or generated page. This is an existing whole-build limitation rather than behavior introduced by generated pages: regular pages and templates could already overwrite one another. Template output paths may also be chosen only after a template runs, while esbuild, static, and copied outputs are written by separate build steps. Manifest reconciliation cannot prevent these conflicts because it runs after files have been written. - -Resolved for this PR by: - -- Narrowing the PR summary to say it detects conflicts between generated pages and regular or other generated pages. -- Keeping the generated-page checks aligned with the original minimum v1 scope in the β€œConflict detection” section below. -- Tracking shared conflict detection across templates and other build steps separately in [issue #288](https://github.com/bcomnes/domstack/issues/288). - -A duplicate-record check after the build would be too late to prevent an overwrite, so this PR does not add a partial post-write check. - -#### 4. Resolved: layout asset additions rebuild generated HTML in watch mode - -For layout CSS or client add events, the watch handler built a filter from `#layoutPageMap`. That map contains only concrete `siteData.pages`, so generated pages were omitted when a concrete and generated page shared the affected layout. The new asset was built, but generated HTML did not gain its `<link>` or `<script>` reference. - -Removal already reached the fallback full rebuild because the removed asset was absent from newly identified site data. The add/unlink branch now handles both directions explicitly: whenever any `*.pages.*` files exist, layout asset additions and removals use the same conservative full generated-page rebuild as layout source changes. Sites without pages files keep the targeted regular-page rebuild. - -Regression coverage adds and removes both layout CSS and layout clients while a regular and generated page share the layout, and checks that both HTML outputs add and remove the asset references. - -#### 5. Resolved: generated-page setup errors keep their type and source context - -Errors raised while importing or running a `*.pages.*` file, validating its definitions, or checking its output paths were caught only after the complete generated-pages resolution step. The responsible pages file was no longer known, and wrapping the error for worker transfer removed the output-conflict code and details. - -Implemented resolution: - -- Generated-page resolution remembers the pages file currently being processed and attaches it to failures before they leave the resolver. -- The worker continues to return a safe plain `Error`, so non-copyable values in a user error's `cause` cannot replace the useful failure with a worker-copy error. -- The existing `errorData` object now carries `pagesFile` plus the output-conflict code and details when applicable. -- The main thread's existing error restoration adds the pages-file name to the message and exposes `pagesFile`, `code`, and `conflict` on the caller-visible error. -- Built-in error names such as `TypeError` are retained. -- Conflict details now identify concrete page source files and generated definitions by `<pages-file>#<index>`, while `conflict.outputPath` separately identifies the duplicated output. -- Regression tests cover a pages function throwing with a non-copyable cause, invalid definitions and paths, generated-to-concrete conflicts, and generated-to-generated conflicts. - -#### 6. Resolved: public `siteData.pages` is intentionally discovery-only - -Generated pages are downstream of regular page discovery and initialization. Every `*.pages.*` factory receives the same source-backed `PageData[]`; factories do not receive pages produced by earlier pages files. This avoids making generated output depend on pages-file processing order or creating circular page-generation dependencies. - -The public `siteData` object remains the result of `identifyPages()`. Its `pages` array therefore contains only source-backed pages discovered from the source tree. `global.data.*` runs from the initialized source-backed pages, and its result is available to every `*.pages.*` factory. Generated pages are then created inside the page worker and combined with regular pages for templates, page functions, layouts, and rendering. They are not added back to the public discovery object. - -This keeps one clear `SiteData` meaning rather than introducing separate concrete and expanded variants. It also avoids transferring complete generated `PageInfo` objects from the worker when their definitions can contain functions or other values that cannot be copied between threads. - -Implemented resolution: - -- Documented the discovery-only meaning on the public `SiteData` type and in the Generated Pages README section. -- Clarified that the `siteData` factory parameter and returned `results.siteData` follow the same rule. -- Renamed the initial build summary count from `Pages:` to `Source pages:`; `Pages built:` continues to include regular and generated pages. -- Added regression coverage confirming returned `results.siteData.pages` contains only the five source-backed fixture pages while generated pages are still built and exposed to downstream render steps. -- Kept watch maps source-backed. Generated-page sites intentionally use full page rebuilds for changes that can alter arbitrary generated outputs. - -#### 7. Resolved: generated-pages documentation and public types are complete - -The Generated Pages documentation is now a top-level README section rather than part of Templates. It documents: - -- All supported `.pages.js`, `.pages.mjs`, `.pages.cjs`, `.pages.ts`, `.pages.mts`, and `.pages.cts` filenames, including the Node.js TypeScript-loading requirement. -- Static object and array exports, normal and async factory functions, and async iterables. -- The `pages`, `vars`, `pagesFile`, and `siteData` factory parameters, including that `vars` contains `global.data.*` output, and when generated pages join the downstream `PageData[]`. -- Every generated definition field, output path rules, asset behavior, and `draft: true` with `--drafts` or `buildDrafts: true`. -- `PagesFunction`, `PagesFunctionParams`, `GeneratedPageDefinition`, and `PagesFileInfo` in the public type catalog. -- Public type imports from `@domstack/static/types.js` rather than the runtime package entry. - -The public types were simplified before release: - -- `PagesFunction` now explicitly covers normal functions, async functions, and async generators. Its existing return union already describes direct definitions, promises, and async iterables. -- The overlapping `AsyncPagesFunction` was removed instead of adding another `PagesAsyncIterator` type. One factory type accurately describes every supported function form with fewer nearly identical names. -- `PagesFunctionParams` is generic, and `PagesFunction` has a separate third generic for the default/global/global-data vars received by the factory. Generated-page vars and factory input vars can therefore be typed independently. -- `GeneratedPageDefinition.outputName` documents its `<pages-file-name>/index.html` default. -- Type-checked fixtures cover an async generator and separately typed generated/factory vars. -- The blog fixture and real blog example prepare grouped yearly collections in `global.data.*`; pages files only declare outputs, and layouts render their HTML. -- Runtime coverage confirms static object, static array, and async function exports. - -The redirect security warning and meta-refresh SEO guidance remain accurate. - -### Objective assessment - -The implementation achieves the core design in a clean one-shot build: - -- Discovers the intended `*.pages.*` module families. -- Gives every factory a stable view of initialized concrete pages. -- Supports object, array, promise, and async-iterable results. -- Runs generated pages through normal vars, layout, global asset, and manifest processing. -- Makes source-derived global data available to generated-page factories and all pages at final render time. -- Exposes generated pages to templates, page functions, and layouts. -- Validates definitions and output paths. -- Detects generated-to-concrete and generated-to-generated page conflicts. -- Rebuilds generated pages for normal pages-file and imported-dependency changes. - -The generated-page build, watch behavior, and public site-data model are now intentional and covered by regression tests. The documented programmatic index builds successfully from grouped collection data returned by `global.data.*`, while its layout owns the rendered HTML. Runtime-only `PageData[]` values remain available while rendering and are left out of the page-vars snapshot returned from the worker. - -#### 8. Resolved: generated pages close issue #237 - -For now, the generalized generated-pages API is accepted as the resolution of issue #237. It provides the central redirect-page generation requested by the later discussion while keeping redirect output inside the normal page and layout pipeline. - -The feature does not add redirect-specific destination validation or native hosting-provider redirect files. Those can be proposed separately if real-world use shows they are needed; they are not required for PR #253 to close #237. - -#### 9. Resolved during the final pass: remaining path and watch edge cases - -The final complete-diff review found two smaller gaps: - -- `outputName: '.'`, `outputName: './'`, and paths ending in a separator passed the initial non-empty check without actually naming an output file. Generated output validation now rejects values that normalize to the current directory or end with a separator. -- Changing `markdown-it.settings.*` rebuilt only source Markdown pages. A generated page that rendered one of those pages could therefore remain stale. Sites with any pages files now use the conservative full generated-page rebuild for Markdown settings changes, while sites without pages files retain the targeted Markdown-only rebuild. - -Regression tests cover both cases. Generated drafts also have positive coverage with `buildDrafts: true`, complementing the existing default-omission coverage. - -### Landing checklist - -- [x] Make generated-page success and error results safe to send from the worker. -- [x] Validate the README index example with a worker-boundary regression test. -- [x] Reconcile and remove obsolete regular and generated page outputs in watch mode. -- [x] Rebuild generated pages when layout assets are added or removed. -- [x] Preserve output-conflict codes, metadata, and pages-file context. -- [x] Define conflict detection as generated-to-regular and generated-to-generated page checks; track whole-build conflicts in issue #288. -- [x] Define returned `siteData.pages` as source-backed discovery data. -- [x] Run `global.data.*` before generated-page factories and expose its result through factory `vars`. -- [x] Finalize generated-pages type names and generics. -- [x] Complete the README API and type documentation. -- [x] Accept the generalized generated-pages feature as closing issue #237. -- [x] Reject generated output paths that do not name a file. -- [x] Refresh generated pages when Markdown settings change. - -### Validation performed during review - -- `node --test test-cases/generated-pages/index.test.js` β€” passed (final focused suite: 19 tests). -- `npm run test:node-test` β€” passed. -- `npm run test:tsc` β€” passed. -- `npm run test:installed-check` β€” passed. -- `npm run build:declaration` after cleaning generated declarations β€” passed. -- Focused ESLint over all changed JavaScript and TypeScript files β€” passed. -- `git diff --check` β€” passed. -- Root `npm run test:neostandard` in the review checkout was polluted by malformed fixtures under local `.delta/worktrees`; the equivalent full-repository ESLint command passed with `.delta/**` excluded. -- The original worker-copy reproduction now passes. Obsolete regular and generated page outputs are removed in watch mode, and layout asset additions/removals plus Markdown settings changes now refresh generated HTML. General conflicts between templates and other build steps are tracked separately in issue #288. - ---- - -## Original design plan - -## Problem - -Templates can already write arbitrary files, including redirect HTML files, `_redirects`, feeds, and other generated assets. They do not, however, create real DomStack pages: - -- Template outputs bypass page vars, layouts, default/global assets, and page render helpers. -- Template outputs are not represented in `pages`, so templates, feeds, indexes, and other final-render introspective code cannot see them. -- Redirect pages are conceptually pages: they should use a redirect layout, inherit vars, and appear at page URLs. -- Some generated-page use cases need central control: redirect lists, yearly/monthly blog indexes, tag indexes, pagination, archive pages, etc. - -The final PR comments point toward a dedicated `*.pages.ts` feature rather than more redirect docs or more template escape hatches. - -## Refined recommendation - -Add a generated-pages file type, discovered as `*.pages.*`, but do **not** treat its outputs as a separate output class. - -Instead: - -> `*.pages.*` files are page factories. They receive collection data derived by `global.data.*`, and their returned definitions expand into normal `PageInfo` entries before templates and final page rendering run. - -This preserves the useful authoring model from templates β€” one file can return one output, many outputs, or an async stream of outputs β€” while keeping generated results inside the normal page pipeline. - -| Feature | Purpose | Output semantics | -|---|---|---| -| `*.template.*` | Generate arbitrary files | Caller provides final file content | -| `*.pages.*` | Generate real pages | Caller provides output name, vars, and children; DomStack renders through layout/page pipeline | - -## File naming - -Discover the same JS/TS module families as templates: - -```txt -*.pages.ts / *.pages.mts / *.pages.cts -*.pages.js / *.pages.mjs / *.pages.cjs -``` - -Use `nodeHasTS` just like `templateSuffixs` in `lib/identify-pages.js`. - -Examples: - -```txt -src/redirects.pages.js -src/blog/indexes.pages.ts -src/tags.pages.mjs -``` - -## Proposed API - -A pages file exports a default function, async function, array, object, or async iterable that yields generated page definitions. - -```ts -import type { PagesFunction } from '@domstack/static' - -export default (async function redirectsPages ({ pages }) { - return [ - { - outputName: '2020/old-slug/index.html', - vars: { - layout: 'redirect', - title: 'Redirecting...', - redirectTo: '/2020/new-slug/', - }, - }, - ] -}) satisfies PagesFunction -``` - -The generated page definition is template-like, but layout-driven: `outputName` chooses where to write the page, `children` supplies the layout child content, and `vars` controls page/layout variables. - -```ts -type GeneratedPageDefinition<Vars = Record<string, any>, Children = string> = { - outputName?: string // default: '<pages-file-name>/index.html' - vars?: Vars - children?: Children | ((params: PageFunctionParams<Vars, Children>) => Children | Promise<Children>) | undefined - draft?: boolean -} -``` - -Rules: - -- `outputName` is a relative output path, resolved from the `*.pages.*` file's directory, with no leading `/` and no `..` segments. -- `outputName` defaults to `<pages-file-name>/index.html`. -- `vars.layout` participates in normal layout resolution. If omitted, the usual default/global layout value applies. -- `children` is optional and can be static content or an inline page-like render function. Omitted or explicitly `undefined` children render as empty content. -- Generated pages must not reference another page file as their render template. -- Generated pages intentionally do not get page-local assets (`style.css`, `client.js`, workers). They only participate in global and layout assets. - -## Pages file parameters - -Pass enough context for reflection while avoiding circular or ordering-dependent generation: - -```ts -type PagesFunctionParams = { - pages: PageData[] - vars: Record<string, any> - pagesFile: PagesFileInfo - siteData: SiteData -} -``` - -`pages` contains only concrete/source-backed pages discovered directly from the source tree, initialized with default/global/page/builder vars and the values returned by `global.data.*`. It does not include generated pages from any `*.pages.*` file, including pages produced by earlier files in the same build. - -`vars` contains default vars, `global.vars.*`, and the collection data returned by `global.data.*`. This gives every pages file the same stable introspection set and derived-data input. - -## Build pipeline - -Do not run `*.pages.*` files inside `identifyPages()`. They need initialized concrete page data (`page.vars`, builder vars, pageInfo, render helpers), and `identifyPages()` should remain a file-discovery phase. - -Instead, add an explicit page-expansion phase early in `buildPagesDirect()`. - -Current pipeline: - -```txt -identifyPages() - discover concrete pages - discover layouts/templates/global assets - -buildPagesDirect() - resolve default/global vars - resolve layouts - initialize concrete PageData[] - resolve global.data.* with concrete pages - stamp globalDataVars - render pages and templates -``` - -Proposed pipeline: - -```txt -identifyPages() - discover concrete pages - discover layouts/templates/global assets - discover pagesFiles (*.pages.*) - -buildPagesDirect() - resolve default/global vars - resolve layouts - - concretePageInfos = siteData.pages - concretePageData = initialize concrete PageData[] - - resolve global.data.* with concretePageData - stamp globalDataVars onto concretePageData - - run pagesFiles with concretePageData + global/globalData vars + siteData - validate generated page definitions - convert definitions into generated PageInfo objects - detect output conflicts against concrete pages and earlier generated pages - - generatedPageData = initialize generated PageData[] - stamp globalDataVars onto generatedPageData - allPages = [...concretePageData, ...generatedPageData] - - render pages/templates using siteData + allPages -``` - -The important framing is that `global.data.*` derives shared collection data from concrete pages, then generated outputs become ordinary pages built from that source data. Final page rendering and templates operate on the combined page list, while public `siteData` and watch maps remain source-backed. - -## Data model changes - -### `identify-pages.js` - -Add: - -```js -export const pagesSuffixs = nodeHasTS - ? ['.pages.ts', '.pages.mts', '.pages.cts', '.pages.js', '.pages.mjs', '.pages.cjs'] - : ['.pages.js', '.pages.mjs', '.pages.cjs'] -``` - -Add `PagesFileInfo` and `siteData.pagesFiles` alongside `siteData.templates`. - -Optionally distinguish the raw concrete pages from expanded pages once expansion has run: - -```ts -type SiteData = { - pages: PageInfo[] // expanded pages after generated-page expansion - concretePages?: PageInfo[] // source-backed pages discovered by identifyPages() - pagesFiles: PagesFileInfo[] -} -``` - -`identifyPages()` can initially return `pages` and `concretePages` as the same list. The expansion phase can then produce an `expandedSiteData` object rather than mutating the original `siteData` in place. - -### Generated page info - -Represent generated pages as regular `PageInfo` entries with an additional marker: - -```ts -type GeneratedPageInfo = PageInfo & { - type: 'js' - generated: { - pagesFile: PagesFileInfo - vars: Record<string, any> - children: unknown | PageFunction - } -} -``` - -Let the existing JS page builder consume the in-memory generated payload before -falling back to importing a concrete JS page module: - -```js -if (pageInfo.generated) { - return { - vars: pageInfo.generated.vars, - pageLayout: typeof pageInfo.generated.children === 'function' - ? pageInfo.generated.children - : () => pageInfo.generated.children ?? '', - } -} -``` - -Generated pages then follow the same `PageData` initialization and rendering -path as concrete JavaScript pages. Generated vars and functions stay inside the -page worker while rendering. When the worker returns its build report, it copies -each top-level page var independently and leaves out values that cannot be -copied. Generated page error information similarly leaves out `vars` and -`children`. - -## Conflict detection - -Generated pages must not silently overwrite concrete pages, loose markdown outputs, or other generated pages. Any duplicate generated/concrete page output path must throw a conflict error. - -Minimum v1 conflict checks: - -1. Validate `outputName` is relative and cannot escape the pages file's directory. -2. Compute: - - `outputRelname = join(pagesFile.path, outputName)` - - `path = dirname(outputRelname)` - - `outputName = basename(outputRelname)` - - `url = computePageUrl({ path, outputName })` -3. Reject duplicates within: - - existing concrete `siteData.pages[*].outputRelname` - - generated definitions from all pages files - -Prefer hard errors for duplicate page output paths, matching the existing duplicate page-source behavior. - -## Watch mode integration - -Generated pages should eventually make watch mode cleaner, not more special, if watch maps are rebuilt from expanded page data. - -### Conservative v1 - -Treat `*.pages.*` as structural page inputs: - -- Add/change/unlink of a `*.pages.*` file β†’ full page rebuild and rebuild maps. -- Dependency of a `*.pages.*` file β†’ full page rebuild. -- Layout changes may need a full page rebuild until generated pages are included in layout watch maps. - -### Better follow-up - -Once the build has an `expandedSiteData` concept, rebuild watch maps from expanded pages: - -- `#layoutPageMap` should include generated pages by resolving their final `vars.layout`. -- A layout change can then target both concrete and generated pages using that layout. -- `#pageFileMap` can include generated page pseudo-file paths only if targeted rebuilds need them; otherwise pages-file changes remain structural. -- `#pagesFileDepMap` tracks dependencies imported by pages files and can conservatively trigger full page rebuilds. - -This avoids the current broad special case of β€œif any pages files exist, layout changed means rebuild all pages.” - -## Public types - -Export from the dedicated `types.js` type entry: - -- `PagesFunction` for normal, async, and async-generator factories -- `PagesFunctionParams` -- `GeneratedPageDefinition` -- `PagesFileInfo` - -Add JSDoc typedefs first, then declaration generation will expose them through the existing `tsc -p declaration.tsconfig.json` flow. - -## Documentation examples - -### Redirects - -```md ---- -title: Current Post -redirectFrom: - - /2020/old-slug/ ---- -``` - -```js -// global.data.js validates destination-page metadata and derives `{ from, to }`. -export default function ({ pages }) { - const redirects = [] - const redirectOwners = new Map() - for (const page of pages) { - const redirectFrom = page.vars.redirectFrom - if (redirectFrom === undefined) continue - - const source = page.pageInfo.pageFile.relname - if (!Array.isArray(redirectFrom)) throw new TypeError(`redirectFrom on "${source}" must be an array`) - for (const from of redirectFrom) { - if (typeof from !== 'string' || !from.startsWith('/') || from.startsWith('//')) throw new Error(`Invalid redirectFrom on "${source}"`) - const existingSource = redirectOwners.get(from) - if (existingSource) throw new Error(`redirectFrom "${from}" is declared by both "${existingSource}" and "${source}"`) - redirectOwners.set(from, source) - redirects.push({ from, to: page.pageInfo.url }) - } - } - return { redirects } -} - -// redirects.pages.js turns the derived collection into normal pages. -export default function ({ vars }) { - const pages = [] - for (const { from, to } of vars.redirects) { - const relativePath = from.slice(1) - pages.push({ - outputName: relativePath.endsWith('/') ? `${relativePath}index.html` : relativePath, - vars: { - layout: 'redirect', - title: 'Redirecting...', - redirectTo: to, - }, - }) - } - return pages -} -``` - -```js -// src/redirect.layout.js -import { html, render } from 'fragtml' - -export default function redirectLayout ({ vars }) { - return render(html`<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta http-equiv="refresh" content="0;url=${vars.redirectTo}"> - <link rel="canonical" href="${vars.redirectTo}"> - <title>${vars.title} - - -

    Redirecting to ${vars.redirectTo}

    - -`) -} -``` - -Redirect metadata validation happens in `global.data.*` while the destination source page is known. It rejects malformed metadata, unsafe paths, and duplicate old URLs with source-specific errors. The generated-output validator remains a second path-safety check. - -### Blog indexes - -`global.data.*` groups and sorts the source posts once. The pages file maps that prepared collection to normal pages, and the selected layout renders each archive: - -```js -// src/blog-indexes.pages.js -export default function ({ vars }) { - const pages = [] - - for (const { year, posts } of vars.blogIndexes) { - pages.push({ - outputName: `blog/${year}/index.html`, - vars: { - layout: 'blog-index', - title: `${year} posts`, - posts, - }, - }) - } - - return pages -} -``` - -## Tests - -Add a focused generated-pages fixture, likely `test-cases/generated-pages/`: - -1. Discovers `*.pages.js` and exposes it on `siteData.pagesFiles`. -2. Collects destination-page `redirectFrom` metadata in `global.data.js` and generates redirect pages through a `redirect.layout.js`. -3. `global.data.js` receives source-backed pages, and its returned collection data is available to pages factories and template vars. -4. Generated blog/year indexes can use collection data derived from concrete pages. -5. Multiple `*.pages.*` files each receive only concrete pages, not generated pages from other pages files. -6. Duplicate generated/concrete output paths throw an aggregate build error. -7. Invalid generated output paths (`/absolute`, `../escape`, `nested/../../escape`) throw a clear error. -8. Async iterable pages files work for large output sets. -9. Watch mode: changing a `*.pages.js` file triggers a full page rebuild. -10. Follow-up watch test: once expanded watch maps exist, a layout change rebuilds generated pages using that layout. - -Run at minimum: - -```sh -npm run test:node-test -- test-cases/generated-pages/index.test.js -npm run test:neostandard -npm run test:tsc -``` - -Then run full `npm test` before merging. - -## Design decisions - -1. `*.pages.*` files are page factories, not a separate output system. - - Their outputs become regular `PageInfo` entries in the expanded page list. - - Downstream systems should consume the expanded page list wherever possible. -2. Generated pages are distinct from concrete/source-backed pages only while pages files are running. - - The `pages` argument passed to `*.pages.*` files contains only concrete pages discovered directly from the source tree. - - Generated pages are not passed to other pages files in the same build. - - This avoids ordering-dependent generation. -3. Generated pages do not support page-level `style.css`, `client.js`, or workers. - - They participate only in global assets and layout assets. - - This keeps generated pages focused on central page creation while concrete pages remain the place for page-local asset bundles. -4. Generated pages pass child content directly; they do not pull in existing page files as render templates. - - `children` may be static content or an inline render function. - - Reusable presentation belongs in layouts or userland helper functions imported by the pages file. - -## Milestones - -1. Discovery and types: `pagesSuffixs`, `PagesFileInfo`, `siteData.pagesFiles`, exported JSDoc typedefs. -2. Runtime: `resolvePagesFiles()`, generated page validation, and generated `PageInfo` support in the JS page builder. -3. Data flow: run `global.data.*` from concrete pages and pass its result to generated-page factories. -4. Expansion: combine concrete and generated pages for templates and final page rendering. -5. Errors: duplicate generated/concrete page output conflicts and invalid generated output path errors with useful file context. -6. Tests and docs: generated-pages fixture, README section, redirect and blog-index examples. -7. Watch follow-up: use conservative full page rebuilds when generated outputs may change; keep targeted source-page maps for sites without pages files. diff --git a/plans/standard-static-mpa-service-worker.md b/plans/standard-static-mpa-service-worker.md deleted file mode 100644 index edfee616..00000000 --- a/plans/standard-static-mpa-service-worker.md +++ /dev/null @@ -1,199 +0,0 @@ -# Optional Standard Static MPA Service Worker - -## Status: Proposal validated by example - -`examples/static-mpa-offline` is the current domstack-native prototype for a possible optional standard static MPA service-worker preset. - -It no longer runtime-fetches `domstack-manifest.json` or a generated policy JSON file. - -Instead, `hooks.manifestBuilt` injects the finalized manifest-shaped policy into `/service-worker.js` with `defineServiceWorkerConstant()`. - -The service worker consumes Domstack manifest entries directly and derives cache behavior from the manifest fields and selected offline vars. - -## Goals - -- Provide a simple, robust, production-ready static MPA offline preset. -- Keep service workers explicit opt-in. -- Avoid forcing Workbox on sites that only need static MPA offline behavior. -- Use Domstack's finalized build graph instead of hand-maintained asset lists. -- Keep watch mode safe by disabling caches and unregistering old workers. -- Include recovery paths from the start. - -## Non-goals - -- Do not auto-enable service workers for all domstack sites. -- Do not cache API/data endpoints by default. -- Do not implement app-specific offline mutations, background sync, push subscriptions, or data models. -- Do not force a domstack-provided update UI into user layouts. -- Do not replace Workbox for apps that need Workbox plugins and recipes. - -## Current example behavior - -The vanilla example has these moving parts: - -- `src/globals/domstack-manifest/domstack-manifest.settings.ts` selects `offline` and `precache` manifest vars and registers the build hook. -- `src/globals/domstack-manifest/policy-build.ts` injects `{ version, entries, offlineFallbackUrl }` into `/service-worker.js`. -- `src/globals/service-worker/service-worker.ts` chooses production vs watch behavior by detecting whether the injected policy constant exists. -- `src/globals/service-worker/precache.ts` derives precache keys and runtime strategy from Domstack manifest entries. -- `src/globals/global-client/*` owns registration, update UI, watch cleanup, reset query params, and connection status. - -The service worker uses: - -- stable `/service-worker.js` -- stable cache names -- revisioned cache keys for non-hashed URLs -- cache-first handling for precached static outputs -- network-first handling for progressive/runtime routes -- network-only behavior for offline-disabled routes -- navigation fallback to the offline page -- watch-mode no-policy self-disable -- `SKIP_WAITING` and `RESET_SERVICE_WORKER` messages - -## Offline vars convention - -The example intentionally keeps user-facing vars small: - -```ts -type StaticMpaOfflineManifestVars = { - offline?: boolean - precache?: boolean -} -``` - -`offline: true` means the page/route is allowed to become available offline. - -`offline: false` makes the route network-only with offline fallback behavior for navigations. - -`precache: true` means the navigation page is cached during install. - -`precache: false` means the navigation page is runtime-cached after the first successful visit. - -Layout vars set section defaults. - -Page vars/frontmatter can override layout vars through the normal cascade. - -The cascade is: - -```txt -page vars -> layout vars -> global vars -> defaults -``` - -## Build-time injection model - -The current build model is: - -```txt -final Domstack manifest - -> manifestBuilt hook - -> context.defineServiceWorkerConstant('__DOMSTACK_SERVICE_WORKER_POLICY__', policy) - -> final /service-worker.js bundle -``` - -This is preferred over: - -- fetching `/domstack-manifest.json` at runtime -- fetching `/domstack-service-worker-policy.json` at runtime -- generating JavaScript globals with `importScripts()` -- using top-level await in service workers - -Policy changes change `/service-worker.js` bytes and trigger the browser update lifecycle. - -## Watch mode - -Watch mode does not produce a manifest policy. - -The service worker detects that the injected policy constant is missing and installs as a no-op cleanup worker. - -The watch worker: - -- calls `skipWaiting()` during install -- deletes owned caches during activation -- unregisters itself -- registers no fetch handler - -The browser client also unregisters workers and clears known caches in watch mode. - -This double layer matters because a previous production worker can serve cached HTML/JS before the watch-mode client code runs. - -Watch builds disable esbuild splitting so `/service-worker.js` stays self-contained during cleanup. - -## Client registration helper behavior - -A future reusable client helper should: - -1. No-op when `navigator.serviceWorker` is unavailable. -2. Clean up when `DOMSTACK_MANIFEST_ENABLED` is false. -3. Register after `window.load` by default. -4. Register with the stable service-worker URL/scope from Domstack defines. -5. Use `{ type: 'module', updateViaCache: 'none' }`. -6. Detect `installing`, `waiting`, and `active` states immediately after registration. -7. Expose callbacks/events for ready, update available, updating, reset, error, and online/offline state. -8. Avoid hard-coded blocking dialogs. -9. Provide a default reset query param such as `?reset-sw`. -10. Reload once on `controllerchange` after an accepted update. - -## Possible reusable API - -Start with reusable imports rather than generated service-worker source: - -```ts -// src/service-worker.ts -import '@domstack/static/service-worker/static-mpa' -``` - -```ts -// src/global.client.ts -import { registerDomstackServiceWorker } from '@domstack/static/client/service-worker' - -registerDomstackServiceWorker() -``` - -This keeps service workers inspectable and customizable. - -A higher-level preset can come later if the helper API stabilizes. - -## Recovery design - -Every standard path should include two recovery tiers. - -### Recoverable reset - -If page JS still loads, a query param should reset worker state: - -```txt -/?reset-sw -``` - -Behavior: - -1. Post `RESET_SERVICE_WORKER` to active/waiting/installing workers. -2. Unregister matching registrations. -3. Delete known domstack cache prefixes. -4. Remove the reset query param. -5. Reload from the network. - -### Emergency replacement worker - -A rescue worker can be deployed at the exact production service-worker URL: - -```txt -/service-worker.js -``` - -It should: - -- call `skipWaiting()` during install -- have no `fetch` handler -- delete known domstack caches during activate -- reload or let clients reload after control changes - -The exact URL requirement is important. - -Deploying a rescue worker at a different URL leaves the broken worker active. - -## Open questions - -- Should domstack ship reusable static-MPA service-worker modules, or keep examples as copyable recipes? -- Should core expose helper utilities for deriving runtime strategy and precache keys from manifest entries? -- Should the public `domstack-manifest.json` schema remain a packaged artifact if service-worker use mostly relies on injected constants? -- How much default update UI should a helper provide versus only dispatching events? diff --git a/plans/workbox-workflow-integration.md b/plans/workbox-workflow-integration.md deleted file mode 100644 index b4a6c3fb..00000000 --- a/plans/workbox-workflow-integration.md +++ /dev/null @@ -1,227 +0,0 @@ -# Workbox Workflow Integration Plan - -## Status: Example implemented with injected policy - -`examples/static-mpa-workbox-offline` demonstrates the current preferred Workbox integration model. - -Domstack does not run Workbox `injectManifest` and does not generate a JavaScript global loaded with `importScripts()`. - -Instead, `hooks.manifestBuilt` computes a Workbox-oriented policy from the finalized Domstack manifest and injects it into the final `/service-worker.js` bundle with `context.defineServiceWorkerConstant()`. - -The authored service worker remains normal user code. - -It imports Workbox packages directly and passes the generated `precacheManifest` field to Workbox APIs. - -## Goals - -- Support Workbox without forcing it into domstack core. -- Generate correct Workbox precache data from Domstack's real emitted outputs. -- Keep user-authored service-worker code inspectable and customizable. -- Use Workbox for mature precaching, routing, strategies, plugins, and update helpers. -- Avoid runtime policy fetches and generated global scripts. -- Keep watch mode safe when no manifest policy exists. - -## Non-goals - -- Do not make service workers automatic for every domstack build. -- Do not require Workbox as a core dependency for sites that do not opt into it. -- Do not cache API/data endpoints by default. -- Do not hide service-worker stickiness or recovery requirements. -- Do not replace custom user-authored service workers. - -## Current example architecture - -The Workbox example uses: - -- `src/globals/domstack-manifest/domstack-manifest.settings.ts` -- `src/globals/domstack-manifest/policy-build.ts` -- `src/globals/service-worker/service-worker.ts` -- `src/globals/global-client/*` - -The manifest settings hook injects a policy constant: - -```ts -context.defineServiceWorkerConstant('__DOMSTACK_WORKBOX_POLICY__', { - version: manifest.version, - offlineFallbackUrl: '/offline/', - precacheManifest: [ - { url: '/', revision: 'sha256hex...' }, - { url: '/about/', revision: 'sha256hex...' }, - { url: '/global-ABC123.css', revision: null, integrity: 'sha256-...' }, - ], - runtimeUrls: ['/progressive-cache/'], - networkOnlyUrls: ['/admin/'], -}) -``` - -The service worker reads the injected policy inside the manifest-enabled branch: - -```ts -declare const __DOMSTACK_WORKBOX_POLICY__: StaticMpaWorkboxServiceWorkerPolicy - -if (manifestEnabled) { - const policy = __DOMSTACK_WORKBOX_POLICY__ - precacheAndRoute(policy.precacheManifest) -} -``` - -The policy constant must not be read at module top level in watch mode. - -Watch mode builds do not define it. - -## Workbox APIs currently used - -The example uses: - -- `workbox-precaching` - - `precacheAndRoute()` - - `cleanupOutdatedCaches()` - - `matchPrecache()` where needed by fallback behavior -- `workbox-routing` - - `registerRoute()` - - `setCatchHandler()` -- `workbox-strategies` - - `NetworkFirst` - - `NetworkOnly` -- `workbox-cacheable-response` - - `CacheableResponsePlugin` -- `workbox-expiration` - - `ExpirationPlugin` -- `workbox-recipes` - - `offlineFallback()` -- `workbox-window` - - registration lifecycle events - - `messageSkipWaiting()` - -## Policy shape - -Workbox's native precache input is: - -```ts -type WorkboxPrecacheEntry = { - url: string - revision: string | null - integrity?: string -} -``` - -The example policy includes that native shape plus app-specific route policy: - -```ts -type StaticMpaWorkboxServiceWorkerPolicy = { - version: string - offlineFallbackUrl: string - precacheManifest: WorkboxPrecacheEntry[] - runtimeUrls: string[] - networkOnlyUrls: string[] -} -``` - -Only `precacheManifest` is passed directly to Workbox precaching. - -`runtimeUrls`, `networkOnlyUrls`, and `offlineFallbackUrl` are app policy and are mapped explicitly to Workbox routing/strategy APIs. - -## Why injected policy is preferred - -Injected policy has these advantages: - -- no runtime fetch for a policy JSON file -- no generated JS file imported by the service worker -- no `importScripts()` convention -- no Workbox `self.__WB_MANIFEST` source transform -- policy changes alter `/service-worker.js` bytes -- browser update lifecycle is triggered naturally -- the authored service worker remains regular bundled module code - -This means Domstack only needs the general `manifestBuilt` hook and final service-worker build step. - -It does not need Workbox-specific source transformation in core. - -## Watch mode - -Workbox precaching is disabled in watch mode. - -Watch mode sets `DOMSTACK_MANIFEST_ENABLED=false` and does not run the manifest/policy injection path. - -The service worker branch for watch mode: - -- installs immediately -- deletes owned caches -- unregisters itself -- registers no Workbox routes -- does not touch the injected policy constant - -The client also unregisters existing workers and clears known caches in watch mode. - -Watch builds disable esbuild splitting so `/service-worker.js` stays self-contained during cleanup. - -## Client lifecycle - -The Workbox example uses `workbox-window` because it provides cleaner lifecycle events than hand-rolled registration logic. - -Current behavior: - -- `installing` shows β€œInstalling offline cache…” -- `activated` shows ready state for first install -- `waiting` prompts for update or applies a previously waiting update -- `controlling` reloads after accepted updates -- `redundant` logs to the console only - -Watch-mode cleanup happens before normal registration and does not wait for `window.load`. - -Production registration waits for `window.load`. - -## Runtime caching policy - -The example only runtime-caches routes selected by manifest vars. - -It uses Workbox plugins to keep runtime cache behavior bounded: - -- `CacheableResponsePlugin` limits which responses can enter the cache. -- `ExpirationPlugin` limits cache age/count. - -The example does not cache arbitrary API/data requests by default. - -## Potential package helper - -A future helper could live outside core or as an optional export: - -```ts -import { createWorkboxPolicy } from '@domstack/static/workbox' - -export default { - hooks: { - manifestBuilt: [context => { - context.defineServiceWorkerConstant( - '__APP_WORKBOX_POLICY__', - createWorkboxPolicy(context.manifest, options), - ) - }], - }, -} -``` - -The helper could cover: - -- max precache size -- include/exclude filters -- revision/null handling for hashed URLs -- optional integrity inclusion -- route-policy derivation from selected `manifestVars` -- warnings for skipped entries - -This should remain optional. - -Domstack core should continue exposing generic manifest hooks rather than hard-coding Workbox behavior. - -## Deprecated ideas - -These ideas were considered but are not the current direction: - -- generating a public Workbox manifest module and importing it from the service worker -- fetching a policy JSON file during service-worker install -- using `importScripts()` to load generated globals -- transforming `self.__WB_MANIFEST` like Workbox `injectManifest` -- generating the entire Workbox service worker from core config - -They remain possible for external integrations, but the example and current plan prefer injected constants. diff --git a/esbuild.settings.js b/site/globals/esbuild.settings.js similarity index 88% rename from esbuild.settings.js rename to site/globals/esbuild.settings.js index e09d04c4..5cd2d4da 100644 --- a/esbuild.settings.js +++ b/site/globals/esbuild.settings.js @@ -1,5 +1,5 @@ /** - * @import { BuildOptions } from '.' + * @import { BuildOptions } from '#types' */ /** diff --git a/global.client.ts b/site/globals/global.client.ts similarity index 98% rename from global.client.ts rename to site/globals/global.client.ts index 91d0ccab..f185b849 100644 --- a/global.client.ts +++ b/site/globals/global.client.ts @@ -1,3 +1,5 @@ +/// + const toc = document.querySelector('.table-of-contents') const tocLinks = Array.from(toc?.querySelectorAll('a[href^="#"]') ?? []) const main = toc?.closest('.app-main') diff --git a/global.css b/site/globals/global.css similarity index 67% rename from global.css rename to site/globals/global.css index 3216b5e1..9caecd4c 100644 --- a/global.css +++ b/site/globals/global.css @@ -1,3 +1,5 @@ +/* A custom root layout opts into DOMStack's shared base styles explicitly. */ +@import '../../lib/defaults/default.style.css'; @import 'markdown-it-github-alerts/styles/github-base.css' layer(domstack.global); @import 'markdown-it-github-alerts/styles/github-colors-light.css' layer(domstack.global); @import 'markdown-it-github-alerts/styles/github-colors-dark-media.css' layer(domstack.global); @@ -7,7 +9,7 @@ @font-face { font-family: 'DSWeiss-Gotisch'; - src: url('./fonts/ds-weiss-gotisch/DSWeiss-Gotisch.ttf') format('truetype'); + src: url('../../fonts/ds-weiss-gotisch/DSWeiss-Gotisch.ttf') format('truetype'); } body { @@ -18,6 +20,17 @@ font-family: 'DSWeiss-Gotisch', serif; } + /* Site navigation uses relative URLs; absolute web links point off-site. + Empty alternative text keeps this visual marker out of accessible names. + Image links (including badges) already supply their own presentation. */ + a:is([href^='https://' i], [href^='http://' i], [href^='//']):not(:has(img, svg))::after { + content: 'β†—' / ''; + display: inline-block; + margin-inline-start: 0.2em; + font-family: var(--system-sans, system-ui, sans-serif); + font-size: 0.8em; + } + /* Generated Markdown table of contents */ @media (min-width: 82rem) { .app-main.has-sidebar-toc { @@ -27,7 +40,7 @@ max-inline-size: calc(16rem + 4rem + 56em + 2em); } - .docs-content { + .app-main.has-sidebar-toc > .docs-content { grid-column: 2; min-inline-size: 0; } diff --git a/site/globals/global.data.ts b/site/globals/global.data.ts new file mode 100644 index 00000000..053ac730 --- /dev/null +++ b/site/globals/global.data.ts @@ -0,0 +1,10 @@ +import type { GlobalDataFunctionParams } from '../../types.ts' +import { render } from 'fragtml' +import { collectDocsNavigation, docsIndex } from '../layouts/docs/navigation.js' + +// The collector excludes the index, whose Markdown consumes docsIndexHtml. +// Other source pages are rendered without their data-subscribing layouts. +export default async function ({ pages }: GlobalDataFunctionParams) { + const docsNavigation = await collectDocsNavigation(pages) + return { docsNavigation, docsIndexHtml: render(docsIndex(docsNavigation)) } +} diff --git a/site/layouts/docs/docs.layout.client.ts b/site/layouts/docs/docs.layout.client.ts new file mode 100644 index 00000000..2c1420dd --- /dev/null +++ b/site/layouts/docs/docs.layout.client.ts @@ -0,0 +1,221 @@ +/// + +const navigation = document.querySelector('.docs-navigation') +const links = Array.from(navigation?.querySelectorAll('a[href]') ?? []) +const headings = Array.from(document.querySelectorAll('#docs-content h2[id], #docs-content h3[id], #docs-content h4[id]')) +const breadcrumbList = document.querySelector('.docs-breadcrumb ol') +const breadcrumbPage = breadcrumbList?.querySelector('[aria-current="page"]') +let breadcrumbHeading: HTMLAnchorElement | undefined +// Keep this breakpoint in sync with docs.layout.css. +const desktop = matchMedia('(min-width: 64rem)') + +function decodedHash (hash: string): string { + try { + return decodeURIComponent(hash) + } catch { + return hash + } +} + +function fragmentTarget (hash: string): HTMLElement | null { + const id = hash.slice(1) + return document.getElementById(id) ?? document.getElementById(decodedHash(id)) +} + +function sectionLink (target: HTMLElement | null): HTMLAnchorElement | undefined { + const findLink = (id: string): HTMLAnchorElement | undefined => + links.find(link => link.pathname === location.pathname && link.hash !== '' && fragmentTarget(link.hash)?.id === id) + if (!target) return + const exact = findLink(target.id) + if (exact) return exact + // The shared ToC includes h2/h3. Deeper headings belong to the nearest + // preceding section that is represented there. + const index = headings.indexOf(target) + if (index < 0) return + for (const heading of headings.slice(0, index).reverse()) { + const link = findLink(heading.id) + if (link) return link + } +} + +function updateBreadcrumb (target: HTMLElement | null): void { + if (!breadcrumbList || !breadcrumbPage) return + if (!target || !headings.includes(target)) { + breadcrumbHeading?.parentElement?.remove() + breadcrumbHeading = undefined + breadcrumbPage.setAttribute('aria-current', 'page') + return + } + if (!breadcrumbHeading) { + const item = document.createElement('li') + item.className = 'docs-breadcrumb-section' + breadcrumbHeading = document.createElement('a') + breadcrumbHeading.setAttribute('aria-current', 'location') + item.append(breadcrumbHeading) + breadcrumbList.append(item) + } + const title = target.textContent?.trim().replace(/\s+/g, ' ') ?? '' + breadcrumbHeading.textContent = title + breadcrumbHeading.title = title + breadcrumbHeading.setAttribute('href', `#${encodeURIComponent(target.id)}`) + breadcrumbPage.removeAttribute('aria-current') +} + +function updateLocation (reveal: boolean): void { + // The browser accepts both literal and decoded fragments. Markdown heading + // IDs can themselves contain percent escapes. Resolve the same target before + // the browser's initial fragment scroll (when :target may not yet be set). + const target = fragmentTarget(location.hash) + updateBreadcrumb(target) + const currentSection = sectionLink(target) + let active: HTMLAnchorElement | undefined + for (const link of links) { + const samePage = link.pathname === location.pathname + const sameSection = link === currentSection + if (sameSection || (samePage && !link.hash)) { + link.setAttribute('aria-current', sameSection ? 'location' : 'page') + } else { + link.removeAttribute('aria-current') + } + if (sameSection) active = link + } + active ??= links.find(link => link.getAttribute('aria-current') === 'page') + if (!active) return + const section = active.closest('details') + if (section) section.open = true + if (reveal && navigation && (desktop.matches || navigation.closest('dialog[open]'))) { + // Scroll only the sidebar, never the document away from its anchor. + const bounds = navigation.getBoundingClientRect() + const linkBounds = active.getBoundingClientRect() + if (linkBounds.top < bounds.top || linkBounds.bottom > bounds.bottom) { + navigation.scrollTop += linkBounds.top - bounds.top + } + } +} + +/** + * Scrolling describes the reading position; it must never navigate or focus. + * Replace the current history entry, preserving its state and query string. + * Native fragment navigation wins over a pending scroll update. + */ +function trackReadingPosition (): void { + let pending: ReturnType | undefined + let lastScrollY = scrollY + let navigating = false + const cancel = (): void => { + clearTimeout(pending) + pending = undefined + } + const finishNavigation = (): void => { + cancel() + lastScrollY = scrollY + navigating = false + } + const waitForNavigation = (): void => { + cancel() + // Also handles browsers without scrollend, and links that don't move the + // page. Each animation frame's scroll event postpones this idle fallback. + pending = setTimeout(finishNavigation, 150) + } + const beginNavigation = (): void => { + navigating = true + waitForNavigation() + } + const update = (): void => { + pending = undefined + if (scrollY === lastScrollY || document.querySelector('.docs-menu[open]')) return + lastScrollY = scrollY + const threshold = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0 + let current: HTMLElement | undefined + for (const heading of headings) { + if (!heading.getClientRects().length) continue // Ignore closed disclosures. + if (heading.getBoundingClientRect().top > threshold + 1) break + current = heading + } + const hash = current ? `#${encodeURIComponent(current.id)}` : '' + if (fragmentTarget(location.hash) === current || location.hash === hash) return + history.replaceState(history.state, '', `${location.pathname}${location.search}${hash}`) + updateLocation(true) // replaceState does not emit hashchange. + } + addEventListener('hashchange', beginNavigation) + addEventListener('popstate', beginNavigation) + addEventListener('scrollend', () => { + if (navigating) finishNavigation() + }) + // Initial deep links, anchor animations, and history restoration choose their + // own URL. Resume reading-position tracking only after that scroll settles. + const start = (): void => { + beginNavigation() + addEventListener('scroll', () => { + if (navigating) waitForNavigation() + // Throttle, not debounce: keep the URL current during continuous reading. + else pending ??= setTimeout(update, 300) + }, { passive: true }) + } + if (document.readyState === 'complete') start() + else addEventListener('load', start, { once: true }) +} + +/** + * One server-rendered navigation tree has two homes: the desktop sidebar and + * a native modal dialog on small screens. Without this enhancement it stays + * an ordinary details disclosure in the document. + */ +function enhanceMenu (): void { + const shell = document.querySelector('.docs-shell') + const content = document.getElementById('docs-content') + const dialog = document.querySelector('.docs-menu') + const toggle = document.querySelector('.site-menu-toggle') + if (!navigation || !shell || !content || !dialog || !toggle || typeof dialog.showModal !== 'function') return + const nav = navigation + shell.setAttribute('data-navigation-enhanced', '') + + const updateMenuState = (): void => { + toggle.setAttribute('aria-expanded', String(dialog.open)) + } + + const updateMenuLayout = (): void => { + const hadFocus = dialog.contains(document.activeElement) + const hadNavigationFocus = nav.contains(document.activeElement) + if (desktop.matches) { + dialog.close() + shell.insertBefore(nav, content) + if (hadFocus) nav.querySelector('a[aria-current="page"]')?.focus({ preventScroll: true }) + } else { + dialog.append(nav) + } + nav.open = true + toggle.hidden = desktop.matches + if (!desktop.matches && hadNavigationFocus) toggle.focus({ preventScroll: true }) + updateMenuState() + updateLocation(true) + } + + toggle.addEventListener('click', () => { + dialog.showModal() + updateMenuState() + updateLocation(true) + }) + dialog.addEventListener('close', updateMenuState) + // Native dialog handles Escape, focus containment, and return to the opener. + nav.addEventListener('click', event => { + const link = (event.target as Element).closest('a[href]') + if (!link || !dialog.open || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + const sameDocument = link.origin === location.origin && link.pathname === location.pathname && link.search === location.search + if (!sameDocument) return // A cross-page navigation unloads the document. + dialog.close() + const target = fragmentTarget(link.hash) ?? content + const hadTabindex = target.hasAttribute('tabindex') + if (!hadTabindex) target.setAttribute('tabindex', '-1') + target.focus({ preventScroll: true }) + if (!hadTabindex) target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true }) + // Let the browser perform the link's normal fragment navigation. + }) + desktop.addEventListener('change', updateMenuLayout) + updateMenuLayout() +} + +enhanceMenu() +updateLocation(true) +addEventListener('hashchange', () => updateLocation(true)) +trackReadingPosition() diff --git a/site/layouts/docs/docs.layout.css b/site/layouts/docs/docs.layout.css new file mode 100644 index 00000000..cdae92f9 --- /dev/null +++ b/site/layouts/docs/docs.layout.css @@ -0,0 +1,294 @@ +@layer domstack.layout { + .docs-shell { + --docs-navigation-width: 17rem; + container-type: inline-size; + min-inline-size: 0; + max-inline-size: 90rem; + margin-inline: auto; + } + + .docs-content { + --docs-content-padding-inline: max(1.25rem, env(safe-area-inset-right)); + min-inline-size: 0; + max-inline-size: 56rem; + padding: 0 var(--docs-content-padding-inline) 4rem; + overflow-wrap: anywhere; + } + + .docs-content > h1 { + margin-block-start: 2rem; + font-size: 2.5rem; + } + + .docs-content h2 { + font-size: 1.9rem; + } + + .docs-content h3 { + font-size: 1.5rem; + } + + .docs-content h4 { + font-size: 1.25rem; + } + + .docs-navigation { + padding: 1.25rem; + overflow-wrap: anywhere; + font-family: var(--system-sans, system-ui, sans-serif); + font-size: 0.85rem; + line-height: 1.5; + border-block-end: 1px solid var(--site-border); + + summary { + cursor: pointer; + } + + > summary { + font-weight: 600; + } + + nav > a, + nav > ul > li > a, + summary a { + display: block; + padding: 0.65rem 0.75rem; + color: var(--text); + border-radius: 0.35rem; + } + + nav > a { + margin-block-end: 0.5rem; + } + + nav summary { + display: flex; + align-items: center; + list-style: none; + border-radius: 0.35rem; + } + + nav summary::-webkit-details-marker { + display: none; + } + + .docs-navigation-chevron { + display: block; + flex: none; + inline-size: 16px; + block-size: 16px; + margin-inline: 6px; + transform-origin: center; + + @media (prefers-reduced-motion: no-preference) { + transition: transform 200ms ease; + } + } + + nav details[open] > summary > .docs-navigation-chevron { + transform: rotate(-180deg); + } + + nav summary a { + flex: 1; + } + + ul { + list-style: none; + margin: 0; + padding-inline-start: 1rem; + } + + details > ul { + margin: 0.3rem 0 0.75rem 0.75rem; + border-inline-start: 1px solid var(--site-border); + } + + li > a { + display: block; + padding: 0.4rem 0.75rem; + border-radius: 0.35rem; + } + + nav > ul { + padding: 0; + } + + a { + color: var(--site-muted); + text-decoration: none; + box-shadow: none; + } + + a[aria-current] { + color: var(--link-text); + font-weight: 600; + } + + a[aria-current='page'] { + background: color-mix(in srgb, var(--link-text) 10%, var(--background)); + } + + a:hover { + color: var(--link-text); + text-decoration: underline; + } + } + + .docs-menu { + inline-size: 100%; + max-inline-size: none; + block-size: 100dvh; + max-block-size: none; + margin: 0; + padding: 0; + border: 0; + /* The full-viewport backdrop supplies the glass behind the menu content. */ + background: transparent; + color: var(--text); + overflow: hidden; + } + + .docs-menu[open] { + display: flex; + flex-direction: column; + } + + .docs-menu::backdrop { + background: var(--site-glass-background); + -webkit-backdrop-filter: var(--site-glass-filter); + backdrop-filter: var(--site-glass-filter); + } + + .docs-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex: none; + min-block-size: var(--site-header-height); + padding: 0.75rem 1.25rem; + border-block-end: 1px solid var(--site-border); + font: 600 0.9rem var(--system-sans, system-ui, sans-serif); + + form { + margin: 0; + } + + button { + min-block-size: 2.75rem; + padding: 0.5rem 0.75rem; + font: inherit; + box-shadow: none; + } + } + + #docs-menu-title { + font: 400 1.8rem / 1.2 'DSWeiss-Gotisch', serif; + } + + .docs-menu .docs-navigation { + min-block-size: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding-block-end: max(2rem, env(safe-area-inset-bottom)); + border: 0; + } + + .docs-shell[data-navigation-enhanced] .docs-navigation > summary { + display: none; + } + + html:has(.docs-menu[open]) { + overflow: hidden; + } + + /* Keep this breakpoint in sync with docs.layout.client.ts. */ + @media (min-width: 64rem) { + .docs-shell { + display: grid; + grid-template-columns: var(--docs-navigation-width) minmax(0, 1fr); + min-block-size: calc(100dvh - var(--site-header-height)); + } + + .docs-content { + --docs-content-padding-inline: clamp(2rem, 4vw, 4rem); + --docs-breadcrumb-width: calc(100cqi - var(--docs-navigation-width) - 1px); + grid-column: 2; + padding: 0 var(--docs-content-padding-inline) 5rem; + border-inline-start: 1px solid var(--site-border); + } + + .docs-shell > .docs-navigation { + position: sticky; + inset-block-start: var(--site-header-height); + align-self: start; + max-block-size: calc(100dvh - var(--site-header-height)); + overflow-y: auto; + scrollbar-gutter: stable; + border: 0; + + > summary { + display: none; + } + } + } + + .docs-breadcrumb { + position: sticky; + inset-block-start: var(--site-header-height); + z-index: 10; + box-sizing: border-box; + inline-size: var(--docs-breadcrumb-width, 100cqi); + margin-inline: calc(-1 * var(--docs-content-padding-inline)); + margin-block-end: 1.5rem; + padding: 0.5rem var(--docs-content-padding-inline); + border-block-end: 1px solid var(--site-border); + background: var(--site-glass-background); + -webkit-backdrop-filter: var(--site-glass-filter); + backdrop-filter: var(--site-glass-filter); + font: 0.8rem / 1.5 var(--system-sans, system-ui, sans-serif); + color: var(--site-muted); + + ol { + display: flex; + flex-wrap: wrap; + column-gap: 0.5em; + padding: 0; + margin: 0; + list-style: none; + } + + li { + display: flex; + align-items: center; + column-gap: 0.5em; + min-inline-size: 0; + } + + li + li::before { + content: ''; + flex: none; + inline-size: 0.35em; + block-size: 0.35em; + border-inline-end: 1px solid currentColor; + border-block-end: 1px solid currentColor; + transform: rotate(-45deg); + } + + .docs-breadcrumb-section { + flex: 1 1 0; + + a { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + li > span { + color: var(--text); + } + } +} diff --git a/site/layouts/docs/docs.layout.js b/site/layouts/docs/docs.layout.js new file mode 100644 index 00000000..594caf0b --- /dev/null +++ b/site/layouts/docs/docs.layout.js @@ -0,0 +1,104 @@ +/** + * @import { LayoutFunction } from '#types' + * @import { HtmlResult } from 'fragtml/types.js' + * @import { NavigationEntry } from './navigation.js' + */ +import { html, raw, render } from 'fragtml' +import { load } from 'cheerio' +import { docsIndexUrl, navigationHref, sectionLinks } from './navigation.js' + +export const parentLayout = 'root' +export const vars = { dataDeps: ['docsNavigation'] } + +/** @param {string} url The canonical page URL, without a deployment base path. */ +export function breadcrumb (url) { + const segments = url.split('/').filter(Boolean) + const isDirectory = url.endsWith('/') + const depth = segments.length - (isDirectory ? 0 : 1) + + return html` + ` +} + +/** @param {NavigationEntry[]} entries @param {string} pageUrl */ +export function navigation (entries, pageUrl) { + return html` +
    + Documentation contents + +
    + ` +} + +/** + * Keep Markdown's local ToCs useful on GitHub, but replace them on the website. + * Parsing only inner content also keeps the shared navigation out of itself. + * @param {string} content + */ +export function documentationContent (content) { + const $ = load(content, {}, false) + $('.table-of-contents').each((_, toc) => { + const heading = $(toc).prev() + if (heading.is('h2, h3') && heading.text().trim().toLowerCase() === 'table of contents') heading.remove() + $(toc).remove() + }) + + return raw($.html()) +} + +/** @type {LayoutFunction, string | HtmlResult, HtmlResult, { docsNavigation: NavigationEntry[] }>} */ +export default function docsLayout ({ children, page, data }) { + return html` +
    + ${navigation(data.docsNavigation, page.url)} +
    + ${breadcrumb(page.url)} + ${documentationContent(typeof children === 'string' ? children : render(children))} +
    + +
    + Documentation +
    +
    +
    +
    + ` +} diff --git a/site/layouts/docs/docs.layout.test.js b/site/layouts/docs/docs.layout.test.js new file mode 100644 index 00000000..a5af862e --- /dev/null +++ b/site/layouts/docs/docs.layout.test.js @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { load } from 'cheerio' +import { render } from 'fragtml' +import { breadcrumb } from './docs.layout.js' + +for (const { url, labels, links } of [ + { url: '/docs/', labels: ['Home', 'docs'], links: ['../'] }, + { url: '/docs/pages/', labels: ['Home', 'docs', 'pages'], links: ['../../', '../'] }, + { url: '/docs/migrations/v12-migration.html', labels: ['Home', 'docs', 'migrations', 'v12-migration'], links: ['../../', '../', './'] }, + { url: '/docs/nested/page/', labels: ['Home', 'docs', 'nested', 'page'], links: ['../../../', '../../', '../'] }, +]) { + test(`breadcrumbs for ${url}`, () => { + const $ = load(render(breadcrumb(url))) + const nav = $('nav[aria-label="Breadcrumb"]') + assert.deepEqual(nav.find('li').map((_, element) => $(element).text().trim()).get(), labels) + assert.deepEqual(nav.find('a').map((_, element) => $(element).attr('href')).get(), links) + assert.equal(nav.find('[aria-current="page"]').length, 1) + assert.equal(nav.find('[aria-current="page"]').text(), labels.at(-1)) + + for (const basePath of ['', '/domstack']) { + const base = `https://example.com${basePath}${url}` + assert.ok(links[0]) + assert.equal(new URL(links[0], base).pathname, `${basePath}/`) + if (links[1]) assert.equal(new URL(links[1], base).pathname, `${basePath}/docs/`) + } + }) +} + +test('breadcrumb labels escape HTML', () => { + const $ = load(render(breadcrumb('/docs/\n```', {}) + const $ = load(render(documentationContent(content))) + assert.equal($('.table-of-contents, #table-of-contents').length, 0) + assert.equal($('#content').text(), 'Content') + assert.equal($('pre code').text().trim(), '') + const entries = [{ title: '', url: '/docs/page/', sections: [], group: '' }] + const nav = load(render(navigation(entries, '/docs/'))) + assert.equal(nav('script').length, 0) + assert.equal(nav('nav > ul > li > a').text(), '') + const index = load(render(docsIndex(entries))) + assert.equal(index('script').length, 0) + assert.equal(index('.docs-index > h2').text(), '') + assert.equal(index('.docs-index a').text(), '') +}) diff --git a/site/layouts/root/root.layout.css b/site/layouts/root/root.layout.css new file mode 100644 index 00000000..bcf4b975 --- /dev/null +++ b/site/layouts/root/root.layout.css @@ -0,0 +1,172 @@ +@layer domstack.layout { + :root { + --site-header-height: 4.5rem; + --site-border: color-mix(in srgb, var(--text) 16%, var(--background)); + --site-muted: color-mix(in srgb, var(--text) 70%, var(--background)); + --site-glass-background: var(--background); + --site-glass-filter: none; + scrollbar-gutter: stable; + scroll-padding-block-start: calc(var(--site-header-height) + 1rem); + } + + body.site { + display: flex; + flex-direction: column; + min-block-size: 100dvh; + margin: 0; + padding: 0; + } + + .site-body { + flex: 1; + min-inline-size: 0; + } + + .site-header, + .site-footer { + font-family: var(--system-sans, system-ui, sans-serif); + font-size: 0.875rem; + } + + .site-header { + position: sticky; + inset-block-start: 0; + z-index: 20; + background: var(--site-glass-background); + -webkit-backdrop-filter: var(--site-glass-filter); + backdrop-filter: var(--site-glass-filter); + border-block-end: 1px solid var(--site-border); + } + + /* The header and mobile menu share a theme-aware glass surface, with a + solid fallback for unsupported browsers and reduced transparency. */ + @supports (backdrop-filter: blur(12px)) or (-webkit-backdrop-filter: blur(12px)) { + :root { + --site-glass-background: color-mix(in srgb, var(--background) 80%, transparent); + --site-glass-filter: blur(12px); + } + } + + @media (prefers-reduced-transparency: reduce) { + :root { + --site-glass-background: var(--background); + --site-glass-filter: none; + } + } + + .site-header-inner, + .site-footer-inner { + display: flex; + align-items: center; + gap: 1.5rem; + max-inline-size: 90rem; + margin-inline: auto; + padding-inline: max(1.25rem, env(safe-area-inset-left)) max(1.25rem, env(safe-area-inset-right)); + } + + .site-header-inner { + block-size: var(--site-header-height); + } + + .site-brand { + color: var(--text); + font-family: 'DSWeiss-Gotisch', serif; + font-size: 1.8rem; + line-height: 1; + text-decoration: none; + } + + .site-header .site-links { + margin-inline-start: auto; + } + + .site-links { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1.5rem; + } + + .site-links a { + color: var(--site-muted); + text-decoration: none; + } + + .site-links a:hover, + .site-links a[aria-current] { + color: var(--text); + text-decoration: underline; + text-underline-offset: 0.3em; + } + + .site-menu-toggle { + display: grid; + place-items: center; + flex: none; + inline-size: 2.75rem; + block-size: 2.75rem; + padding: 0; + border: 1px solid var(--site-border); + border-radius: 0.4rem; + background: var(--background); + color: var(--text); + box-shadow: none; + cursor: pointer; + } + + .site-menu-toggle[hidden] { + display: none; + } + + .site-skip-link { + position: fixed; + inset-block-start: 0.5rem; + inset-inline-start: 1rem; + z-index: 30; + padding: 0.5rem 1rem; + background: var(--background); + } + + .site-skip-link:not(:focus) { + clip-path: inset(50%); + inline-size: 1px; + block-size: 1px; + overflow: hidden; + } + + .site-footer { + border-block-start: 1px solid var(--site-border); + color: var(--site-muted); + } + + .site-footer .site-brand { + font-size: 1.25rem; + color: var(--site-muted); + } + + .site-footer-inner { + justify-content: space-between; + flex-wrap: wrap; + padding-block: 2rem; + } + + .site-copyright { + margin: 0; + } + + @media (max-width: 40rem) { + .site-header-inner, + .site-links { + gap: 1rem; + } + + .site-examples-link { + display: none; + } + + .site-footer-inner { + align-items: start; + flex-direction: column; + } + } +} diff --git a/site/layouts/root/root.layout.js b/site/layouts/root/root.layout.js new file mode 100644 index 00000000..d9e05030 --- /dev/null +++ b/site/layouts/root/root.layout.js @@ -0,0 +1,74 @@ +/** + * @import { LayoutFunction } from '#types' + * @import { HtmlResult } from 'fragtml/types.js' + */ +import { html, raw, render } from 'fragtml' +import { navigationHref } from '../docs/navigation.js' + +function year () { + return new Date().getFullYear() +} + +/** + * The website owns its document shell; the docs layout owns the sidebar and + * main landmark. Other pages get a simple main landmark here instead. + * @type {LayoutFunction<{ title?: string, layout?: string, lang?: string, basePath?: string }, string | HtmlResult, string>} + */ +export default function rootLayout ({ children, vars, page, scripts, styles }) { + const isDocs = vars.layout === 'docs' + const content = typeof children === 'string' ? raw(children) : children + const home = navigationHref(page.url, '/') + const docs = navigationHref(page.url, '/docs/') + const examples = navigationHref(page.url, '/docs/example-projects/') + /** @param {string} path */ + const assetUrl = path => path.startsWith('/') ? `${vars.basePath ?? ''}${path}` : path + const menuToggle = html` + + ` + + return render(html` + + + + + + + ${vars.title ? `${vars.title} | domstack` : 'domstack'} + ${styles?.map(style => html``)} + ${scripts?.map(script => html``)} + + + Skip to content + +
    + ${isDocs ? content : html`
    ${content}
    `} +
    + + + + `) +} diff --git a/site/layouts/root/root.layout.test.js b/site/layouts/root/root.layout.test.js new file mode 100644 index 00000000..79383c11 --- /dev/null +++ b/site/layouts/root/root.layout.test.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { load } from 'cheerio' +import { html, raw } from 'fragtml' +import rootLayout from './root.layout.js' +import docsLayout from '../docs/docs.layout.js' + +test('site shell preserves children, escapes metadata, and creates exactly one main landmark', async () => { + const code = 'first line\n indented\n\nlast line\n' + const page = /** @type {any} */ ({ url: '/docs/layouts/' }) + for (const layout of ['root', 'docs']) { + const contents = `
    ${code}
    ` + // Exercise the real nested rendering path that previously stripped newlines. + const children = layout === 'docs' + ? await docsLayout({ children: html`
    ${raw(contents)}
    `, page, vars: {}, data: { docsNavigation: [] } }) + : contents + const output = await rootLayout({ + children, + vars: { layout, title: '', lang: 'en', basePath: '/project' }, + page, + data: {}, + scripts: ['/site/client.js'], + styles: ['/site/styles.css'], + }) + const $ = load(output) + assert.equal($('title').text(), '<Title> | domstack') + assert.equal($('main').length, 1) + assert.equal($('main pre code').text(), code) + assert.equal($('main header, main footer').length, 0) + assert.equal($('script').attr('src'), '/project/site/client.js') + assert.equal($('link[rel="stylesheet"]').attr('href'), '/project/site/styles.css') + } +}) diff --git a/test-cases/cli-errors/index.test.js b/test-cases/cli-errors/index.test.js new file mode 100644 index 00000000..e39585ec --- /dev/null +++ b/test-cases/cli-errors/index.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { stripVTControlCharacters } from 'node:util' +import test from 'node:test' + +const bin = resolve(import.meta.dirname, '../../bin.js') + +for (const mode of ['build', 'watch']) { + test(`CLI ${mode} failures print complete diagnostic arrays and locations`, async t => { + const root = await mkdtemp(join(import.meta.dirname, '.tmp-cli-errors-')) + t.after(() => rm(root, { recursive: true, force: true })) + const src = join(root, 'src') + await mkdir(src) + await writeFile(join(src, 'page.md'), '# CLI diagnostics\n') + // Exceed util.inspect's default 100-item array limit as well as its depth limit. + const imports = Array.from({ length: 101 }, (_, index) => `import './missing-${index}.js'`) + await writeFile(join(src, 'client.js'), imports.join('\n')) + + const result = spawnSync(process.execPath, [ + bin, '--src', 'src', '--dest', 'dest', + ...(mode === 'watch' ? ['--watch-only'] : []), + ], { + cwd: root, + encoding: 'utf8', + timeout: 15_000, + maxBuffer: 4 * 1024 * 1024, + }) + + assert.ifError(result.error) + assert.equal(result.status, 1) + const output = result.stdout + result.stderr + // The one-shot handler also logs a separately formatted discovery tree. + const diagnostic = mode === 'watch' + ? result.stderr + : result.stdout.slice(result.stdout.indexOf('ERROR: DomStackAggregateError:')) + assert.match(diagnostic, /Error:/) + assert.equal(diagnostic, stripVTControlCharacters(diagnostic), 'redirected diagnostics should not contain terminal colors') + assert.doesNotMatch(output, /\[Array\]|\[Object\]|\.\.\. \d+ more items/) + assert.equal( + [...output.matchAll(/text: 'Could not resolve "\.\/missing-\d+\.js"'/g)].length, + imports.length, + 'every structured diagnostic should be expanded, not just the esbuild summary' + ) + assert.match(output, /lineText: "import '\.\/missing-100\.js'"/) + if (mode === 'watch') { + assert.match(result.stderr, /Unhandled domstack error/) + assert.match(result.stderr, /Error starting esbuild watch context/) + assert.match(result.stderr, /\[cause\]/) + } + }) +} diff --git a/test-cases/cli-errors/logging.test.js b/test-cases/cli-errors/logging.test.js new file mode 100644 index 00000000..efd170dd --- /dev/null +++ b/test-cases/cli-errors/logging.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import test from 'node:test' + +test('CLI summaries are concise and --verbose restores the build tree', async t => { + const root = await mkdtemp(join(import.meta.dirname, '.tmp-cli-logging-')) + t.after(() => rm(root, { recursive: true, force: true })) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'src', 'page.md'), '# Logging\n') + for (const verbose of [false, true]) { + const result = spawnSync(process.execPath, [ + resolve(import.meta.dirname, '../../bin.js'), '--src', 'src', '--dest', 'dest', + ...(verbose ? ['--verbose'] : []), + ], { cwd: root, encoding: 'utf8', timeout: 15000 }) + assert.ifError(result.error) + assert.equal(result.status, 0, result.stdout + result.stderr) + assert.match(result.stdout, /INFO: Built src β†’ dest/) + assert.match(result.stdout, /Build Success!/) + if (verbose) { + assert.match(result.stdout, /DEBUG:/) + assert.match(result.stdout, /page.md:/) + } else { + assert.doesNotMatch(result.stdout, /DEBUG:|page.md:/) + } + } +}) diff --git a/test-cases/watch-lifecycle/logging.test.js b/test-cases/watch-lifecycle/logging.test.js new file mode 100644 index 00000000..78ab6ed0 --- /dev/null +++ b/test-cases/watch-lifecycle/logging.test.js @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { setTimeout } from 'node:timers/promises' +import test from 'node:test' +import pino from 'pino' +import { DomStack } from '../../index.js' + +/** + * @param {() => boolean} check + */ +async function until (check) { + for (let attempt = 0; !check(); attempt++) { + assert.ok(attempt < 200, 'watch result did not arrive') + await setTimeout(25) + } +} + +for (const level of ['debug', 'silent']) { + test(`watch builds once per context and respects the ${level} logger`, { timeout: 20000 }, async t => { + const root = await mkdtemp(join(import.meta.dirname, 'logging-workspace-')) + const src = join(root, 'src') + const dest = join(root, 'dest') + await mkdir(src) + await Promise.all(Object.entries({ + 'page.html': '<p>Logging fixture</p>', + 'root.layout.js': 'export default ({children}) => children', + 'global.vars.js': "export default { layout: 'root' }", + 'client.js': 'console.log("initial")', + 'service-worker.js': 'console.log("worker")', + 'asset.txt': 'static asset', + 'esbuild.settings.js': ` + export const starts = [] + export default opts => ({ + ...opts, + plugins: [{ + name: 'count-starts', + setup(build) { + build.onStart(() => { starts.push(build.initialOptions.entryPoints) }) + } + }] + }) + `, + }).map(([file, content]) => writeFile(join(src, file), content))) + const settings = await import(pathToFileURL(join(src, 'esbuild.settings.js')).href) + /** @type {Array<{level: number, msg: string, errors?: Array<{text: string, location: {file: string, lineText: string}}>}>} */ + const records = [] + const logger = pino({ level }, { write: chunk => records.push(JSON.parse(chunk)) }) + const log = t.mock.method(console, 'log', () => {}) + const error = t.mock.method(console, 'error', () => {}) + const site = new DomStack(src, dest, { logger }) + t.after(async () => { + if (site.watching) await site.stopWatching() + await rm(root, { recursive: true, force: true }) + }) + await site.watch({ serve: false }) + await setTimeout(300) + assert.equal(settings.starts.length, 2, 'one browser build and one worker build, with no startup rebuild') + if (level === 'debug') { + assert.equal(records.filter(record => record.msg.endsWith('initial build complete')).length, 2) + assert.ok(records.some(record => record.level === 20 && record.msg.startsWith('Copy '))) + assert.ok(!records.some(record => record.level === 30 && record.msg.startsWith('Copy '))) + assert.ok(records.some(record => record.msg.startsWith('Static asset watcher ready'))) + } + + await writeFile(join(src, 'client.js'), 'import "./missing-client.js"') + await until(() => settings.starts.length >= 3) + if (level === 'debug') { + await until(() => records.some(record => record.msg === 'JS/CSS rebuild failed')) + const failure = records.find(record => record.msg === 'JS/CSS rebuild failed') + assert.match(failure?.errors?.[0]?.text ?? '', /missing-client/) + assert.match(failure?.errors?.[0]?.location.lineText ?? '', /import/) + } else { + await setTimeout(300) + } + await writeFile(join(src, 'client.js'), 'console.log("recovered")') + await until(() => settings.starts.length >= 4) + if (level === 'debug') { + await until(() => records.some(record => record.msg === 'JS/CSS rebuild complete')) + } + await writeFile(join(src, 'service-worker.js'), 'console.log("updated worker")') + await until(() => settings.starts.length >= 5) + if (level === 'debug') { + await until(() => records.some(record => record.msg === 'Service worker rebuild complete')) + } + await site.stopWatching() + if (level === 'silent') assert.deepEqual(records, []) + assert.equal(log.mock.callCount(), 0, 'watch output must not bypass the logger') + assert.equal(error.mock.callCount(), 0, 'watch errors must not bypass the logger') + }) +} diff --git a/test-cases/watch/index.test.js b/test-cases/watch/index.test.js index 95e08cb1..3c4438ec 100644 --- a/test-cases/watch/index.test.js +++ b/test-cases/watch/index.test.js @@ -170,6 +170,7 @@ function createTestLogger (logs) { const logger = { level: 'info', + debug () {}, /** @param {...unknown} args */ info (...args) { write(args) }, /** @param {...unknown} args */ diff --git a/tsconfig.json b/tsconfig.json index 94d8fab2..b9b1e533 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "types/**/*", "lib/**/*", "test-cases/**/*", + "site/**/*", "index.js", "bin.js", "types.ts"