Skip to content

Latest commit

Β 

History

741 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

domstack

npm version npm beta version Actions Status Coverage Status Types in JS Neocities

domstack: Cut the πŸͺ’ 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 ande Node.js 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.

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.

Table of Contents

[[toc]]

Usage

$ 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).

  • 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 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.

Given this source:

src/
β”œβ”€β”€ page.md                   # The home page
β”œβ”€β”€ style.css                 # Styles scoped to the home page
β”œβ”€β”€ client.ts                 # Browser code loaded by the home page
β”œβ”€β”€ layouts/
β”‚   β”œβ”€β”€ root.layout.ts        # The default layout for every page
β”‚   └── blog.layout.ts        # An optional layout selected by page variables
β”œβ”€β”€ globals/
β”‚   β”œβ”€β”€ global.css            # Styles loaded by every page
β”‚   β”œβ”€β”€ global.client.ts      # Browser code loaded by every page
β”‚   └── global.vars.ts        # Variables available to every page and layout
β”œβ”€β”€ about/
β”‚   └── page.md               # The /about/ page
β”œβ”€β”€ interactive/
β”‚   β”œβ”€β”€ page.html             # The /interactive/ page
β”‚   └── client.tsx            # Page-scoped browser UI written with JSX
└── blog/
    β”œβ”€β”€ page.ts               # The /blog/ page
    └── first-post/
        β”œβ”€β”€ README.md         # The /blog/first-post/ page
        └── diagram.svg       # A static asset colocated with the post

domstack produces output resembling the following (generated bundle hashes will vary):

public/
β”œβ”€β”€ index.html                # Home content rendered through root.layout.ts
β”œβ”€β”€ style-ABC123.css          # Bundle built from the home page's style.css
β”œβ”€β”€ client-ABC123.js          # Bundle built from the home page's client.ts
β”œβ”€β”€ globals/
β”‚   β”œβ”€β”€ global-ABC123.css     # Site-wide bundle built from global.css
β”‚   └── global.client-ABC123.js # Site-wide bundle built from global.client.ts
β”œβ”€β”€ about/
β”‚   └── index.html            # About content rendered through root.layout.ts
β”œβ”€β”€ interactive/
β”‚   β”œβ”€β”€ index.html            # Loads the bundle built from client.tsx
β”‚   └── client-ABC123.js      # Approximate output name for the TSX bundle
└── blog/
    β”œβ”€β”€ index.html            # Blog content rendered through the selected layout
    └── first-post/
        β”œβ”€β”€ index.html        # Post content rendered through the selected layout
        └── 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.

Pages can also have colocated assets:

  • style.css for page-specific styles
  • client.ts or client.tsx for page-specific browser code
  • page.vars.ts for page variables
  • *.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 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 and build a website with near-zero preparation.

Examples

A collection of examples can be found in the ./examples folder:

  • basic β€” A broad tour of Markdown, HTML, and TypeScript pages, nested pages and layouts, variables, styles, client bundles, and static assets.
  • blog β€” A blog with derived global data, generated archive pages, redirects, nested layouts, and feed templates.
  • css-modules β€” Using CSS Modules from page code alongside global and page styles.
  • default-layout β€” Building a Markdown site with DOMStack's built-in default layout and no custom layout.
  • esbuild-settings β€” Customizing the browser build through esbuild.settings.
  • markdown-settings β€” Customizing Markdown rendering with markdown-it.settings and Markdown-it plugins.
  • nested-dest β€” Using the project root as src while writing the built site to a nested public directory.
  • preact-isomorphic β€” Rendering with Preact on the server and mounting page-scoped Preact and JSX in the browser.
  • react β€” Configuring React and TypeScript for a page-scoped TSX client.
  • 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 β€” The offline static MPA pattern implemented with Workbox routing, strategies, and precaching.
  • string-layouts β€” Writing layouts that return plain HTML strings instead of using the default renderer.
  • tailwind β€” Integrating Tailwind CSS through an esbuild plugin.
  • type-stripping β€” Using Node.js type stripping for TypeScript pages and layouts, plus a page-scoped TSX client.
  • uhtml-isomorphic β€” Rendering with uhtml-isomorphic on the server and mounting or hydrating UI in the browser.
  • worker-example β€” Bundling and communicating with page-scoped JavaScript and TypeScript Web Workers.

