Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,12 @@ jobs:
preid: ${{ github.event.inputs['preid'] }}
dist-tag: ${{ github.event.inputs['dist-tag'] }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build website
run: npm run build
- name: Deploy website to Neocities
uses: bcomnes/deploy-to-neocities@v3
with:
api_key: ${{ secrets.NEOCITIES_DOMSTACK_API_TOKEN }}
cleanup: true
neocities_supporter: true
preview_before_deploy: true
28 changes: 28 additions & 0 deletions authors/bcomnes/author-meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"username": "bcomnes",
"name": "Bret Comnes",
"profile": "https://github.com/bcomnes",
"website": "https://bret.io/",
"links": [
{
"provider": "twitter",
"url": "https://x.com/bcomnes"
},
{
"provider": "mastodon",
"url": "https://fosstodon.org/@bcomnes"
},
{
"provider": "bluesky",
"url": "https://bsky.app/profile/bret.io"
},
{
"provider": "npm",
"url": "https://www.npmjs.com/~bret"
}
],
"avatar": "avatar.jpg",
"keys": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIER/jKDDJvSthkj8vbnPLik2TT+DVW0pIZZpzxFSLWc0\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOAuw2eZOGpbPnuicq7MQWLa03N1zLq0F0iJbWI3Dg2V\n",
"gpg": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\n=VW1V\n-----END PGP PUBLIC KEY BLOCK-----"
}
Binary file added authors/bcomnes/avatar.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions blog/2026/hello-world/page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
layout: blog
title: "Hello, DOMStack"
description: "A first look at the DOMStack blog and the ideas behind building static sites from ordinary files."
publishDate: "2026-09-19T12:00:00Z"
authors:
- bcomnes
---


This is the DOMStack blog: a place for release notes, technical walkthroughs, and notes from building static websites with ordinary HTML, Markdown, CSS, and JavaScript.

DOMStack keeps the source tree close to the output. Pages are files, layouts are composable functions, and shared indexes and feeds are built from the same page data.

## Follow along

Subscribe with [Atom](/feed.xml) or [JSON Feed](/feed.json) to follow future posts.
19 changes: 19 additions & 0 deletions blog/archives.pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { DataDeps, PagesFunction } from '@domstack/static/types.js'
import type { BlogData } from '../site/lib/blog.ts'
import { blogIndex } from '../site/lib/blog-index.ts'

type ArchiveData = Pick<BlogData, 'blogArchives'>
export const dataDeps = ['blogArchives'] satisfies DataDeps<ArchiveData>

const archives: PagesFunction<{ layout: 'blog-index'; title: string; description: string }, string, Record<string, never>, ArchiveData> = ({ data }) =>
data.blogArchives.map(archive => ({
outputName: `${archive.year}/index.html`,
vars: {
layout: 'blog-index' as const,
title: `${archive.year} · Blog · domstack`,
description: `Posts from the ${archive.year} domstack blog archive.`,
},
children: blogIndex(archive.posts, data.blogArchives, archive.year),
}))

export default archives
1 change: 1 addition & 0 deletions blog/atom.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions blog/jsonfeed.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions blog/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { DataDeps, PageFunction } from '@domstack/static/types.js'
import type { BlogData } from '../site/lib/blog.ts'
import { blogIndex } from '../site/lib/blog-index.ts'

export const vars = {
layout: 'blog-index',
title: 'Blog · domstack',
dataDeps: ['blogPosts', 'blogArchives'] satisfies DataDeps<Pick<BlogData, 'blogPosts' | 'blogArchives'>>,
}

const page: PageFunction<typeof vars, string, Pick<BlogData, 'blogPosts' | 'blogArchives'>> = ({ data }) => blogIndex(data.blogPosts, data.blogArchives)
export default page
7 changes: 7 additions & 0 deletions feed.json.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { DataDeps, TemplateFunction } from '@domstack/static/types.js'
import type { BlogData } from './site/lib/blog.ts'
import { jsonFeed } from './site/lib/blog-feeds.ts'

export const dataDeps = ['blogFeed'] satisfies DataDeps<Pick<BlogData, 'blogFeed'>>
const template: TemplateFunction<{ siteUrl: string }, Pick<BlogData, 'blogFeed'>> = ({ data, vars }) => jsonFeed(data.blogFeed, vars.siteUrl)
export default template
7 changes: 7 additions & 0 deletions feed.xml.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { DataDeps, TemplateFunction } from '@domstack/static/types.js'
import type { BlogData } from './site/lib/blog.ts'
import { atomFeed } from './site/lib/blog-feeds.ts'

