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
325 changes: 300 additions & 25 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/v11-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export default async function ({ pages }) {
Key differences:
- There is only **one** `global.data.js` per project (the first one found wins; duplicates emit a warning)
- The function is the **default export**, not a named `postVars` export
- The returned data is stamped onto **every** page's vars (same behavior as `postVars` was)
- The returned data is available to generated-page factories and stamped onto **every** page's vars (same final-render behavior as `postVars` had)
- The types `PostVarsFunction` and `AsyncPostVarsFunction` are replaced by `GlobalDataFunction` and `AsyncGlobalDataFunction`

---
Expand All @@ -205,7 +205,7 @@ Two new filenames are now recognized and processed by domstack. If you have exis

### `global.data.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`)

Now treated as the global data aggregation file. Its default export is called with `{ pages }` after all pages are initialized. See [section 7](#7-postvars-removed--globaldatajs) above.
Now treated as the global data aggregation file. Its default export is called with `{ pages }` after source-backed pages are initialized and before generated-page factories run. See [section 7](#7-postvars-removed--globaldatajs) above.

### `markdown-it.settings.js` (and `.ts`, `.mjs`, `.mts`, `.cjs`, `.cts`)

Expand Down
2 changes: 1 addition & 1 deletion examples/blog/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@domstack/blog-example",
"version": "0.0.0",
"description": "A blog example for domstack demonstrating global.data.ts, nested layouts, and feeds.",
"description": "A blog example for domstack demonstrating generated archives and redirects, global.data.ts, nested layouts, and feeds.",
"type": "module",
"scripts": {
"start": "npm run watch",
Expand Down
31 changes: 31 additions & 0 deletions examples/blog/src/blog-indexes.pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { PagesFunction } from '@domstack/static/types.js'
import type { GlobalData } from './global.data.js'

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

/**
* Turn the yearly groups prepared by global.data.ts into normal pages.
* The year-index layout renders the posts already assigned to each archive.
*/
const blogIndexes: PagesFunction<YearIndexPageVars, string, GlobalData> = ({ vars }) => {
const pages = []

for (const { year, posts } of vars.blogIndexes) {
pages.push({
outputName: `blog/${year}/index.html`,
vars: {
layout: 'year-index' as const,
title: String(year),
posts,
},
})
}

return pages
}

export default blogIndexes
4 changes: 0 additions & 4 deletions examples/blog/src/blog/2024/README.md

This file was deleted.

18 changes: 15 additions & 3 deletions examples/blog/src/blog/2024/hello-world/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ layout: post
title: "Hello, World"
publishDate: "2024-03-15T12:00:00.000Z"
description: "The first post on this blog. An introduction to what this is all about."
redirectFrom:
- /blog/hello-world/
tags:
- meta
- intro
Expand All @@ -21,14 +23,24 @@ They're generated at build time by `global.data.ts`:

```ts
// src/global.data.ts
const blogPosts = pages
.filter(p => p.vars?.layout === 'post' && p.vars?.publishDate)
.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate))
const blogPosts = collectBlogPosts(pages)
const blogIndexes = collectBlogIndexes(blogPosts)

return { blogPosts, blogIndexes /* ...other site data */ }
```

The returned object is stamped onto every page's `vars`, so any page or layout can read
`vars.blogPosts` directly — no postVars, no custom wiring.

The yearly `/blog/2024/` and `/blog/2025/` archives are generated separately by
`src/blog-indexes.pages.ts`. `global.data.ts` groups the posts by year once, the pages file
turns those groups into normal pages, and the `year-index` layout renders each group's
newest-first posts. There are no hand-maintained year index files.

This page also owns its old `/blog/hello-world/` location through the `redirectFrom`
frontmatter above. `global.data.ts` collects that metadata, and `redirects.pages.ts`
generates the redirect to this page's current URL.

## This layout

This post uses the `post` layout (`src/layouts/post.layout.ts`), which wraps the root layout
Expand Down
4 changes: 0 additions & 4 deletions examples/blog/src/blog/2025/README.md

This file was deleted.

18 changes: 17 additions & 1 deletion examples/blog/src/blog/page.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { basename, dirname } from 'node:path'
import { html, render } from 'fragtml'
import type { PageFunction } from '@domstack/static/types.js'
import type { GlobalData } from '../global.data.js'
Expand All @@ -10,8 +11,17 @@ type Vars = SiteVars & GlobalData
* Post data comes entirely from global.data.ts via vars.blogPosts.
* No postVars, no manual wiring needed here.
*/
const blogIndex: PageFunction<Vars> = ({ vars }) => {
const blogIndex: PageFunction<Vars> = ({ vars, page, pages }) => {
const { blogPosts } = vars
const archivePages = []

for (const candidate of pages) {
if (dirname(candidate.pageInfo.path) === page.path && candidate.vars['layout'] === 'year-index') {
archivePages.push(candidate)
}
}

archivePages.sort((a, b) => b.pageInfo.path.localeCompare(a.pageInfo.path))

if (blogPosts.length === 0) {
return '<p>No posts yet.</p>'
Expand Down Expand Up @@ -39,6 +49,12 @@ const blogIndex: PageFunction<Vars> = ({ vars }) => {
`
})}
</ul>
<h2>Archive</h2>
<ul class="archive-list">
${archivePages.map(archive => html`
<li><a href="${archive.pageInfo.url}">${basename(archive.pageInfo.path)}</a></li>
`)}
</ul>
</div>
`)
}
Expand Down
109 changes: 96 additions & 13 deletions examples/blog/src/global.data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { html, render } from 'fragtml'
import type { AsyncGlobalDataFunction } from '@domstack/static/types.js'
import type { AsyncGlobalDataFunction, GlobalDataFunctionParams } from '@domstack/static/types.js'

export interface BlogPost {
path: string
Expand All @@ -9,29 +9,112 @@ export interface BlogPost {
tags: string[]
}

export interface BlogIndex {
year: number
posts: BlogPost[]
}

export interface PageRedirect {
/** Old same-origin URL path that should redirect. */
from: string
/** Current URL of the page that declared the old path. */
to: string
}

function collectRedirects (pages: GlobalDataFunctionParams['pages']): PageRedirect[] {
const redirects: PageRedirect[] = []
const redirectOwners = new Map<string, string>()

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 of same-origin URL paths`)

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 beginning with "/"`)
if (from.includes('?') || from.includes('#')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": queries and fragments are not supported`)
if (from.includes('\\') || from.split('/').some(part => part === '.' || part === '..')) throw new Error(`Invalid redirectFrom "${from}" on "${source}": path must not contain ".", "..", or backslash segments`)

const existingSource = redirectOwners.get(from)
if (existingSource) {
const detail = existingSource === source
? `more than once on "${source}"`
: `by both "${existingSource}" and "${source}"`
throw new Error(`redirectFrom "${from}" is declared ${detail}`)
}

redirectOwners.set(from, source)
redirects.push({ from, to: page.pageInfo.url })
}
}

return redirects
}

function collectBlogPosts (pages: GlobalDataFunctionParams['pages']): BlogPost[] {
const blogPosts: BlogPost[] = []

for (const page of pages) {
const publishDateValue = page.vars.publishDate
if (page.vars.layout !== 'post' || (typeof publishDateValue !== 'string' && !(publishDateValue instanceof Date))) continue

const publishDate = new Date(publishDateValue.valueOf())
if (Number.isNaN(publishDate.valueOf())) continue

blogPosts.push({
path: page.pageInfo.path,
title: String(page.vars.title ?? 'Untitled'),
publishDate: publishDate.toISOString(),
description: String(page.vars.description ?? ''),
tags: Array.isArray(page.vars.tags) ? page.vars.tags as string[] : [],
})
}

blogPosts.sort((a, b) => b.publishDate.localeCompare(a.publishDate))
return blogPosts
}

function collectBlogIndexes (blogPosts: BlogPost[]): BlogIndex[] {
const postsByYear = new Map<number, BlogPost[]>()

for (const post of blogPosts) {
const year = new Date(post.publishDate).getUTCFullYear()
const yearPosts = postsByYear.get(year) ?? []
yearPosts.push(post)
postsByYear.set(year, yearPosts)
}

const blogIndexes: BlogIndex[] = []
for (const [year, posts] of postsByYear) {
blogIndexes.push({ year, posts })
}

blogIndexes.sort((a, b) => b.year - a.year)
return blogIndexes
}

export interface GlobalData {
/** All blog posts, sorted newest-first. Available to every page and template. */
blogPosts: BlogPost[]
/** Yearly post groups used to generate and render archive pages. */
blogIndexes: BlogIndex[]
/** The 5 most recent posts — used by the home page listing. */
recentPosts: BlogPost[]
/** Pre-rendered HTML snippet of recent posts — drop into a page with {{{ vars.recentPostsHtml }}} */
recentPostsHtml: string
/** tag → posts index, available for tag archive pages. */
tagIndex: Record<string, BlogPost[]>
/** Redirects collected from each destination page's redirectFrom metadata. */
redirects: PageRedirect[]
}

const buildGlobalData: AsyncGlobalDataFunction<GlobalData> = async ({ pages }) => {
const blogPosts: BlogPost[] = pages
.filter(p => p.vars?.layout === 'post' && p.vars?.publishDate)
.map(p => ({
path: p.pageInfo.path,
title: String(p.vars?.title ?? 'Untitled'),
publishDate: String(p.vars?.publishDate),
description: String(p.vars?.description ?? ''),
tags: Array.isArray(p.vars?.tags) ? (p.vars.tags as string[]) : [],
}))
.sort((a, b) => new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime())

const blogPosts = collectBlogPosts(pages)
const blogIndexes = collectBlogIndexes(blogPosts)
const redirects = collectRedirects(pages)
const recentPosts = blogPosts.slice(0, 5)

// Pre-render an HTML snippet for use on the home page via handlebars {{{ vars.recentPostsHtml }}}
Expand Down Expand Up @@ -66,7 +149,7 @@ const buildGlobalData: AsyncGlobalDataFunction<GlobalData> = async ({ pages }) =
}
}

return { blogPosts, recentPosts, recentPostsHtml, tagIndex }
return { blogPosts, blogIndexes, recentPosts, recentPostsHtml, tagIndex, redirects }
}

export default buildGlobalData
25 changes: 25 additions & 0 deletions examples/blog/src/layouts/redirect.layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { html, render } from 'fragtml'
import type { LayoutFunction } from '@domstack/static/types.js'
import type { RootVars } from './root.layout.js'

type RedirectVars = RootVars & {
redirectTo: string
}

const redirectLayout: LayoutFunction<RedirectVars, string, string> = ({ vars }) => {
return render(html`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<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>`)
}

export default redirectLayout
30 changes: 12 additions & 18 deletions examples/blog/src/layouts/year-index.layout.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,38 @@
import { html, raw, render } from 'fragtml'
import type { HtmlResult } from 'fragtml/types.js'
import { dirname } from 'node:path'
import type { LayoutFunction } from '@domstack/static/types.js'
import rootLayout from './root.layout.ts'
import type { RootVars } from './root.layout.ts'
import type { PostVars } from './post.layout.ts'
import type { BlogPost } from '../global.data.ts'

export type YearIndexVars = RootVars & Pick<PostVars, 'publishDate' | 'description'>
export type YearIndexVars = RootVars & {
posts?: BlogPost[]
}

/**
* Auto-index layout: lists all direct child pages of the current page's
* folder, sorted newest-first by publishDate. Use on year/section index
* pages — just set `layout: year-index` in frontmatter, no page.ts needed.
* Yearly archive layout. `global.data.ts` prepares each newest-first post
* collection and `blog-indexes.pages.ts` assigns it to a generated page.
*/
const yearIndexLayout: LayoutFunction<YearIndexVars, string | HtmlResult, string> = (args) => {
const { children, page, pages, ...rest } = args
type DatedPage = (typeof pages)[number] & { vars: YearIndexVars & { publishDate: string } }

const childPages = pages
.filter((p): p is DatedPage => dirname(p.pageInfo.path) === page.path && typeof p.vars.publishDate === 'string')
.sort((a, b) => new Date(b.vars.publishDate).getTime() - new Date(a.vars.publishDate).getTime())
const { children, ...rest } = args

const wrappedChildren = render(html`
<div>
<h1>${args.vars.title}</h1>
<ul class="post-list">
${childPages.map(p => {
const title = p.vars.title ?? 'Untitled'
const date = new Date(p.vars.publishDate)
${(args.vars.posts ?? []).map(post => {
const date = new Date(post.publishDate)
return html`
<li class="post-list-item">
<h2 class="post-list-title">
<a href="/${p.pageInfo.path}/">${title}</a>
<a href="/${post.path}/">${post.title}</a>
</h2>
<p class="post-list-meta">
<time datetime="${date.toISOString()}">
${date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</time>
</p>
${p.vars.description ? html`<p class="post-list-description">${p.vars.description}</p>` : null}
${post.description ? html`<p class="post-list-description">${post.description}</p>` : null}
</li>
`
})}
Expand All @@ -50,7 +44,7 @@ const yearIndexLayout: LayoutFunction<YearIndexVars, string | HtmlResult, string
</div>
`)

return rootLayout({ ...rest, page, pages, children: wrappedChildren })
return rootLayout({ ...rest, children: wrappedChildren })
}

export default yearIndexLayout
Loading