To run an example:

$ 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!

(Did you make a cool DOMStack website that is open source? PR it to the list!)

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 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.

Pages

Pages are named directories inside src with one of the following page files:

  • md pages are CommonMark markdown pages, with an optional YAML front-matter block.
  • html pages are an inner HTML fragment that get inserted into the page layout.
  • ts pages are 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 run.

Variables are available in all pages. md and html pages support variable access via handlebars template blocks. ts pages receive variables as part of the argument passed to them. See the Variables section for more info.

Pages can define a special variable called 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 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:

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 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 template placeholders.
  • You can disable md page handlebars processing by setting the handlebars variable to false.
  • md pages support many github flavored markdown features.

An example of a md page:

---
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:

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 template placeholders.
  • You can disable html page handlebars processing by setting the handlebars variable to false.

An example html page:

<h2>Favorite frameworks</h2>
<ul>
  <li>React</li>
  <li>Vue</li>
  <li>Svelte</li>
  <!-- favoriteFramework defined in page.vars.ts -->
  <li>{{ vars.favoriteFramework }}</li>
</ul>

ts pages

A ts page looks like this:

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 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<T, U> where T is the variables type and U is the return type (defaults to any).
  • A ts page can export a vars variable provider 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 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:

import type { PageFunction } from '@domstack/static/types.js'

export const vars = {
  favoriteCookie: 'Chocolate Chip with Sea Salt'
}

const page: PageFunction<typeof vars> = async ({
  vars
}) => {
  return /* html */`<div>
    <p>This is just some html.</p>
    <p>My favorite cookie: ${vars.favoriteCookie}</p>
  </div>`
}

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, a safe-by-default HTML tagged template library. Here is a more realistic TypeScript example that uses fragtml and domstack page introspection.

import { html } from 'fragtml'
import type { HtmlResult } from 'fragtml/types.js'
import { dirname, basename } from 'node:path'
import type { PageFunction } from '@domstack/static/types.js'

type BlogVars = {
  favoriteCake: string
}

export const vars = {
  favoriteCake: 'Chocolate Cloud Cake'
}

const blogIndex: PageFunction<BlogVars, HtmlResult> = async ({
  vars: { favoriteCake },
  pages
}) => {
  const yearPages = pages.filter(page => dirname(page.pageInfo.path) === 'blog')
  return html`<div>
    <p>I love ${favoriteCake}!!</p>
    <ul>
      ${yearPages.map(yearPage => html`
        <li>
          <a href="${yearPage.pageInfo.url}">
            ${basename(yearPage.pageInfo.path)}
          </a>
        </li>
      `)}
    </ul>
  </div>`
}

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 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.

An example of a page style.css file:

/* /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.

An example of a page client.ts file:

/* /some-page/client.ts */
import { funnyLibrary } from 'funny-library'
import { someHelper } from '../helpers/foo.ts'

await someHelper()
await funnyLibrary()

.tsx

Client bundles support .tsx through esbuild's JSX transform.

Note

Wherever you see .tsx being used for a client bundle, you can also use .jsx. Type checking is supported in both file types. See 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 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 and 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:

npm install preact
// 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 for more details.