export const dataDeps = ['blogFeed'] satisfies DataDeps<Pick<BlogData, 'blogFeed'>>
const template: TemplateFunction<{ siteUrl: string }, Pick<BlogData, 'blogFeed'>> = ({ data, vars }) => atomFeed(data.blogFeed, vars.siteUrl)
export default template
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@
"build": "npm run clean && npm run build:defaults && run-p build:domstack build:declaration build:schema",
"build:defaults": "node scripts/build-defaults.js",
"build:domstack": "./bin.js --src . --ignore examples,test-cases,test-fixtures,type-tests,coverage,*.tsconfig.json,fonts,/lib,/types,/scripts,/plans,/browser-tests,/test-results,/lcov.info,/tsconfig.json,/AGENTS.md,/AGENT.md,/agents.md",
"new-blogpost": "node tools/blog/create.ts",
"publish-draft": "node tools/blog/publish.ts",
"import-author": "node tools/authors/import.ts",
"build:declaration": "tsc -p declaration.tsconfig.json",
"build:schema": "node scripts/domstack-manifest-schema.js",
"watch": "npm run clean && run-p watch:*",
Expand Down
9 changes: 7 additions & 2 deletions site/globals/global.data.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import type { GlobalDataFunctionParams } from '../../types.ts'
import type { DocsNavigationIndex, DocsPageVars } from '../layouts/docs/navigation.js'
import { projectBlog, readBlogPost, type BlogData } from '../lib/blog.ts'
import { cpus } from 'node:os'
import pMap from 'p-map'
import { render } from 'fragtml'
import { readDocsNavigationPage, projectDocsNavigation, docsIndex } from '../layouts/docs/navigation.js'

const MAX_CONCURRENCY = Math.min(cpus().length, 24)

