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@betaNote
DOMStack v12 is currently published under npm's beta dist-tag. Omit @beta to install the latest stable release.
- π domstack docs website
- π¬ Discord Chat
- π’ v12 Migration Guide
- π fragtml docs
- π’ v11 - top-bun is now domstack
- π’ v7 Announcement
[[toc]]
$ 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
domstackwill result in abuildby default. -
Running
domstack --watchordomstack -wwill build the site and start an auto-reloading development web-server that watches for changes (provided by@domstack/sync). -
Running
domstack --ejectordomstack -ewill 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.
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.cssfor page-specific stylesclient.tsorclient.tsxfor page-specific browser codepage.vars.tsfor page variables*.worker.tsfor 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.
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 throughesbuild.settings.markdown-settingsβ Customizing Markdown rendering withmarkdown-it.settingsand Markdown-it plugins.nested-destβ Using the project root assrcwhile writing the built site to a nestedpublicdirectory.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 withuhtml-isomorphicon 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 buildHere are some additional external examples of larger domstack projects. If you have a project that uses domstack and could act as a nice example, please PR it to the list!
- Blog Example - A personal blog written with DOMStack
- Isomorphic Static/Client App - Pages build from client templates and hydrate on load.
- Zero-Conf Markdown Docs - A npm package with markdown docs, transformed into a website without any any configuration
(Did you make a cool DOMStack website that is open source? PR it to the list!)
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:
- Create a default root layout file at
layouts/root.layout.js(or.mjsdepending on your package.json type) - Create a default global CSS file at
globals/global.css - Create a default client-side JavaScript file at
globals/global.client.js - 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 are named directories inside src with one of the following page files:
mdpages are CommonMark markdown pages, with an optional YAML front-matter block.htmlpages are an inner HTML fragment that get inserted into the page layout.tspages 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.
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.mdmdpages have three types: apage.md, aREADME.md, or a loosewhatever-name-you-want.mdfile.page.mdandREADME.mdfiles transform to anindex.htmlat the same path. When both exist in the same directory,page.mdtakes precedence overREADME.md.whatever-name-you-want.mdloose markdown files transform intowhatever-name-you-want.htmlfiles at the same path in thedestdirectory.mdpages 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.
mdpages support handlebars template placeholders.- You can disable
mdpage handlebars processing by setting thehandlebarsvariable tofalse. mdpages 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 }}.A html page looks like this:
src/page-name/page.htmlhtmlpages are namedpage.htmlinside an associated page folder.htmlpages are the simplest page type indomstack. 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 rawhtml, and the rules with buildinghtmlin a realhtmlfile are much more flexible than inside of amdfile.htmlpage variables can only be set in apage.vars.tsfile inside the page directory.htmlpages support handlebars template placeholders.- You can disable
htmlpage handlebars processing by setting thehandlebarsvariable tofalse.
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>A ts page looks like this:
src/page-name/page.tsNote
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.
tspages consist of a named directory with apage.tsfile that exports a default function returning the contents of the inner page.- A
tspage needs toexport defaulta 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>whereTis the variables type andUis the return type (defaults toany). - A
tspage can export avarsvariable provider that takes highest variable precedence when rendering the page.export varsis similar to amdpage's front matter. - A
tspage receives the standarddomstackVariables set. - There is no built-in Handlebars support in
tspages; however, you are free to use any template library that you can import. tspages 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 pageIt 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 blogIndexYou 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;
}
}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()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
}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.
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 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 articleA 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 defaultsThis makes layout vars useful for section-wide defaults while still letting individual pages override them.
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 typemodule.styles: array of paths that should be included onto the page in alink rel="stylesheet"tag with thehrefpointing to the paths in the array.children: A string containing the page's inner content, or whatever type yourtspage function returns.mdandhtmlpage 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 thepagesarray.
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 defaultRootLayoutIf 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.
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.
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.
Layouts can be typed using LayoutFunction<T, U, V> where:
Tis the variables typeUis the type of content received from pages (defaults toany)Vis the layout's return type (defaults tostringfor 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 articleLayoutDOMStack 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:
vars: An object with the variables ofglobal.vars.ts,global.data.ts,page.vars.ts, and any frontmatter orvarsexports from the page merged together.pages: The availablePageDatacollection.page: The current page'sPageInfometadata.
Template files receive a similar set of variables:
vars: An object with the variables fromglobal.vars.tsandglobal.data.ts.pages: The availablePageDatacollection.template: Information about the current template file.
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.
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
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.
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'
}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.
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!')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.
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.
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
targetto makeesbuildrun 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
definehere conflicts with thebrowserexport inglobal.vars.tsand 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 esbuildSettingsOverrideDOMStack 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 esbuildSettingsOverrideIf 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 esbuildSettingsOverrideThis 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 markdownItSettingsOverrideimport 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 markdownItSettingsOverrideBy default, DOMStack ships with the following markdown-it plugins enabled:
- markdown-it
- markdown-it-footnote
- markdown-it-highlightjs
- markdown-it-emoji
- markdown-it-sub
- markdown-it-sup
- markdown-it-deflist
- markdown-it-ins
- markdown-it-mark
- markdown-it-abbr
- markdown-it-task-lists
- markdown-it-github-alerts
- markdown-it-anchor
- markdown-it-attrs
- markdown-it-table-of-contents
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 buildGlobalDataThe 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 asglobal.vars.*), since the output is stamped onto every page's vars.
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 globalDataUse AsyncGlobalDataFunction<DerivedData> instead when the implementation needs to await rendering, network requests, or other asynchronous work.
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 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.
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.
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>',
}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',
},
]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.
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 },
}))
}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 },
}
}
}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.
| 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.
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 termsUse PagesFunction<T, U, V> for normal functions, async functions, and async generators:
Tis the variables type added to each generated page.Uis the generated children type (defaults tostring).Vis 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 archivePagesFor metadata-driven redirects, see the cookbook recipe Generate redirect pages from page metadata.
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.tsTemplate 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.
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 simpleTemplateA 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'
})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 objectArrayTemplateAn 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 templateIteratorTemplates receive the current page collection through pages. See Page data and introspection for page metadata and rendering methods.
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 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.
The current page is a PageInfo object with the following properties:
type: The page type (md,html, orjs).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.htmlfor 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.tsfile that created a generated page, orundefinedfor 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.
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.
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 globalDataRendering 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.
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" domstackenv variable. - Seamlessly mix
.ts,.mts,.ctsfiles alongside.js,.mjs,.cjs. - No explicit compilation step neededβNode.js handles type stripping at runtime.
- Fully compatible with existing
domstackfile naming conventions. - Anywhere DOMStack loads JS files, it can now load TS files.
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.
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:
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
})
}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 toany)
LayoutFunction<T, U, V>
T- The type of variables passed to the layout (required)U- The type of content received from pages aschildren(optional, defaults toany)V- The return type of the layout function (optional, defaults tostring)
PagesFunction<T, U, V>
T- The vars added to generated pages (optional, defaults toRecord<string, any>)U- The static children or inline page-function return type (optional, defaults tostring)V- The default and global vars received by the pages factory (optional, defaults toRecord<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>`
}These features customize DOMStackβs rendering pipeline or coordinate generated assets with browser runtimes.
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
classandforrather than React aliases likeclassNameandhtmlFor, whichasync-htm-to-stringwill output literally with no warning. For attributes liketabindex,tabIndexis only a casing preference in HTML, but using standard lowercase keeps templates consistent. - Always
awaitthehtmltag. The tag returns an object that resolves to a string asynchronously. If you return it withoutawaitfrom a non-async function, or assign it where a string is expected, you will get[object Object]in the output with no error thrown. Useasync functionandawaitthe 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().
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.
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.tsDOMStack produces:
public/
βββ service-worker.jsNote
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.
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:
static-mpa-offlineuses DOMStack's manifest hooks with a custom service worker and registration lifecycle.static-mpa-workbox-offlineimplements the same offline MPA pattern with Workbox.
Caution
DOMStack does not clean dest before building. Clean the destination before deployment, especially after removing or renaming a service worker, so an old /service-worker.js cannot remain publicly available.
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:
- DOMStack collects and reconciles emitted outputs.
- Excludes and entry filters run, then selected page variables are attached.
- DOMStack finalizes the manifest entries, root policy, and deterministic version.
manifestBuilthooks receive the finalized manifest.- DOMStack bundles the site service worker with any constants defined by the hooks.
- DOMStack optionally writes
domstack-manifest.jsonand 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.
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.
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 settingsThe 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 changegeneratedAt: The build timestamp, which does not affectversionentries: Included public outputs sorted by URLpolicy: 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.
hooks.manifestBuilt runs after entries, policy, and version are finalized but before /service-worker.js is bundled. Each hook receives:
manifest: The finalized manifestdest: The absolute destination directorydefineServiceWorkerConstant(name, value): Injects a JSON-serializable value into only the final service-worker bundlewriteFile(outputRelname, contents): Writes an additional file underdest
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.
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 settingsThe 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 3001See the complete examples for production-oriented cache lifecycle behavior:
static-mpa-offlineinjects DOMStack manifest entries into a custom service worker.static-mpa-workbox-offlineconverts the finalized entries into Workbox precaching and routing policy.
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)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:
test-build-helper/index.test.jstests temporary output,readOutput(), copied directories, and cleanup.default-layout/index.test.jsusestestBuild()for a focused output assertion.generated-pages/index.test.jsuses it with generated pages, global data, and templates.
Applied examples that combine multiple DOMStack features.
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 blogLayoutNow 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.
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.
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.
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 feedsTemplateGlobal 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 globalDataThen 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 blogIndexesSee 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 PostCollect 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.
domstack bundles the best tools for every technology in the stack:
jsandcssis bundled withesbuild.mdis processed with markdown-it.- static files are processed with cpx2.
tssupport via native typestripping in Node.js and esbuild.jsx/tsxsupport 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.
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:
- Page identification - Scans the source directory to identify all pages, layouts, templates, and global assets
- Destination preparation - Ensures the destination directory is ready for the build output
- 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
--copyoptions)
- Page building - Processes pages and normal templates, applying layouts and recording outputs
- Manifest reconciliation - Normalizes recorded outputs, hashes file contents, filters entries, and computes a stable manifest version
- 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.
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 varsfrom the selected layout module. - Page-specific vars vary by type:
- MD pages:
page.vars.jsplus builder vars from frontmatter. - HTML pages:
page.vars.js. - JS pages: exported
varspluspage.vars.js.
- MD pages:
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
srcand 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.tsfiles. - 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.
| 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.
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.
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.
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.
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.
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 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.
- 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.
- The source directory structure should mirror the site's URL structure.
- Every page should have an obvious entrypoint and build to an
index.htmlin 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.mdandREADME.mdentrypoints.README.mdkeeps a source tree navigable on Git hosts, whilepage.mdis available when repository navigation is not a concern.
- 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 file types and syntax rather than framework-specific extensions or editor plugins.
- Use real TC39 ESM and prefer standard
.tsand.jsmodules 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.
- 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.
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.
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.