{
  "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:

npm install react react-dom
// 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 containing page-specific variables.

// 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 for the full variable cascade.

Draft pages

A complete draft page can use the same colocated files as a published page:

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 for all available extensions.

Example layout file names:

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.

---
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. To reuse a common frame across multiple layouts, see Compose nested layouts.

Layouts may also export an optional vars variable provider containing defaults for pages that use the layout:

export const vars = {
  showSidebar: true,
  pageType: 'article',
}

Layout vars are merged into the same resolved page variable cascade that pages, layouts, templates, and domstack manifest settings receive. Precedence is:

page/frontmatter vars > page.vars.* > layout vars > global.data/global.vars > domstack defaults

This makes layout vars useful for section-wide defaults while still letting individual pages override them.

The default root.layout.ts

A layout is a ts file that default-exports an async or sync function implementing an outer HTML template that houses the page's inner content (children). Think of the frame around a picture. That's a layout. πŸ–ΌοΈ

It is always passed a single object argument with the following entries. See Page data and introspection for details about the page and pages entries:

  • vars: The resolved page variable cascade, including domstack defaults, global vars/data, layout vars, page vars, and page builder vars/frontmatter. Pages can customize layouts by overriding global or layout defaults.
  • scripts: array of paths that should be included onto the page in a script tag src with type module.
  • styles: array of paths that should be included onto the page in a link rel="stylesheet" tag with the href pointing to the paths in the array.
  • children: A string containing the page's inner content, or whatever type your ts page function returns. md and html page types always return strings.
  • pages: An array of page data that you can use to generate index pages with, or any other page-introspection based content that you desire.
  • page: An object with metadata and other facts about the current page being rendered into the template. This will also be found somewhere in the pages array.

The default root.layout.ts is featured below, and is implemented with 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 for escaping, raw HTML, rendering, and fragment usage.

root.layout.ts can live anywhere in the src directory.

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<RootLayoutVars, string | HtmlResult, string> = ({
  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,
  pages,
  page,
}) => {
  return render(html`
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName}</title>
        <meta name="viewport" content="width=device-width, user-scalable=no" />
        <meta name="color-scheme" content="light dark" />
        ${scripts
          ? scripts.map(script => html`<script type="module" src="${script.startsWith('/') ? `${basePath ?? ''}${script}` : script}"></script>`)
          : null}
        ${styles
          ? styles.map(style => html`<link rel="stylesheet" href="${style.startsWith('/') ? `${basePath ?? ''}${style}` : style}" />`)
          : null}
      </head>
      <body class="safe-area-inset">
        <main class="mine-layout app-main">${typeof children === 'string' ? raw(children) : children}</main>
      </body>
    </html>
  `)
}

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 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) 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.

