From 5b1e0a15913e31e7ace665fc0f73d529a620ef49 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 19 Sep 2026 09:29:08 -0700 Subject: [PATCH 01/12] feat: add blog to domstack website --- blog/2026/hello-world/page.md | 16 +++ blog/archives.pages.ts | 19 +++ blog/page.ts | 12 ++ feed.json.template.ts | 7 + feed.xml.template.ts | 7 + package.json | 2 + site/globals/global.data.ts | 9 +- site/globals/global.vars.ts | 6 + site/layouts/blog-index/blog-index.layout.css | 1 + site/layouts/blog-index/blog-index.layout.js | 11 ++ site/layouts/blog/blog.layout.css | 41 ++++++ site/layouts/blog/blog.layout.js | 21 +++ site/layouts/docs/navigation-watch.test.js | 4 +- site/layouts/root/root.layout.css | 9 ++ site/layouts/root/root.layout.js | 19 ++- site/lib/authors.ts | 15 +++ site/lib/blog-feeds.ts | 92 +++++++++++++ site/lib/blog-index.ts | 20 +++ site/lib/blog.test.js | 46 +++++++ site/lib/blog.ts | 123 ++++++++++++++++++ tools/blog/create.ts | 48 +++++++ tools/blog/publish.ts | 43 ++++++ tools/blog/shared.ts | 41 ++++++ 23 files changed, 607 insertions(+), 5 deletions(-) create mode 100644 blog/2026/hello-world/page.md create mode 100644 blog/archives.pages.ts create mode 100644 blog/page.ts create mode 100644 feed.json.template.ts create mode 100644 feed.xml.template.ts create mode 100644 site/globals/global.vars.ts create mode 100644 site/layouts/blog-index/blog-index.layout.css create mode 100644 site/layouts/blog-index/blog-index.layout.js create mode 100644 site/layouts/blog/blog.layout.css create mode 100644 site/layouts/blog/blog.layout.js create mode 100644 site/lib/authors.ts create mode 100644 site/lib/blog-feeds.ts create mode 100644 site/lib/blog-index.ts create mode 100644 site/lib/blog.test.js create mode 100644 site/lib/blog.ts create mode 100644 tools/blog/create.ts create mode 100644 tools/blog/publish.ts create mode 100644 tools/blog/shared.ts diff --git a/blog/2026/hello-world/page.md b/blog/2026/hello-world/page.md new file mode 100644 index 00000000..4f11ff75 --- /dev/null +++ b/blog/2026/hello-world/page.md @@ -0,0 +1,16 @@ +--- +layout: blog +description: "A first look at the DOMStack blog and the ideas behind building static sites from ordinary files." +publishDate: "2026-09-19T12:00:00Z" +author: bret +--- + +# Hello, DOMStack + +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. diff --git a/blog/archives.pages.ts b/blog/archives.pages.ts new file mode 100644 index 00000000..cc37020f --- /dev/null +++ b/blog/archives.pages.ts @@ -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 +export const dataDeps = ['blogArchives'] satisfies DataDeps + +const archives: PagesFunction<{ layout: 'blog-index'; title: string; description: string }, string, Record, 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 diff --git a/blog/page.ts b/blog/page.ts new file mode 100644 index 00000000..fe32c9d8 --- /dev/null +++ b/blog/page.ts @@ -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>, +} + +const page: PageFunction> = ({ data }) => blogIndex(data.blogPosts, data.blogArchives) +export default page diff --git a/feed.json.template.ts b/feed.json.template.ts new file mode 100644 index 00000000..3feed243 --- /dev/null +++ b/feed.json.template.ts @@ -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> +const template: TemplateFunction<{ siteUrl: string }, Pick> = ({ data, vars }) => jsonFeed(data.blogFeed, vars.siteUrl) +export default template diff --git a/feed.xml.template.ts b/feed.xml.template.ts new file mode 100644 index 00000000..9fd83f43 --- /dev/null +++ b/feed.xml.template.ts @@ -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> +const template: TemplateFunction<{ siteUrl: string }, Pick> = ({ data, vars }) => atomFeed(data.blogFeed, vars.siteUrl) +export default template diff --git a/package.json b/package.json index bacf34ad..36322dc7 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,8 @@ "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", "build:declaration": "tsc -p declaration.tsconfig.json", "build:schema": "node scripts/domstack-manifest-schema.js", "watch": "npm run clean && run-p watch:*", diff --git a/site/globals/global.data.ts b/site/globals/global.data.ts index 2c2173a9..fafbd5e4 100644 --- a/site/globals/global.data.ts +++ b/site/globals/global.data.ts @@ -1,5 +1,6 @@ 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' @@ -7,7 +8,9 @@ import { readDocsNavigationPage, projectDocsNavigation, docsIndex } from '../lay const MAX_CONCURRENCY = Math.min(cpus().length, 24) -export default async function ({ pages, previousState, changes, setState }: GlobalDataFunctionParams) { +type SourcePageVars = DocsPageVars & { layout?: string } + +export default async function ({ pages, previousState, changes, setState }: GlobalDataFunctionParams): Promise<{ docsNavigation: ReturnType; docsIndexHtml: string } & BlogData> { let index: DocsNavigationIndex let inputs: typeof pages switch (changes.kind) { @@ -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 } } diff --git a/site/globals/global.vars.ts b/site/globals/global.vars.ts new file mode 100644 index 00000000..645078a5 --- /dev/null +++ b/site/globals/global.vars.ts @@ -0,0 +1,6 @@ +export default { + siteName: 'domstack', + siteUrl: 'https://domstack.neocities.org', + editRepository: 'https://github.com/bcomnes/domstack/edit/master', + lang: 'en', +} diff --git a/site/layouts/blog-index/blog-index.layout.css b/site/layouts/blog-index/blog-index.layout.css new file mode 100644 index 00000000..dc4ff67b --- /dev/null +++ b/site/layouts/blog-index/blog-index.layout.css @@ -0,0 +1 @@ +@import '../blog/blog.layout.css'; diff --git a/site/layouts/blog-index/blog-index.layout.js b/site/layouts/blog-index/blog-index.layout.js new file mode 100644 index 00000000..f8a872c2 --- /dev/null +++ b/site/layouts/blog-index/blog-index.layout.js @@ -0,0 +1,11 @@ +/** + * @import { LayoutFunction } from '#types' + */ +import { html, raw, render } from 'fragtml' + +export const parentLayout = 'root' + +/** @type {LayoutFunction, string, string>} */ +export default function blogIndexLayout ({ children }) { + return render(html`
${raw(children)}
`) +} diff --git a/site/layouts/blog/blog.layout.css b/site/layouts/blog/blog.layout.css new file mode 100644 index 00000000..077e13b9 --- /dev/null +++ b/site/layouts/blog/blog.layout.css @@ -0,0 +1,41 @@ +@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-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 1rem; } + .blog-list p { margin-block: 1rem 0; } + .blog-byline, + .blog-updated { color: var(--site-muted); font-size: 0.875rem; } + .blog-updated { display: block; margin-block-start: 0.5rem; } + .blog-article-meta { margin-block-end: 2rem; } + .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); } +} diff --git a/site/layouts/blog/blog.layout.js b/site/layouts/blog/blog.layout.js new file mode 100644 index 00000000..9366b3e8 --- /dev/null +++ b/site/layouts/blog/blog.layout.js @@ -0,0 +1,21 @@ +/** + * @import { LayoutFunction } from '#types' + */ +import { html, raw, render } from 'fragtml' +import { blogYear, formatBlogDate, validateBlogVars } from '../../lib/blog.ts' + +export const parentLayout = 'root' + +/** @type {LayoutFunction, string, string>} */ +export default function blogLayout ({ vars, children, page }) { + const post = validateBlogVars(vars, page.url) + const year = blogYear(page.url) + return render(html`
`) +} diff --git a/site/layouts/docs/navigation-watch.test.js b/site/layouts/docs/navigation-watch.test.js index 486df97b..e2a79290 100644 --- a/site/layouts/docs/navigation-watch.test.js +++ b/site/layouts/docs/navigation-watch.test.js @@ -28,6 +28,8 @@ test('heading changes refresh shared navigation; body edits leave other pages al 'site/layouts/docs/navigation.js', 'site/layouts/docs/docs.layout.js', 'site/layouts/root/root.layout.js', + 'site/lib/authors.ts', + 'site/lib/blog.ts', ]) { await mkdir(dirname(join(src, file)), { recursive: true }) await cp(join(root, file), join(src, file)) @@ -104,7 +106,7 @@ test('docs producer retains navigation while rendering only the required sources setState (next) { state = structuredClone(next) }, }) assert.deepEqual(rendered.sort(), expectedRenders, 'actual renderInnerPage calls, including duplicates') - assert.deepEqual(Object.keys(data).sort(), ['docsIndexHtml', 'docsNavigation']) + assert.deepEqual(Object.keys(data).sort(), ['blogArchives', 'blogFeed', 'blogPosts', 'docsIndexHtml', 'docsNavigation']) return data } diff --git a/site/layouts/root/root.layout.css b/site/layouts/root/root.layout.css index bcf4b975..5e8a0ac6 100644 --- a/site/layouts/root/root.layout.css +++ b/site/layouts/root/root.layout.css @@ -154,6 +154,11 @@ margin: 0; } + .page-edit { + margin-inline-start: auto; + white-space: nowrap; + } + @media (max-width: 40rem) { .site-header-inner, .site-links { @@ -168,5 +173,9 @@ align-items: start; flex-direction: column; } + + .page-edit { + margin-inline-start: 0; + } } } diff --git a/site/layouts/root/root.layout.js b/site/layouts/root/root.layout.js index d9e05030..d7b3b37d 100644 --- a/site/layouts/root/root.layout.js +++ b/site/layouts/root/root.layout.js @@ -9,19 +9,28 @@ function year () { return new Date().getFullYear() } +/** @param {string} sourceRelname */ +function editUrl (sourceRelname) { + return 'https://github.com/bcomnes/domstack/edit/master/' + sourceRelname.replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/') +} + /** * The website owns its document shell; the docs layout owns the sidebar and * main landmark. Other pages get a simple main landmark here instead. - * @type {LayoutFunction<{ title?: string, layout?: string, lang?: string, basePath?: string }, string | HtmlResult, string>} + * @type {LayoutFunction<{ title?: string, layout?: string, lang?: string, basePath?: string, siteUrl?: string }, string | HtmlResult, string>} */ export default function rootLayout ({ children, vars, page, scripts, styles }) { const isDocs = vars.layout === 'docs' + const ownsMain = isDocs || vars.layout === 'blog' || vars.layout === 'blog-index' const content = typeof children === 'string' ? raw(children) : children const home = navigationHref(page.url, '/') const docs = navigationHref(page.url, '/docs/') const examples = navigationHref(page.url, '/docs/example-projects/') + const blog = navigationHref(page.url, '/blog/') /** @param {string} path */ const assetUrl = path => path.startsWith('/') ? `${vars.basePath ?? ''}${path}` : path + const editSource = page.generated?.pagesFile.pagesFile.relname ?? page.pageFile?.relname ?? 'README.md' + const menuToggle = html`