export default async function ({ pages, previousState, changes, setState }: GlobalDataFunctionParams<DocsPageVars, string, DocsNavigationIndex>) {
type SourcePageVars = DocsPageVars & { layout?: string }

export default async function ({ pages, previousState, changes, setState }: GlobalDataFunctionParams<SourcePageVars, string, DocsNavigationIndex>): Promise<{ docsNavigation: ReturnType<typeof projectDocsNavigation>; docsIndexHtml: string } & BlogData> {
let index: DocsNavigationIndex
let inputs: typeof pages
switch (changes.kind) {
Expand All @@ -33,6 +36,8 @@ export default async function ({ pages, previousState, changes, setState }: Glob

const docsNavigation = projectDocsNavigation(index.values())
const docsIndexHtml = render(docsIndex(docsNavigation))
const blogPosts = await pMap(pages.filter(page => page.vars.layout === 'blog'), readBlogPost, { concurrency: MAX_CONCURRENCY })
const blog = projectBlog(blogPosts)
setState(index)
return { docsNavigation, docsIndexHtml }
return { docsNavigation, docsIndexHtml, ...blog }
}
6 changes: 6 additions & 0 deletions site/globals/global.vars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
siteName: 'domstack',
siteUrl: 'https://domstack.net',
editRepository: 'https://github.com/bcomnes/domstack/edit/master',
lang: 'en',
}
83 changes: 83 additions & 0 deletions site/layouts/blog-index/blog-index.layout.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
@import '../blog/blog.layout.css';

@layer domstack.layout {
.blog-index-header {
margin-block-end: 0;
}

.blog-index-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}

.blog-index-heading h1 {
margin: 0;
letter-spacing: -0.03em;
}

.blog-index-heading .blog-feeds {
flex: none;
margin: 0;
}

.blog-index-description {
max-inline-size: 56ch;
margin: 1rem 0 1.5rem;
color: var(--site-muted);
}

.blog-years {
align-items: center;
gap: 0.5rem 1.5rem;
margin-block-end: 2rem;
font-size: 0.875rem;
}

.blog-years [aria-current="page"] {
color: var(--text);
font-weight: 700;
}

.blog-list > li {
padding-block: 2rem;
}

.blog-list h2 {
margin: 0 0 0.5rem;
font-size: clamp(1.5rem, 3vw, 1.75rem);
letter-spacing: -0.02em;
}

.blog-list .blog-byline {
margin-block-end: 1rem;
}

.blog-list .blog-summary {
max-inline-size: 64ch;
margin: 0;
color: var(--site-muted);
}

.blog-empty {
margin: 0;
padding-block: 2rem;
border-block-start: 1px solid var(--site-border);
color: var(--site-muted);
}

@media (max-width: 40rem) {
.blog-index-heading {
align-items: flex-start;
}

.blog-index-description {
margin-block: 1rem 1.5rem;
}

.blog-years {
gap: 0.25rem 1rem;
}
}
}
28 changes: 28 additions & 0 deletions site/layouts/blog-index/blog-index.layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { html, raw, render } from 'fragtml'
import type { LayoutFunction } from '@domstack/static/types.js'

export interface BlogIndexVars {
layout: 'blog-index'
title?: string
description?: string
[key: string]: unknown
}

export const parentLayout = 'root' as const

const blogIndexLayout: LayoutFunction<BlogIndexVars, string, string> = ({ children }) => render(html`
<main class="blog-main" id="main-content">
<div class="blog-column">${raw(children)}</div>
</main>
`)

export default blogIndexLayout

declare module '@domstack/static/types.js' {
interface LayoutRegistry {
'blog-index': {
parentLayout: typeof parentLayout
render: typeof blogIndexLayout
}
}
}
54 changes: 54 additions & 0 deletions site/layouts/blog/blog.layout.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
@layer domstack.layout {
.blog-main {
padding: 3rem max(1.25rem, env(safe-area-inset-right)) 5rem;
}

.blog-column {
max-inline-size: 72ch;
margin-inline: auto;
font-size: 1.1rem;
line-height: 1.65;
overflow-wrap: anywhere;
}

.blog-column h1,
.blog-column h2,
.blog-column h3 {
line-height: 1.2;
}

.blog-column h1 { font-size: clamp(2rem, 6vw, 3rem); }
.blog-column h2 { margin-block-start: 2.5rem; }
.blog-feeds,
.blog-years { display: flex; flex-wrap: wrap; gap: 1rem 1.5rem; }
.blog-feeds { margin-block: 1.5rem; }
.blog-feeds a { display: inline-flex; align-items: center; justify-content: center; min-inline-size: 1.5rem; min-block-size: 1.5rem; }
.blog-feeds img { display: block; inline-size: 1.5rem; block-size: 1.5rem; border-radius: 0.2rem; }
.blog-years { margin-block-end: 2rem; }
.blog-years [aria-current="page"] { font-weight: 700; }
.blog-breadcrumb { margin-block-end: 1rem; }
.blog-list { list-style: none; padding: 0; margin: 0; }
.blog-list > li { border-block-start: 1px solid var(--site-border); padding-block: 2rem; }
.blog-list h2 { margin-block: 0.5rem 0.25rem; }
.blog-list p { margin-block: 1rem 0; }
.blog-list .blog-byline { margin: 0 0 1rem; }
.blog-list .blog-summary { margin: 0; }
.blog-byline,
.blog-updated { color: var(--site-muted); font-size: 0.875rem; }
.blog-byline,
.blog-authors { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.5rem; }
.blog-authors { gap: 0.5rem 1rem; }
.blog-author { display: inline-flex; align-items: center; gap: 0.5rem; min-width: 0; }
.blog-author img { flex-shrink: 0; border-radius: 50%; object-fit: cover; width: 1.5rem; height: 1.5rem; }
.blog-article-meta { margin-block-end: 2rem; padding-block-end: 1.5rem; border-block-end: 1px solid var(--site-border); }
.blog-article-meta .blog-breadcrumb { margin-block-end: 1rem; }
.blog-article-meta .blog-title { max-inline-size: 24ch; margin: 0 0 0.5rem; text-wrap: balance; }
.blog-article-meta .blog-author img { width: 2rem; height: 2rem; }
.blog-article-meta .blog-byline { margin: 0; }
.blog-article-meta .blog-updated { margin-block-start: 0.5rem; }
.blog-article-footer { border-block-start: 1px solid var(--site-border); margin-block-start: 3rem; padding-block-start: 1.5rem; }
.blog-prose img,
.blog-prose video { max-inline-size: 100%; block-size: auto; }
.blog-prose pre { overflow-x: auto; padding: 1rem; border-radius: 0.4rem; }
.blog-prose blockquote { margin-inline: 0; padding-inline-start: 1.5rem; border-inline-start: 4px solid var(--link-text); }
}
56 changes: 56 additions & 0 deletions site/layouts/blog/blog.layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { html, raw, render } from 'fragtml'
import type { AsyncLayoutFunction } from '@domstack/static/types.js'
import type { BlogPostVars } from '../../lib/blog.ts'
import { blogYear, formatBlogDate, validateBlogVars } from '../../lib/blog.ts'

export const parentLayout = 'root' as const

const blogLayout: AsyncLayoutFunction<BlogPostVars, string, string> = async ({ vars, children, page }) => {
const post = await validateBlogVars({ ...vars }, page.url)
const year = blogYear(page.url)

return render(html`
<main class="blog-main" id="main-content">
<div class="blog-column">
<article class="blog-article">
<header class="blog-article-meta">
<nav class="blog-breadcrumb" aria-label="Blog navigation">
<a href="/blog/">Blog</a>
${year ? html`<span aria-hidden="true"> / </span><a href="/blog/${year}/">${year}</a>` : null}
</nav>
<h1 class="blog-title">${post.title}</h1>
<p class="blog-byline">
<span class="blog-authors">
${post.authors.map(author => html`
<a class="blog-author" rel="author" href="${author.url}">
<img src="${author.avatar}" width="32" height="32" alt="" />
<span>${author.name}</span>
</a>
`)}
</span>
<time datetime="${post.publishDate}">${formatBlogDate(post.publishDate)}</time>
</p>
${post.updatedDate
? html`<p class="blog-updated">Updated <time datetime="${post.updatedDate}">${formatBlogDate(post.updatedDate)}</time></p>`
: null}
</header>
<div class="blog-prose">${raw(children)}</div>
<footer class="blog-article-footer">
<a href="/blog/">← Back to blog</a>
</footer>
</article>
</div>
</main>
`)
}

export default blogLayout

declare module '@domstack/static/types.js' {
interface LayoutRegistry {
blog: {
parentLayout: typeof parentLayout
render: typeof blogLayout
}
}
}
Loading
Loading