/* /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. Layout styles are bundled with esbuild and can bundle relative and npm css using css @import statements. DOMStack loads stylesheets in this order: global, layout, then page. Under the normal CSS cascade, later styles take precedence when origin, importance, cascade layer, and specificity are otherwise equal. This lets page styles override layout styles, and layout styles override global 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.

Note

Use ${layout-name}.layout.client.tsx when a layout client bundle contains JSX. You can also use .jsx. See Supported file types for all available extensions and .tsx client bundles for JSX configuration.

/* /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. Layout client bundles are built with esbuild and can bundle relative and npm modules using ESM import statements.

Layout types

Layouts can be typed using LayoutFunction<T, U, V> where:

  • T is the variables type
  • U is the type of content received from pages (defaults to any)
  • V is the layout's return type (defaults to string for HTML output)
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<ArticleLayoutVars, string | HtmlResult, string> = ({
  vars,
  children,
}) => {
  return render(html`
    <article>
      <h1>${vars.title}</h1>
      ${typeof children === 'string' ? raw(children) : children}
      ${vars.showSidebar ? html`<aside>Related articles</aside>` : null}
    </article>
  `)
}

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:

// src/global.vars.ts
export default {
  siteName: 'My site'
}

Synchronous function provider:

// src/global.vars.ts
export default function vars () {
  return {
    siteName: 'My site'
  }
}

Asynchronous function provider:

// src/global.vars.ts
export default async function vars () {
  return {
    siteName: 'My site'
  }
}

Pages and layouts receive an object with the following parameters:

Template files receive a similar set of variables:

  • vars: An object with the variables from global.vars.ts and global.data.ts.
  • pages: The available PageData collection.
  • 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. 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.

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 for all available extensions.

global.vars.ts

The global.vars.ts file should default-export a variable provider. The variables in this file are available to all pages, unless the page sets a variable with the same key, taking a higher precedence.

export default {
  siteName: 'The name of my website',
  authorName: 'Mr. Wallace'
}

browser variable

global.vars.ts can uniquely export a browser variable provider. These variables are made available in all client bundles.

export const browser = {
  'process.env.TRANSPORT': 'http',
  'process.env.HOST': 'localhost'
}

The exported object is passed to esbuild's 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 while also using the browser export will throw an error. Use one or the other.

global.client.ts

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.

Note

Use global.client.tsx when the global client bundle contains JSX. You can also use global.client.jsx. See Supported file types for all available extensions and .tsx client bundles for JSX configuration.

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:

/* global.css */
@layer domstack.global {
  /* Site-wide rules */
}
/* article.layout.css */
@layer domstack.layout {
  /* Layout rules */
}
/* 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 - Set the target to make esbuild run a few small transforms on your CSS and JS code.
  • jsx - Configure how esbuild transforms JSX and TSX.
  • jsxImportSource - Set this when using an automatic JSX runtime such as React or Preact.
  • define - Define compile-time constants for JS bundles. Setting define here conflicts with the browser export 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:

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<BuildOptions> => {
  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 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:

import type { BuildOptions } from '@domstack/static/types.js'

const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise<BuildOptions> => {
  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:

import type { BuildOptions } from '@domstack/static/types.js'

const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise<BuildOptions> => {
  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:

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 '<details><summary>' + md.utils.escapeHtml(m[1]) + '</summary>\n'
      } else {
        return '</details>\n'
      }
    }
  })

  md.use(markdownItPlantuml)

  return md
}

export default markdownItSettingsOverride
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 <br>
    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:

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 are initialized and before generated-page factories run.

Note

global.data.js works too. See Supported file types for all available extensions.

For data that aggregates across multiple pages β€” like blog indexes, sitemaps, or RSS feed content β€” use global.data.ts. It receives the fully resolved source-backed PageData[] array and returns an object that is passed to generated-page factories and stamped onto every source-backed and generated page's vars. The derived data is therefore available to every page, layout, and template at final render time.

// src/global.data.ts
import type { AsyncGlobalDataFunction } from '@domstack/static/types.js'
import { html, render } from 'fragtml'

type GlobalData = {
  blogPostsHtml: string
}

const buildGlobalData: AsyncGlobalDataFunction<GlobalData> = 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`
    <ul class="blog-index-list">
      ${blogPosts.map(p => html`
        <li class="blog-entry h-entry">
          <a class="blog-entry-link u-url u-uid p-name" href="${p.pageInfo.url}">
            ${p.vars?.title}
          </a>
        </li>
      `)}
    </ul>
  `)

  return { blogPostsHtml }
}

export default buildGlobalData

The returned object is stamped onto every page's vars before rendering, so any page or layout can read the derived data via vars:

<!-- src/page.md -->
## [Blog](./blog/)

{{{ vars.blogPostsHtml }}}

Key properties of global.data.ts:

  • Centralizes page collation and processing. Collect, filter, group, and sort pages once, then share the result with generated pages, normal pages, layouts, and templates instead of repeating the same work in each downstream consumer.
  • 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.
  • 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.
  • Changes to global.data.* trigger a full page rebuild (same as global.vars.*), since the output is stamped onto every page's vars.

Global data types

Use GlobalDataFunction<T> for a synchronous function or AsyncGlobalDataFunction<T> for an async function. In both types, T describes the derived variables object returned by global.data.ts:

// src/global.data.ts
import type { GlobalDataFunction } from '@domstack/static/types.js'

type DerivedData = {
  pageCount: number
  pageUrls: string[]
}

const globalData: GlobalDataFunction<DerivedData> = ({ pages }) => {
  return {
    pageCount: pages.length,
    pageUrls: pages.map(page => page.pageInfo.url),
  }
}

export default globalData

Use AsyncGlobalDataFunction<DerivedData> instead when the implementation needs to await rendering, network requests, or other asynchronous work.

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.

// 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.

// 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, and rendering requires the current pages collection.

// src/global.data.ts
const renderedPages = await Promise.all(
  pages.map(async page => ({
    path: page.pageInfo.path,
    html: await page.renderInnerPage({ pages }),
  }))
)

See 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 for all available extensions.

Generated-pages exports

Like 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 source pages, global or derived data, or other discovery data
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:

// src/about.pages.ts
export default {
  outputName: 'about/index.html',
  vars: { layout: 'root', title: 'About' },
  children: '<p>About this site</p>',
}

Page definition array

Export an array when the module always creates a fixed set of pages:

// 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 source pages or shared variables:

// src/tag-indexes.pages.ts
export default function tagIndexes ({ vars }) {
  return Object.entries(vars.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.

Asynchronous factory

Export an async function when creating definitions requires asynchronous work:

// 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:

// src/archive.pages.ts
export default async function * archivePages ({ vars }) {
  for (const year of vars.blogYears) {
    yield {
      outputName: `blog/${year}/index.html`,
      vars: { layout: 'archive', year },
    }
  }
}

Generated-pages factory parameters

Functions receive one object with:

Important

Generated-page factories receive only source-backed pages. They do not receive pages generated by the same or other *.pages.ts files.

Parameter Contents
pages Initialized source-backed PageData[]. Generated pages from this or other pages files are not included.
vars Default and global vars plus the values returned by global.data.*.
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.
siteData Discovery data returned by identifyPages(). Its siteData.pages array is also source-backed only.

Every *.pages.ts factory receives the same snapshot of source-backed pages and global data. A *.pages.ts file cannot access pages created by another *.pages.ts file, regardless of file processing order. After every factory finishes, DOMStack adds all generated pages to the final pages collection used while rendering page functions, layouts, and templates.

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 <pages-file-name>/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 and layout assets. 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<T, U> to type an individual definition. T is the generated page's variables type, and U is its children type, which defaults to string:

// src/terms.pages.ts
import type { GeneratedPageDefinition } from '@domstack/static/types.js'

type LegalPageVars = {
  layout: string
  title: string
}

const terms: GeneratedPageDefinition<LegalPageVars> = {
  outputName: 'terms/index.html',
  vars: { layout: 'legal', title: 'Terms' },
  children: 'Terms of service',
}

export default terms

Use PagesFunction<T, U, V> 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, global, and derived variables type received by the factory.
// src/archive.pages.ts
import type { PagesFunction } from '@domstack/static/types.js'

type ArchiveVars = { layout: string, year: number }
type CollectionVars = { blogYears: number[] }

const archivePages: PagesFunction<ArchiveVars, string, CollectionVars> = async function * ({ vars }) {
  for (const year of vars.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.

Templates

Template files let you write any kind of file type to the dest folder while customizing the contents of that file with access to the site Variables object, or inject any other kind of data fetched at build time. Template files can be located anywhere in the src directory. For a complete feed-generation recipe, see Generate RSS and JSON feeds.

Template files look like:

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 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.

// name-of-template.txt.template.ts
import type { TemplateFunction } from '@domstack/static/types.js'

interface TemplateVars {
  foo: string;
  testVar: string;
}

const simpleTemplate: TemplateFunction<TemplateVars> = 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.

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.

import type { TemplateFunction } from '@domstack/static/types.js'

interface TemplateVars {
  foo: string;
  testVar: string;
}

const objectArrayTemplate: TemplateFunction<TemplateVars> = 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 that yields objects with content and outputName entries.

import type { TemplateAsyncIterator } from '@domstack/static/types.js'

interface TemplateVars {
  foo: string;
  testVar: string;
}

const templateIterator: TemplateAsyncIterator<TemplateVars> = 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 the current page collection through pages. See Page data and introspection for page metadata and rendering methods.

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. Page functions, layouts, and templates receive the final collection of source-backed and generated PageData instances through pages. Entries in pages expose their resolved variables, source metadata, and methods for rendering page content.

// src/example/page.ts
export default function examplePage ({ page, pages }) {
  console.log(page.url)
  console.log(pages[0]?.pageInfo.url)
  return ''
}

Earlier build stages, including global.data.ts and generated-page factories, receive only source-backed pages. See Generated-pages factory parameters for the snapshot available to *.pages.ts files.

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 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 uses this pattern for feed item URLs.

Rendering page content

Each PageData instance exposes two methods for accessing rendered output. This is useful when another generated file needs to embed a page's content, such as the feeds.template.ts implementation in the RSS and JSON feed recipe.

  • await page.renderInnerPage({ pages }) 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({ pages }) returns the complete page output with its layout applied.

Both methods are async and require the pages array available at that build stage. Rendering errors propagate and fail the build.

Rendering many pages

Use global.data.ts to pre-render content shared by multiple downstream pages or templates. This centralizes the work and makes the result available through the resolved variable cascade:

// 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({ pages })
    ] 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, DOMStack adds those values to the page variable cascade before the normal rendering pass.

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" 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 is used. When running in a web client context, esbuild 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, 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:

// 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:

// 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, and GlobalDataFunction for ordinary synchronous or asynchronous implementations. Their Async* variants are available when a type must specifically require a promise return value. 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:

// 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, and PagesFunction support additional type parameters for precise input and return type control:

PageFunction<T, U>

  • T - The type of variables passed to the page (required)
  • U - The return type of the page function (optional, defaults to any)

LayoutFunction<T, U, V>

  • 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)

PagesFunction<T, U, V>

  • T - The vars added to generated pages (optional, defaults to Record<string, any>)
  • 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<string, any>)

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:

// src/rendering-types.ts
// Define custom types
type VDOMNode = {
  type: string
  props: Record<string, any>
  children: Array<VDOMNode | string>
}

// 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><body>${html}</body></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 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. 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.

npm install htm preact preact-render-to-string
/**
 * @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<Record<string, any>, string | VNode, string>} */
