domstack
: Cut the gordian knot of modern web development and build websites with a stack of html, md, css, ts, tsx, (and/or js/jsx).
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.
It's fast to learn, quick to build with, and performs better than you are used to.
npm install @domstack/static
- π domstack docs website
- π¬ Discord Chat
- π’ v11 - top-bun is now domstack
- π’ v7 Announcement
- π Full TypeScript Support
[[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.
--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
--copy path to directories to copy into dist; can be used multiple times
--help, -h show help
--version, -v show version information
domstack (v11.0.0)
domstack
builds a src
directory into a dest
directory (default: public
).
domstack
is also aliased to a dom
bin.
- Running
domstack
will result in abuild
by default. - Running
domstack --watch
ordomstack -w
will build the site and start an auto-reloading development web-server that watches for changes (provided by Browsersync). - Running
domstack --eject
ordomstack -e
will extract the default layout, global styles, and client-side JavaScript into your source directory and add the necessary dependencies to your package.json.
domstack
is primarily a unix bin
written for the Node.js 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
is a static site generator that builds a website from "pages" in a src
directory, nearly 1:1 into a dest
directory.
By building "pages" from their src
location to the dest
destination, the directory structure inside of src
becomes a "filesystem router" naturally, without any additional moving systems or structures.
A src
directory tree might look something like this:
src % tree
.
βββ md-page
β βββ README.md # directories with README.md in them turn into /md-page/index.html.
β βββ client.ts # Every page can define its own client.ts script that loads only with it.
β βββ style.css # Every page can define its own style.css style that loads only with it.
β βββ loose-md-page.md # loose markdown get built in place, but lacks some page features.
β βββ nested-page # pages are built in place and can nest.
β βββ README.md # This page is accessed at /md-page/nested-page/.
β βββ style.css # nested pages are just pages, so they also can have a page scoped client and style.
β βββ client.js # Anywhere JS loads, you can use .js or .ts
βββ html-page
β βββ client.tsx # client bundles can also be written in .jsx/.tsx
β βββ page.html # Raw html pages are also supported. They support handlebars template blocks.
β βββ page.vars.ts # pages can define page variables in a page.vars.ts
β βββ style.css
βββ js-page
β βββ page.js # A page can also just be a plain javascript function that returns content. They can also be type checked.
βββ ts-page
β βββ client.ts # domstack provides type-stripping via Node.JS and esbuild
β βββ page.vars.ts # use tsc to run typechecking
β βββ page.ts
βββ feeds
β βββ feeds.template.ts # Templates let you generate any file you want from variables and page data.
βββ page-with-workers
β βββ client.ts
β βββ page.ts
β βββ counter.worker.ts # Web workers use a .worker.{ts,js} naming convention and are auto-bundled
β βββ analytics.worker.js
βββ layouts # layouts can live anywhere. The inner content of your page is slotted into your layout.
β βββ blog.layout.ts # pages specify which layout they want by setting a `layout` page variable.
β βββ blog.layout.css # layouts can define an additional layout style.
β βββ blog.layout.client.ts # layouts can also define a layout client.
β βββ article.layout.ts # layouts can extend other layouts, since they are just functions.
β βββ javascript.layout.js # layouts can also be written in javascript
β βββ root.layout.ts # the default layout is called "root"
βββ globals # global assets can live anywhere. Here they are in a folder called globals.
β βββ global.client.ts # you can define a global client that loads on every page.
β βββ global.css # you can define a global css file that loads on every page.
β βββ global.vars.ts # site wide variables get defined in global.vars.ts
β βββ markdown-it.settings.ts # You can customize the markdown-it instance used to render markdown
β βββ esbuild.settings.ts # You can even customize the build settings passed to esbuild
βββ README.md # This is just a top level page built from a README.md file.
βββ client.ts # the top level page can define a page scoped js client.
βββ style.css # the top level page can define a page scoped css style.
βββ favicon-16x16.png # static assets can live anywhere. Anything other than JS, CSS and HTML get copied over automatically.
The core idea of domstack
is that a src
directory of markdown, html and ts/js "inner" documents will be transformed into layout wrapped html documents in the dest
directory, along with page scoped js and css bundles, as well as a global stylesheet and global js bundle.
It ships with sane defaults so that you can point domstack
at a standard markdown documented repository and have it build a website with near-zero preparation.
A collection of examples can be found in the ./examples
folder.
To run examples:
$ git clone [email protected]:bcomnes/domstack.git
$ cd domstack
# install the top level deps
$ npm i
$ cd example:{example-name}
# install the example deps
$ npm i
# start the example
$ npm start
Here are some additional external examples of larger domstack projects. If you have a project that uses domstack and could act as a nice example, please PR it to the list!
- Blog Example - 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
Pages are a named directories inside of src
, with one of the following page files inside of it.
md
pages are CommonMark markdown pages, with an optional YAML front-matter block.html
pages are an inner html fragment that get inserted into the page layout.ts
/js
pages are a ts/js file that exports a default function that resolves into an inner-html fragment that is inserted into the page layout.
Variables are available in all pages. md
and html
pages support variable access via handlebars template blocks. ts
/js
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
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/README.md
# or
src/page-name/loose-md.md
md
pages have two types: aREADME.md
in a folder, or a loosewhatever-name-you-want.md
file.README.md
files transform to anindex.html
at the same path, andwhatever-name-you-want.md
loose markdown files transform intowhatever-name-you-want.html
files at the same path in thedest
directory.md
pages can have YAML frontmatter, with variables that are accessible to the page layout and handlebars template blocks when building.- You can include html in markdown files, so long as you adhere to the allowable markdown syntax around html tags.
md
pages support handlebars template placeholders.- You can disable
md
page handlebars processing by setting thehandlebars
variable tofalse
. md
pages support many github flavored markdown features.
An example of a md
page:
---
title: A title for a markdown page
favoriteColor: 'Blue'
---
Just writing about web development.
## Favorite colors
My favorite color is {{ vars.favoriteColor }}.
A html
page looks like this:
src/page-name/page.html
html
pages are namedpage.html
inside an associated page folder.html
pages 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 buildinghtml
in a realhtml
file are much more flexible than inside of amd
file.html
page variables can only be set in apage.vars.js
file inside the page directory.html
pages support handlebars template placeholders.- You can disable
html
page handlebars processing by setting thehandlebars
variable tofalse
.
An example html
page:
<h2>Favorite frameworks</h2>
<ul>
<li>React</li>
<li>Vue</li>
<li>Svelte</li>
<!-- favoriteFramework defined in page.vars.js -->
<li>{{ vars.favoriteFramework }}</li>
</ul>
A ts
/js
page looks like this:
src/page-name/page.ts
# or
src/page-name/page.js
js
/ts
pages consist of a named directory with apage.js
orpage.ts
inside of it, that exports a default function that returns the contents of the inner page.- a
js
/ts
page needs toexport default
a function (async or sync) that accepts a variables argument and returns a string of the inner html of the page, or any other type that your layout can accept. - A
js
/ts
page can export avars
object or function (async or sync) that takes highest variable precedence when rendering the page.export vars
is similar to amd
page's front matter. - A
js
/ts
page receives the standarddomstack
Variables set. - There is no built in handlebars support in
js
/ts
pages, however you are free to use any template library that you can import. js
/ts
pages are run in a Node.js context only.
An example TypeScript page:
import type { PageFunction } from '@domstack/static'
export const vars = {
favoriteCookie: 'Chocolate Chip with Sea Salt'
}
export default 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>`
}
It is recommended to use some level of template processing over raw string templates so that HTML is well-formed and variable values are properly escaped. Here is a more realistic TypeScript example that uses preact
with htm
and domstack
page introspection.
import { html } from 'htm/preact'
import { dirname, basename } from 'node:path'
import type { PageFunction } from '@domstack/static'
type BlogVars = {
favoriteCake: string
}
export const vars = {
favoriteCake: 'Chocolate Cloud Cake'
}
export default const blogIndex: PageFunction<BlogVars> = 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.path}/`}">${basename(yearPage.pageInfo.path)}</a></li>`)}
</ul>
</div>`
}
You can create a style.css
file in any page folder.
Page styles are loaded on just that one page.
You can import common use styles into a style.css
page style using css @import
statements to re-use common css.
You can @import
paths to other css files, or out of npm
modules you have installed in your projects node_modues
folder.
css
page bundles are bundled using esbuild
.
An example of a page style.css
file:
/* /some-page/style.css */
@import "some-npm-module/style.css";
@import "../common-styles/button.css";
.some-page-class {
color: blue;
& .button {
color: purple;
}
}
You can create a client.ts
or client.js
file in any page folder.
Page bundles are client side JS 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
.
The client.js
page bundles are bundle-split with every other client-side js/ts entry-point, so importing common chunks of code are loaded in a maximally efficient way.
Page bundles are run in a browser context only, however they can share carefully crafted code that also runs in a Node.js or layout context.
ts
/js
page bundles are bundled using esbuild
.
An example of a page client.js
file:
/* /some-page/client.ts */
import { funnyLibrary } from 'funny-library'
import { someHelper } from '../helpers/foo.js'
await someHelper()
await funnyLibrary()
Client bundles support .jsx and .tsx. They default to preact, so if you want mainlain react, customize your esbuild settings to load that instead. See the react example for more details.
Each page can also have a page.vars.ts
or page.vars.js
file that exports a default
sync/async function or object that contains 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 precedent than global.vars.ts
variables, but lower precedent than frontmatter or vars
ts/js page exports.
If you add a .draft.{md,html,ts,js}
to any of the page types, 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.
Any static assets near draft pages will still be copied because static assets are processed in parallel from page generation (to keep things fast). If you have an idea on how to relate static assets to a draft page for omission, please open a discussion issue.
Draft pages let you work on pages before they are ready and easily omit them from a build when deploying pages that are ready.
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.
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.js
where ${layout-name}
becomes the name of the layout.
Layouts should have a unique name, and layouts with duplicate name will result in a build error.
Example layout file names:
src/layouts/root.layout.js # this layout is references as 'root'
src/other-layouts/article.layout.js # this layout is references as 'article'
At a minimum, your site requires a root
layout (a file named root.layout.js
), though domstack
ships a default root
layout so defining one in your src
directory is optional, though recommended.
All pages have a layout
variable that defaults to root
. If you set the layout
variable to a different name, pages will build with a layout matching the name you set to that variable.
The following markdown page would be rendered using the article
layout.
---
layout: 'article'
title: 'My Article Title'
---
Thanks for reading my article
A page referencing a layout name that doesn't have a matching layout file will result in a build error.
A layout is a ts
/js
file that export default
's an async or sync function that implements an outer-wrapper html template that will house the inner content from the page (children
) being rendered. Think of the frame around a picture. That's a layout. πΌοΈ
It is always passed a single object argument with the following entries:
vars
: An object of global, page folder, and page variables merged together. Pages can customize layouts by providing or overriding global defaults.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 thehref
pointing to the paths in the array.children
: A string of the inner content of the page, or whatever type your js page functions returns.md
andhtml
page types always return strings.pages
: An array of page data that you can use to generate index pages with, or any other page-introspection based content that you desire.page
: An object with metadata and other facts about the current page being rendered into the template. This will also be found somewhere in thepages
array.
The default root.layout.ts
is featured below, and is implemented with preact
and htm
, though it could just be done with a template literal or any other template system that runs in Node.js.
root.layout.ts
can live anywhere in the src
directory.
import { html } from 'htm/preact'
import { render } from 'preact-render-to-string'
import type { LayoutFunction } from '@domstack/static'
type RootLayoutVars = {
title: string,
siteName: string,
defaultStyle: boolean,
basePath?: string
}
export default const defaultRootLayout: LayoutFunction<RootLayoutVars> = ({
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 /* html */`
<!DOCTYPE html>
<html>
${render(html`
<head>
<meta charset="utf-8" />
<title>${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName}</title>
<meta name="viewport" content="width=device-width, user-scalable=no" />
${scripts
? scripts.map(script => html`<script type='module' src="${script.startsWith('/') ? `${basePath ?? ''}${script}` : script}" />`)
: null}
${styles
? styles.map(style => html`<link rel="stylesheet" href="${style.startsWith('/') ? `${basePath ?? ''}${style}` : style}" />`)
: null}
</head>
`)}
${render(html`
<body className="safe-area-inset">
${typeof children === 'string'
? html`<main className="mine-layout app-main" dangerouslySetInnerHTML=${{ __html: children }}></main>`
: html`<main className="mine-layout app-main">${children}</main>`
}
</body>
`)}
</html>
`
}
If your src
folder doesn't have a root.layout.js
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.
Since layouts are just functionsβ’οΈ, they nest naturally. If you define the majority of your html page meta detritus in a root.layout.js
, you can define additional layouts that act as child wrappers, without having to re-define 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.js'
import { html } from 'htm/preact'
import { render } from 'preact-render-to-string'
import type { LayoutFunction } from '@domstack/static'
// 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> = (layoutVars) => {
const { children: innerChildren, ...rest } = layoutVars
const vars = layoutVars.vars
const children = render(html`
<article className="article-layout h-entry" itemscope itemtype="http://schema.org/NewsArticle">
<header className="article-header">
<h1 className="p-name article-title" itemprop="headline">${vars.title}</h1>
<div className="metadata">
<address className="author-info" itemprop="author" itemscope itemtype="http://schema.org/Person">
${vars.authorImgUrl
? html`<img height="40" width="40" src="${vars.authorImgUrl}" alt="${vars.authorImgAlt}" className="u-photo" itemprop="image" />`
: null
}
${vars.authorName && vars.authorUrl
? html`
<a href="${vars.authorUrl}" className="p-author h-card" itemprop="url">
<span itemprop="name">${vars.authorName}</span>
</a>`
: null
}
</address>
${vars.publishDate
? html`
<time className="dt-published" itemprop="datePublished" datetime="${vars.publishDate}">
<a href="#" className="u-url">
${(new Date(vars.publishDate)).toLocaleString()}
</a>
</time>`
: null
}
${vars.updatedDate
? html`<time className="dt-updated" itemprop="dateModified" datetime="${vars.updatedDate}">Updated ${(new Date(vars.updatedDate)).toLocaleString()}</time>`
: null
}
</div>
</header>
<section className="e-content" itemprop="articleBody">
${typeof innerChildren === 'string'
? html`<div dangerouslySetInnerHTML=${{ __html: innerChildren }}></div>`
: innerChildren
}
</section>
</article>
`)
const rootArgs = { ...rest, children }
return defaultRootLayout(rootArgs)
}
export default blogLayout
Now the blog.layout.js
becomes a nested layout of root.layout.js
. 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.
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.
You can create a ${layout-name}.layout.client.ts
or ${layout-name}.layout.client.js
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.
/* /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 ts/js bundles are loaded on all pages that use that layout.
Layout ts/js bundles are bundled with esbuild
and can bundle relative and npm
modules using ESM import
statements.
If you create a nested layout that imports another layout file, and that imported layout has a layout style and/or layout js bundle, there is no magic that will include those layout styles and clients into the importing layout. To include those layout styles and clients into an additional layout, just import them into the additional layout client and style files. For example, if article.layout.ts
wraps root.layout.ts
, you must 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.
All static assets in the src
directory are copied 1:1 to the public
directory. Any file in the src
directory that doesn't end in .ts
, .js
, .css
, .html
, or .md
is copied to the dest
directory.
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.mjs
depending 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
- preact
- htm
- preact-render-to-string
- 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.
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. Conflicting files are not detected or reported and will cause undefined behavior.
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
.
This is useful when you have legacy or archived site content that you want to include in your site, but don't want domstack
to process or modify it.
In general, static content should live in your primary src
directory, however for merging in old static assets over your domstack build is sometimes easier to reason about when it's kept in a separate folder and isn't processed in any way.
For example:
src/...
oldsite/
βββ client.js
βββ hello.html
βββ styles/
βββ globals.css
After build:
src/...
oldsite/...
public/
βββ client.js
βββ hello.html
βββ styles/
βββ globals.css
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 and look like:
name-of-template.txt.template.ts
${name-portion}.template.ts
Template files are a ts
/js
file that default exports one of the following sync/async functions:
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'
interface TemplateVars {
foo: string;
testVar: string;
}
export default const simpleTemplate: TemplateFunction<TemplateVars> = async ({
vars: {
foo,
testVar
}
}) => {
return `Hello world
This is just a file with access to global vars: ${foo}`
}
A function that returns a single object with a content
and outputName
entries. The outputName
overrides the name portion of the template file name.
import type { TemplateFunction } from '@domstack/static'
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'
interface TemplateVars {
foo: string;
testVar: string;
}
export default 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'
}
]
}
An AsyncIterator that yields
objects with content
and outputName
entries.
import type { TemplateAsyncIterator } from '@domstack/static'
interface TemplateVars {
foo: string;
testVar: string;
}
export default 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'
}
}
Templates receive the standard variables available to pages, so its possible to perform page introspection and generate RSS feeds of website content.
The following example shows how to generate an RSS and JSON feed of the last 10 date sorted pages with the blog
layout using the AsyncIterator template type.
import pMap from 'p-map'
import jsonfeedToAtom from 'jsonfeed-to-atom'
import type { TemplateAsyncIterator } from '@domstack/static'
interface TemplateVars {
title: string;
layout: string;
siteName: string;
homePageUrl: string;
authorName: string;
authorUrl: string;
authorImgUrl?: string;
siteDescription: string;
language: string;
}
export default 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.path}/`,
id: `${homePageUrl}/${page.pageInfo.path}/#${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'
}
}
There are a few important (and optional) global assets that live anywhere in the src
directory. If duplicate named files that match the global asset file name pattern are found, a build error will occur until the duplicate file error is resolved.
The global.vars.ts
or global.vars.js
file should export default
a variables object or a (sync or async) function that returns a variable object.
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
object. These object variables are made available in all js bundles. The browser
export can be an object, or a sync/async function that returns an object.
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.
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.
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.
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. You can break domstack with this, so be careful. Here is an example of using this file to polyfill node builtins in the browser bundle:
import { polyfillNode } from 'esbuild-plugin-polyfill-node'
// BuildOptions re-exported from esbuild
import type { BuildOptions } from '@domstack/static'
export default const esbuildSettingsOverride = async (esbuildSettings: BuildOptions): Promise<BuildOptions> => {
esbuildSettings.plugins = [polyfillNode()]
return esbuildSettings
}
Important esbuild settings you may want to set here are:
- target - Set the
target
to makeesbuild
run a few small transforms on your CSS and JS code. - jsx - Unset this if you want default react transform.
- jsxImportSource - Unset this if you want default react transform.
This is an optional file you can create anywhere. It should export a default sync or async function that accepts a single argument (the markdown-it instance configured by domstack) and returns a modified markdown-it instance. Use this to add custom markdown-it plugins or modify the parser configuration. Here are some examples:
import markdownItContainer from 'markdown-it-container'
import markdownItPlantuml from 'markdown-it-plantuml'
import type { MarkdownIt } from 'markdown-it'
export default 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
}
import markdownIt, { MarkdownIt } from 'markdown-it'
import myCustomPlugin from './my-custom-plugin'
export default 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
}
markdownItSettingsOverride
By 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-anchor
- markdown-it-attrs
- markdown-it-table-of-contents
Pages, Layouts, and postVars
all receive an object with the following parameters:
vars
: An object with the variables ofglobal.vars.ts
,page.vars.ts
, and any front-matter,vars
exports andpostVars
from the page merged together.pages
: An array of [PageData
](https://github.com/bcomnes/d omstack/blob/master/lib/build-pages/page-data.js) instances for every page in the site build. Use this array to introspect pages to generate feeds and index pages.page
: An object of the page being rendered with the following parameters:type
: The type of page (md
,html
, orjs
)path
: The directory path for the page.outputName
: The output name of the final file.outputRelname
: The relative output name/path of the output file.pageFile
: Rawsrc
path details of the page filepageStyle
: file info if the page has a page styleclientBundle
: file info if the page has a page js bundlepageVars
: file info if the page has a page vars
Template files receive a similar set of variables:
vars
: An object with the variables ofglobal.vars.ts
pages
: An array ofPageData
instances for every page in the site build. Use this array to introspect pages to generate feeds and index pages.template
: An object of the template file data being rendered.
In page.vars.ts
files, you can export a postVars
sync/async function that returns an object. This function receives the same variable set as pages and layouts. Whatever object is returned from the function is merged into the final vars
object and is available in the page and layout. This is useful if you want to apply advanced rendering page introspection and insert it into a markdown document (for example, the last few blog posts on a markdown page.)
For example:
import { html } from 'htm/preact'
import { render } from 'preact-render-to-string'
import type { PostVarsFunction } from '@domstack/static'
export const postVars: PostVarsFunction = async ({
pages
}) => {
const blogPosts = pages
.filter(page => page.vars.layout === 'article')
.sort((a, b) => new Date(b.vars.publishDate) - new Date(a.vars.publishDate))
.slice(0, 5)
const blogpostsHtml = render(html`<ul className="blog-index-list">
${blogPosts.map(p => {
const publishDate = p.vars.publishDate ? new Date(p.vars.publishDate) : null
return html`
<li className="blog-entry h-entry">
<a className="blog-entry-link u-url u-uid p-name" href="/${p.pageInfo.path}/">${p.vars.title}</a>
${
publishDate
? html`<time className="blog-entry-date dt-published" datetime="${publishDate.toISOString()}">
${publishDate.toISOString().split('T')[0]}
</time>`
: null
}
</li>`
})}
</ul>`)
return {
blogPostsHtml: blogpostsHtml
}
}
This postVars
renders some html from page introspection of the last 5 blog post titles. In the associated page markdown, this variable is available via a handlebars placeholder.
<!-- README.md -->
## [Blog](./blog/)
{{{ vars.blogPostsHtml }}}
domstack
supports TypeScript via native type-stripping in Node.js.
- Requires Node.js β₯23 (built-in) or Node.js 22 with the
NODE_OPTIONS="--experimental-strip-types" domstack
env variable. - Seamlessly mix
.ts
,.mts
,.cts
files alongside.js
,.mjs
,.cjs
. - No explicit compilation step neededβNode.js handles type stripping at runtime.
- Fully compatible with existing
domstack
file naming conventions. - Anywhere DOMStack loads JS files, it can now load TS files.
Anywhere you can use a .js
, .mjs
or .cjs
file in domstack, you can now use .ts
, .mts
, .cts
.
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 provides type checking in .js
and .ts
files and preconfigured for --no-emit
and extend with type stripping friendly rules:
{
"extends": "@voxpelli/tsconfig/node20.json",
"compilerOptions": {
"skipLibCheck": true,
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true
},
"include": [
"**/*",
],
"exclude": [
"**/*.js",
"node_modules",
"coverage",
".github"
]
}
You can use domstack
's built-in types to strongly type your layout, page, and template functions. The following types are available:
import type {
LayoutFunction,
PostVarsFunction,
PageFunction,
TemplateFunction,
TemplateAsyncIterator
} from '@domstack/static'
They are all generic and accept a variable template that you can develop and share between files.
- Convention over configuration. All configuration should be optional, and at most it should be minimal.
- Align with the
index.html
/README.md
pattern. - The HTML is the source of truth.
- Don't re-implement what the browser already provides!
- No magic
<link>
or<a>
tag magic. - Don't facilitate client side routing. The browser supports routing by default.s
- Accept the nature of the medium. Browsers browse html documents. Don't facilitate shared state between pages.
- No magic
- Library agnostic. Strings are the interchange format.
- Pages are shallow apps. New page, new blank canvas.
- Just a program.
js
pages and layouts are just JavaScript programs. This provides an escape hatch to do anything. Use any template language want, but probably just use tagged template literals. - Steps remain orthogonal. Static file copying, css and js bundling, are mere optimizations on top of the
src
folder. Thesrc
folder should essentially run in the browser. Each step in adomstack
build should work independent of the others. This allows for maximal parallelism when building. - Standardized entrypoints. Every page in a
domstack
site has a natural and obvious entrypoint. There is no magic redirection to learn about. - Pages build into
index.html
files inside of named directories. This allows for naturally colocated assets next to the page, pretty URLs and full support for relative URLs. - No parallel directory structures. You should never be forced to have two directories with identical layouts to put files next to each other. Everything should be colocatable.
- Markdown entrypoints are named README.md. This allows for the
src
folder to be fully navigable in GitHub and other git repo hosting providing a natural hosted CMS UI. - Real TC39 ESM from the start.
- Garbage in, garbage out. Don't over-correct bad input.
- Conventions + standards. Vanilla file types. No new file extensions. No weird syntax to learn. Language tools should just work because you aren't doing anything weird or out of band.
- Encourage directly runnable source files. Direct run is an incredible, undervalued feature more people should learn to use.
- Support typescript, via ts-in-js and type stripping features. Leave type checking to tsc.
- Embrace the now. Limit support on features that let one pretend they are working with future ecosystem features e.g. pseudo esm (technology predictions nearly always are wrong!)
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.
How does domstack
relate to sitedown
: domstack
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.
Look at examples and domstack
dependents for some examples how domstack
can work.
domstack
bundles the best tools for every technology in the stack:
js
andcss
is bundled withesbuild
.md
is processed with markdown-it.- static files are processed with cpx2.
ts
support via native typestripping in Node.js and esbuild.jsx/tsx
support via esbuild.
These tools are treated as implementation details, but they may be exposed more in the future. The idea is that they can be swapped out for better tools in the future if they don't make it.
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 β
β metafile β β (if enabled) β β from opts β
ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ
β β β
βββββββββββββββββββββΌββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ
β buildPages() β
β β
β β’ Process HTML β
β β’ Process MD β
β β’ Process JS β
β β’ Apply layouts β
ββββββββββ¬ββββββββββ
β
βΌ
ββββββββββββββββββββ
β Return Results β
β β
β β’ siteData β
β β’ esbuildResults β
β β’ staticResults β
β β’ copyResults β
β β’ pageResults β
β β’ 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:
- JavaScript and CSS bundling via esbuild
- Static file copying (when enabled)
- Additional directory copying (from
--copy
options)
- Page building - Processes all pages, applying layouts and generating final HTML
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 Queue β
β(Concurrency: min(CPUs, 24))β
ββββββββββββββ¬ββββββββββββββββ
β
ββββββββββββββββββββββΌβββββββββββββββββββββ
β β β
βΌ βΌ βΌ
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β MD Page Task β β HTML Page Task β β JS Page Task β
βββββββββββββββββββ€ βββββββββββββββββββ€ βββββββββββββββββββ€
β βββββββββββββββ β β βββββββββββββββ β β βββββββββββββββ β
β β1. Read .md β β β β1. Read .htmlβ β β β1. Import .jsβ β
β β file β β β β file β β β β module β β
β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β
β βΌ β β βΌ β β βΌ β
β βββββββββββββββ β β βββββββββββββββ β β βββββββββββββββ β
β β2. Extract β β β β2. Variable β β β β2. Variable β β
β β frontmatter β β β β Resolution β β β β Resolution β β
β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β
β βΌ β β βΌ β β βΌ β
β βββββββββββββββ β β βββββββββββββββ β β βββββββββββββββ β
β β Frontmatter β β β βpage.vars.js β β β β Exported β β
β β vars β β β β β β β β vars β β
β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β
β βΌ β β βΌ β β βΌ β
β βββββββββββββββ β β βββββββββββββββ β β βββββββββββββββ β
β βpage.vars.js β β β β postVars β β β βpage.vars.js β β
β β β β β β β β β β β β
β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β
β βΌ β β βΌ β β βΌ β
β βββββββββββββββ β β βββββββββββββββ β β βββββββββββββββ β
β β postVars β β β β3. Handlebarsβ β β β postVars β β
β β β β β β (if enabled)β β β β β β
β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β
β βΌ β β βΌ β β βΌ β
β βββββββββββββββ β β βββββββββββββββ β β βββββββββββββββ β
β β3. Render MD β β β β4. Render β β β β3. Execute β β
β β to HTML β β β β with layoutβ β β β page func β β
β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β β ββββββββ¬βββββββ β
β βΌ β β βΌ β β βΌ β
β βββββββββββββββ β β βββββββββββββββ β β βββββββββββββββ β
β β4. Extract β β β β5. Write HTMLβ β β β4. Render β β
β β title (h1) β β β β β β β β with layoutβ β
β ββββββββ¬βββββββ β β βββββββββββββββ β β ββββββββ¬βββββββ β
β βΌ β β β β βΌ β
β βββββββββββββββ β β β β βββββββββββββββ β
β β5. Render β β β β β β5. Write HTMLβ β
β β with layoutβ β β β β β β β
β ββββββββ¬βββββββ β β β β βββββββββββββββ β
β βΌ β β β β β
β βββββββββββββββ β β β β β
β β6. Write HTMLβ β β β β β
β βββββββββββββββ β β β β β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β β β
ββββββββββββββββββββββββΌβββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ
β Complete when β
β all pages done β
ββββββββββββββββββββ
Variable Resolution Layers:
- Global vars - Site-wide variables from
global.vars.js
(resolved once) - Layout vars - Layout-specific variables from layout functions (resolved once)
- Page-specific vars vary by type:
- MD pages: frontmatter β page.vars.js β postVars
- HTML pages: page.vars.js β postVars
- JS pages: exported vars β page.vars.js β postVars
- postVars - Post-processing function that can modify variables based on all resolved data
domstack
works and has a rudimentary watch command, but hasn't been battle tested yet.
If you end up trying it out, please open any issues or ideas that you have, and feel free to share what you build.
Some notable features are included below, see the roadmap for a more in depth view of whats planned.
-
md
pages -
js
pages -
html
pages -
client.js
page bundles -
style.css
page stylesheets -
page.vars.js
page variables -
loose-markdown-pages.md
- Static asset copying.
- CLI build command
- CLI watch command
- Ignore globbing
- Nested site dest (
src
=.
,dest
=public
) - Default layouts/styles with 0 config starting point
- More examples and ideas.
- Hardened error handling w/ tests
- Multiple layout files
- Nested layout files
- Layout styles
- Layout scripts
- Template files
- Page data available to pages, layouts and template files.
- Handlebars template support in
md
andhtml
-
mjs
andcjs
file extension support - Improved watch log output
- Docs website built with
domstack
: https://domstack.net -
--eject
cli flag - Global assets can live anywhere
- Built in browsersync dev server
- Real default layout style builds
- Esbuild settings escape hatch
- Copy folders
- Full Typescript support via native type stripping
- JSX+TSX support in client bundles
- Rename to domstack
- markdown-it.settings.ts support
- page-worker.worker.ts page worker support
- ...See roadmap
DOMStack started its life as top-bun
in 2023, named after the bakery from Wallace and Gromit. The project was created to provide a simple, fast, and flexible static site generator that could handle modern web development needs while staying true to web standards.
The project was renamed to DOMStack in version 11 to better reflect its purpose and avoid confusion with the Bun JavaScript runtime. The name DOMStack represents the layering of web technologies (HTML, CSS, JavaScript). It is also an homage to subtack as well as a play on the productname that stole his name.