export default function rootLayout ({ children, vars, scripts, styles }) {
  return `<!DOCTYPE html>
${render(html`<html lang=${vars.lang ?? 'en'}>
  <head>
    <title>${vars.title}</title>
    ${styles?.map(style => html`<link rel="stylesheet" href=${style} />`)}
    ${scripts?.map(script => html`<script type="module" src=${script}></script>`)}
  </head>
  <body>
    ${typeof children === 'string'
      ? html`<main dangerouslySetInnerHTML=${{ __html: children }} />`
      : html`<main>${children}</main>`}
  </body>
</html>`)}`
}

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 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.

npm install async-htm-to-string
/**
 * @import { LayoutFunction } from '@domstack/static/types.js'
 */
import { html, rawHtml } from 'async-htm-to-string'

/** @type {LayoutFunction<Record<string, any>, string, Promise<string>>} */
export default async function rootLayout ({ children, vars, scripts, styles }) {
  return await html`<!DOCTYPE html>
<html lang="${vars.lang ?? 'en'}">
  <head>
    <title>${vars.title}</title>
    ${styles?.map(style => html`<link rel="stylesheet" href="${style}" />`)}
    ${scripts?.map(script => html`<script type="module" src="${script}"></script>`)}
  </head>
  <body>
    <main>${rawHtml(children)}</main>
  </body>
</html>`
}

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({ pages }) 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 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.

// 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 for a complete implementation.

Service workers

DOMStack has full native support for service workers. Put one site service worker source file anywhere under src and domstack will build it to a stable root /service-worker.js output:

src/
└── globals/
    └── service-worker.ts

DOMStack produces:

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 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 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:

// 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, 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 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 and reference it from the document head:

<!-- HTML generated by src/layouts/root.layout.ts -->
<link rel="manifest" href="/site.webmanifest">

See these complete examples:

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. (A Web App Manifest such as site.webmanifest can be generated independently with a template.)

A generated manifest resembles:

// public/domstack-manifest.json
{
  "$schema": "https://unpkg.com/@domstack/static@<version>/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 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.

// src/globals/domstack-manifest.settings.ts
import type { DomstackManifestOptions } from '@domstack/static/types.js'

type PageVars = {
  offline?: boolean
  precache?: boolean
}

type ManifestVars = Pick<PageVars, 'offline' | 'precache'>

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:

// 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:

// 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:

domstack --serve
domstack --serve --port 3001

See the complete examples for production-oriented cache lifecycle behavior:

Programmatic configuration

Configure the manifest through the DomStack constructor when coordinating it with another build tool or script:

// 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.

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:

Cookbook

Applied examples that combine multiple DOMStack features.

Compose nested layouts

Since layouts are just functionsℒ️, they nest naturally. If you define the majority of your HTML page metadata in a root.layout.ts, you can define additional layouts that act as child wrappers without having to redefine everything in root.layout.ts.

For example, you could define a blog.layout.ts that re-uses the root.layout.ts:

import defaultRootLayout from './root.layout.ts'
import { html, raw, render } from 'fragtml'
import type { HtmlResult } from 'fragtml/types.js'
import type { LayoutFunction } from '@domstack/static/types.js'

// Import the type from root layout
import type { RootLayoutVars } from './root.layout'

// Extend the RootLayoutVars with blog-specific properties
interface BlogLayoutVars extends RootLayoutVars {
  authorImgUrl?: string;
  authorImgAlt?: string;
  authorName?: string;
  authorUrl?: string;
  publishDate?: string;
  updatedDate?: string;
}

const blogLayout: LayoutFunction<BlogLayoutVars, string | HtmlResult, string> = (layoutVars) => {
  const { children: innerChildren, ...rest } = layoutVars
  const vars = layoutVars.vars

  const children = render(html`
    <article class="article-layout h-entry" itemscope itemtype="http://schema.org/NewsArticle">
      <header class="article-header">
        <h1 class="p-name article-title" itemprop="headline">${vars.title}</h1>
        <div class="metadata">
          <address class="author-info" itemprop="author" itemscope itemtype="http://schema.org/Person">
            ${vars.authorImgUrl
              ? html`<img height="40" width="40" src="${vars.authorImgUrl}" alt="${vars.authorImgAlt}" class="u-photo" itemprop="image" />`
              : null
            }
            ${vars.authorName && vars.authorUrl
              ? html`
                  <a href="${vars.authorUrl}" class="p-author h-card" itemprop="url">
                    <span itemprop="name">${vars.authorName}</span>
                  </a>`
              : null
            }
          </address>
          ${vars.publishDate
            ? html`
              <time class="dt-published" itemprop="datePublished" datetime="${vars.publishDate}">
                <a href="#" class="u-url">
                  ${(new Date(vars.publishDate)).toLocaleString()}
                </a>
              </time>`
            : null
          }
          ${vars.updatedDate
            ? html`<time class="dt-updated" itemprop="dateModified" datetime="${vars.updatedDate}">Updated ${(new Date(vars.updatedDate)).toLocaleString()}</time>`
            : null
          }
        </div>
      </header>

      <section class="e-content" itemprop="articleBody">
        ${typeof innerChildren === 'string'
          ? html`<div>${raw(innerChildren)}</div>`
          : innerChildren
        }
      </section>
    </article>
  `)

  const rootArgs = { ...rest, children }
  return defaultRootLayout(rootArgs)
}

export default blogLayout

Now blog.layout.ts becomes a nested layout of root.layout.ts. No magic, just functions.

Alternatively, you could compose your layouts from re-usable template functions and strings. If you find your layouts nesting more than one or two levels, perhaps composition would be a better strategy.

Layout composition pitfalls

Warning

Nested layouts must explicitly forward scripts and styles. If these values are omitted, the page renders without its CSS or client-side JavaScript, and no error is reported.

// wrong: scripts and styles are dropped
return defaultRootLayout({ children, vars })

// correct: forward them along
return defaultRootLayout({ children, vars, scripts, styles })

Vars can be modified before forwarding. The rest-spread pattern shown above forwards vars unchanged, but you can extend the object before passing it to the base layout. This is useful for setting layout-specific flags that the root layout reads:

const extendedVars = { ...vars, showSidebar: true, pageType: 'article' }
return defaultRootLayout({ children, vars: extendedVars, scripts, styles })

Forward page, pages, and workers when the base layout uses them. If your root layout accesses page.path for canonical URLs, iterates pages for navigation, or uses workers, those params must also be forwarded:

export default function articleLayout ({ children, vars, scripts, styles, page, pages, workers }) {
  return defaultRootLayout({ children, vars, scripts, styles, page, pages, workers })
}

Layout-specific styles and client bundles have a similar explicit-composition requirement: parent layout assets are not included automatically in nested layouts. See Nested layout client bundles and styles for the required @import and import pattern.

Nested layout client bundles and styles

Warning

Nested layouts do not automatically inherit the styles or client bundle of the layout they wrap. Import those assets explicitly or the rendered page will omit them.

Import the wrapped layout's assets from the additional layout's client and style files. For example, if article.layout.ts wraps root.layout.ts, do the following:

/* article.layout.css  */
@import "./root.layout.css";

This will include the layout style from the root layout in the article layout style.

/* article.layout.client.ts  */
import './root.layout.client.ts'

Adding these imports will include the root.layout.ts layout assets into the blog.layout.ts asset files.

Generate RSS and JSON feeds

Templates receive the standard variables available to pages, so they can inspect pages and generate feeds from site content.

The following example generates an RSS and JSON Feed from the 10 most recent date-sorted pages using the blog layout and the AsyncIterator template type. It uses renderInnerPage() to include each post's rendered HTML. See the blog example's feeds.template.ts for a working implementation.

import pMap from 'p-map'
import jsonfeedToAtom from 'jsonfeed-to-atom'
import type { TemplateAsyncIterator } from '@domstack/static/types.js'

interface TemplateVars {
  title: string;
  layout: string;
  siteName: string;
  homePageUrl: string;
  authorName: string;
  authorUrl: string;
  authorImgUrl?: string;
  siteDescription: string;
  language: string;
}

const feedsTemplate: TemplateAsyncIterator<TemplateVars> = async function * ({
  vars: {
    siteName,
    siteDescription,
    homePageUrl,
    language = 'en-us',
    authorName,
    authorUrl,
    authorImgUrl,
  },
  pages
}) {
  const blogPosts = pages
    .filter(page => page.pageInfo.path.startsWith('blog/') && page.vars['layout'] === 'blog')
    .sort((a, b) => new Date(b.vars.publishDate) - new Date(a.vars.publishDate))
    .slice(0, 10)

  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: await pMap(blogPosts, async (page) => {
      return {
        date_published: page.vars['publishDate'],
        title: page.vars['title'],
        url: `${homePageUrl}${page.pageInfo.url}`,
        id: `${homePageUrl}${page.pageInfo.url}#${page.vars['publishDate']}`,
        content_html: await page.renderInnerPage({ pages })
      }
    }, { concurrency: 4 })
  }

  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, global.data.ts, blog-indexes.pages.ts, and 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:

// 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[]
}

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<GlobalData> = async ({ pages }) => {
  const postsByYear = new Map<number, BlogPost[]>()

  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 consume vars.blogIndexes and create one blog/<year>/index.html page per group using the year-index layout:

// src/blog-indexes.pages.ts
import type { PagesFunction } from '@domstack/static/types.js'
import type { BlogPost, GlobalData } from './global.data.js'

type YearIndexPageVars = {
  layout: 'year-index'
  title: string
  posts: BlogPost[]
}

const blogIndexes: PagesFunction<YearIndexPageVars, string, GlobalData> = ({ vars }) =>
  vars.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, redirects.pages.ts, and 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:

---
title: Current Post
redirectFrom:
  - /2020/old-slug/
  - /blog/original-title/
---
<!-- src/blog/current-post.md -->

# Current Post

Collect the metadata in global.data.ts. The current page's URL becomes the redirect target automatically:

// 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:

// 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 default function redirectsPages ({ vars }) {
  const pages = []

  for (const { from, to } of vars.redirects) {
    pages.push({
      outputName: redirectOutputName(from),
      vars: {
        layout: 'redirect',
        title: 'Redirecting...',
        redirectTo: to,
      },
    })
  }

  return pages
}
// src/redirect.layout.ts

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}</title>
</head>
<body>
  <p>Redirecting to <a href="${vars.redirectTo}">${vars.redirectTo}</a></p>
</body>
</html>`)
}

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:

// src/redirects-netlify.txt.template.ts
// Generates a _redirects file for Netlify / Cloudflare Pages.

export default function ({ vars }) {
  return {
    outputName: '_redirects',
    content: vars.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.
  • md is processed with markdown-it.
  • static files are processed with 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    β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 β”‚
                                 β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚  Stamp 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).
  • Global data - Derived variables from global.data.ts, resolved from source-backed pages before generated-page factories run and available to every page at final render time.
  • Layout vars - Optional export const vars from the selected layout module.
  • 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.

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 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. Receiving the pages collection does not create an implicit watch dependency on every source-backed page.

What triggers what

Change Rebuild scope
Existing page.ts, page.html, page.md, or adjacent page.vars.ts That page
A module imported by a TypeScript page or page.vars.ts Pages that depend on it
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, generated pages, and templates
global.data.ts All pages and templates
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 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:

[dir]/[name]

Production builds use content-hashed entry filenames:

[dir]/[name]-[hash]

Shared chunks remain content-hashed in both modes:

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. 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 <a> or <link> 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) 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 homage to Node's former legend substack.

How does domstack relate to 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 🍞 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 when updating an older project. The bun project took off and hosed the projects chances at SEO!

How does domstack relate to 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

See the GitHub roadmap for planned work, or the changelog for completed changes. Issues, ideas, and examples of sites built with DOMStack are welcome.

Links

License

MIT

About

Websites made with HTML, CSS & JS

Topics

Resources

Contributing

Stars

24 stars

Watchers

2 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages