From 7c1df9ef2867d10121500fd952e3a1abc7f6c245 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 26 Aug 2026 23:12:58 +0100 Subject: [PATCH] feat: migrate to comark cms (#1923) --- content.config.ts | 66 +++ modules/markdown.ts | 507 ++++++++++-------- .../md-routes/runtime/server/blog-md.get.ts | 24 +- nuxt.config.ts | 39 +- package.json | 2 +- pnpm-lock.yaml | 121 ++--- server/routes/ai.md.get.ts | 14 +- server/routes/bio.md.get.ts | 14 +- server/routes/blog.md.get.ts | 13 +- server/routes/index.md.get.ts | 11 +- server/routes/llms-full.txt.get.ts | 26 +- server/routes/llms.txt.get.ts | 5 +- server/routes/projects.md.get.ts | 4 +- server/routes/rss.xml.get.ts | 27 +- server/routes/talks.md.get.ts | 6 +- server/routes/uses.md.get.ts | 4 +- server/utils/content.ts | 48 ++ server/utils/md.ts | 41 +- server/utils/site.ts | 6 + test/unit/behaviour.spec.ts | 19 +- test/unit/bundle.spec.ts | 2 +- test/unit/sync-items.spec.ts | 49 +- 22 files changed, 619 insertions(+), 429 deletions(-) create mode 100644 content.config.ts create mode 100644 server/utils/content.ts create mode 100644 server/utils/site.ts diff --git a/content.config.ts b/content.config.ts new file mode 100644 index 00000000..6820f650 --- /dev/null +++ b/content.config.ts @@ -0,0 +1,66 @@ +import { fileURLToPath } from 'node:url' + +import { comarkContent } from 'comark-content' +import fsSource from 'comark-content/sources/fs' +import schemaValidation from 'comark-content/plugins/schema-validation' +import type { JsonSchema } from 'comark-content' +import { isDevelopment } from 'std-env' +import shiki from 'comark/plugins/shiki' +import palenight from 'shiki/themes/material-theme-palenight.mjs' + +import { headingIds } from './modules/shared/comark-heading-ids' + +/** Absolute path to the blog source directory, as the sources below resolve it. */ +export const blogDir = fileURLToPath(new URL('./content/blog', import.meta.url)) + +/** Fields `modules/markdown.ts` derives from a document's source text at build time. */ +export interface ContentMeta { + /** The document body as plain markdown, served by the `.md` routes. */ + markdown: string + /** The document body as HTML, used by the feeds. Blog posts only. */ + html?: string +} + +const blogSchema = { + type: 'object', + properties: { + title: { type: 'string' }, + // an unquoted YAML date parses to a `Date`, which serialises differently + // in the templates and virtuals built from this data + date: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + description: { type: 'string' }, + skip_dev: { type: 'boolean' }, + bluesky: { type: 'string' }, + }, + required: ['title', 'date', 'tags', 'description'], +} satisfies JsonSchema + +/** The content layer behind `/blog`, the plain-markdown routes and the feeds. */ +export const content = comarkContent({ + sources: { + blog: fsSource('./content/blog', { + prefix: '/blog', + schema: blogSchema, + cwd: import.meta.url, + }), + page: fsSource('./content', { + // top-level documents only: a nested file would produce a path that has + // no `pageMeta` entry and no `.md` route + exclude: ['*/**'], + cwd: import.meta.url, + }), + }, + plugins: [schemaValidation({ onError: isDevelopment ? 'warn' : 'throw' })], + markdown: { + plugins: [ + headingIds(), + // code blocks always render on a dark background, so both themes match + shiki({ + themes: { light: palenight, dark: palenight }, + }), + ], + }, +}) + +export default content diff --git a/modules/markdown.ts b/modules/markdown.ts index f10f7f19..f19c8401 100644 --- a/modules/markdown.ts +++ b/modules/markdown.ts @@ -1,47 +1,29 @@ -import { readFile } from 'node:fs/promises' -import { createRequire } from 'node:module' - -import { addTemplate, addTypeTemplate, defineNuxtModule, useNuxt } from 'nuxt/kit' -import { glob } from 'tinyglobby' -import grayMatter from 'gray-matter' -import { filename } from 'pathe/utils' -import { createMarkdownParser } from 'comark/parse' -import shiki from 'comark/plugins/shiki' -import palenight from 'shiki/themes/material-theme-palenight.mjs' - -import { createBuildCache, hashKey } from './shared/build-cache' -import { headingIds } from './shared/comark-heading-ids' +import { join } from 'node:path' +import { gzipSync } from 'node:zlib' + +import { addTemplate, addTypeTemplate, defineNuxtModule, updateTemplates, useNuxt } from 'nuxt/kit' +import { generateSourceTypes } from 'comark-content' +import type { ContentListFile } from 'comark-content' +import type { MarkdownDocument } from 'comark' +import type { ViteDevServer } from 'vite' + +import { blogDir, content } from '../content.config' +import type { ContentMeta } from '../content.config' import { serialize } from './shared/serialisers' import { mdCleanHtml, mdInternalLinks, mdStripContainers } from './shared/md-transforms' import { tidFromDate } from './shared/tid' +type ContentDocument = ContentListFile, ContentMeta> + /** - * Bumped whenever the shape of a cached syntax-highlighted tree changes; the - * resolved `comark`/`shiki` versions are folded in so a dependency bump - * invalidates the cache without needing to remember to touch this. + * `list()` types `meta` from the source, which knows nothing of the fields the + * `file:parsed` hook below adds to it. */ -const TREE_CACHE_VERSION = [ - '1', - ...['comark', 'shiki'].map(name => { - try { - return createRequire(import.meta.url)(`${name}/package.json`).version as string - } - catch { - return name - } - }), -].join('-') - -interface BlogFrontmatter { - title: string - date: string - tags: string[] - description: string - skip_dev?: boolean - bluesky?: string +function withDerivedMeta (files: Array>): ContentDocument[] { + return files as unknown as ContentDocument[] } -interface ParsedBlogPost { +interface BlogEntry { slug: string file: string title: string @@ -52,7 +34,78 @@ interface ParsedBlogPost { path: string skip_dev?: boolean bluesky?: string - body: string +} + +/** Content sources, named for the directory their body templates are written to. */ +const contentSources = ['blog', 'page'] as const +type ContentSource = typeof contentSources[number] + +// `remark`/`html-to-text` are only needed to render the feed and to feed the +// (production-only) sync providers, so both are loaded on demand +let markdownToHtml: ((markdown: string) => Promise) | undefined +async function toHtml (markdown: string) { + if (!markdownToHtml) { + const [{ remark }, { default: remarkHtml }] = await Promise.all([ + import('remark'), + import('remark-html'), + ]) + const md = remark().use(remarkHtml) + markdownToHtml = async source => String(await md.process(source)) + } + return markdownToHtml(markdown) +} + +/** + * Server components rendering content from the templates below. Nuxt's island + * HMR only fires when a component's own file changes, so they have to be told + * when the content behind them does. A component that renders content and is + * missing from this list will serve the previous body until the next reload. + */ +const contentIslands = ['StaticMarkdownRender', 'TheHome', 'TheBlogIndex'] + +function stripFrontmatter (raw: string): string { + return raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '') +} + +/** Frontmatter dates should be quoted, but an unquoted YAML date parses to a `Date`. */ +function isoString (value: string | Date): string { + return typeof value === 'string' ? value : value.toISOString() +} + +/** The dev-time `#content-manifest`, re-reading the manifest template as it changes. */ +function devManifestModule (path: string) { + return `import { readFileSync, statSync } from 'node:fs' + +const path = ${JSON.stringify(path)} +let documents, mtimeMs + +export function getDocuments () { + const stat = statSync(path) + if (!documents || stat.mtimeMs !== mtimeMs) { + mtimeMs = stat.mtimeMs + documents = JSON.parse(readFileSync(path, 'utf8')) + } + return documents +} +` +} + +/** + * The built `#content-manifest`, with the documents inlined. They are gzipped + * rather than embedded as JSON because nitro rewrites `import.meta.*` in module + * source and posts quote it in code samples. + */ +function inlineManifestModule (documents: ContentDocument[]) { + const encoded = gzipSync(JSON.stringify(documents)).toString('base64') + return `import { Buffer } from 'node:buffer' +import { gunzipSync } from 'node:zlib' + +const documents = JSON.parse(gunzipSync(Buffer.from(${JSON.stringify(encoded)}, 'base64')).toString('utf8')) + +export function getDocuments () { + return documents +} +` } export default defineNuxtModule({ @@ -61,50 +114,118 @@ export default defineNuxtModule({ }, async setup () { const nuxt = useNuxt() - const rootDir = nuxt.options.rootDir - const [blogFiles, pageFiles] = await Promise.all([ - glob('./content/blog/**/*.md', { cwd: rootDir, absolute: true }), - glob('./content/*.md', { cwd: rootDir, absolute: true }), - ]) + /** Serialised source text per blog post path, used to build the sync payload. */ + const bodies = new Map() + /** Derived text per path, keyed by the source text it was derived from. */ + const derived = new Map() + + // the plain-markdown routes and the feeds serve transformed source text + // rather than parsed nodes, so it travels with the document + content.hooks.hook('file:parsed', async ctx => { + const file = ctx.file + if (!file) return + + const raw = String(await ctx.source.getItem(`${file.meta.stem}${file.meta.extension}`)) + + // the hook fires once when the manifest is built and again when the body + // is parsed, so the transforms are memoised against the source text + let entry = derived.get(file.path) + if (entry?.raw !== raw) { + const body = serialize(stripFrontmatter(raw)) + entry = ctx.sourceName === 'blog' + ? { raw, markdown: mdInternalLinks(mdStripContainers(body)).trim(), html: await toHtml(body) } + : { raw, markdown: mdInternalLinks(mdCleanHtml(mdStripContainers(body))) } + derived.set(file.path, entry) + if (ctx.sourceName === 'blog') { + bodies.set(file.path, body) + } + } + + file.meta.markdown = entry.markdown + if (entry.html) { + file.meta.html = entry.html + } + }) + + await content.init({ partial: true }) + + let blogEntries: BlogEntry[] = [] + let documents: ContentDocument[] = [] + /** Parsed body nodes, keyed by `/`. Rebuilt on every collect. */ + const nodes = new Map() + + const slugsFor = (source: ContentSource) => + documents.filter(doc => doc.meta.source === source).map(doc => doc.meta.stem) + + async function collect () { + const [blog, pages] = await Promise.all([ + content.list(['blog']), + content.list(['page']), + ]) - const blogPosts: ParsedBlogPost[] = [] - const pageBodies: Record = {} - - for (const filePath of blogFiles) { - const raw = await readFile(filePath, 'utf-8') - const { data, content } = grayMatter(raw) - const slug = filename(filePath)! - const fm = data as BlogFrontmatter - const date = typeof fm.date === 'object' ? (fm.date as Date).toISOString() : fm.date - - blogPosts.push({ - slug, - file: filePath, - title: fm.title, - date, - tid: tidFromDate(date), - tags: fm.tags || [], - description: fm.description || '', - path: `/blog/${slug}`, - skip_dev: fm.skip_dev, - bluesky: fm.bluesky, - body: content, + blog.sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime()) + + blogEntries = blog.map(entry => { + const date = isoString(entry.data.date) + return { + slug: entry.meta.stem, + file: join(blogDir, `${entry.meta.stem}${entry.meta.extension}`), + title: entry.data.title, + date, + tid: tidFromDate(date), + tags: entry.data.tags, + description: entry.data.description, + path: entry.path, + skip_dev: entry.data.skip_dev, + bluesky: entry.data.bluesky, + } }) - } - for (const filePath of pageFiles) { - const raw = await readFile(filePath, 'utf-8') - const { content } = grayMatter(raw) - const slug = filename(filePath)! - pageBodies[slug] = content + documents = withDerivedMeta([...blog, ...pages]) + + const live = new Set(documents.map(doc => doc.path)) + for (const path of derived.keys()) { + if (!live.has(path)) { + derived.delete(path) + bodies.delete(path) + } + } + + nodes.clear() + await Promise.all(documents.map(async entry => { + const file = await content.get(entry.path) + nodes.set(`${entry.meta.source}/${entry.meta.stem}`, file?.nodes ?? []) + })) } - blogPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + await collect() + + async function getSyncArticles () { + const { convert: htmlToText } = await import('html-to-text') + return documents + .filter(doc => doc.meta.source === 'blog' && !doc.data.skip_dev) + .map(post => { + const body = bodies.get(post.path) + if (!body || !post.meta.html) { + throw new Error(`Refusing to sync \`${post.path}\`: no body was captured for it at build time.`) + } + return { + type: 'blog' as const, + title: post.data.title, + date: isoString(post.data.date), + description: post.data.description || '', + body_markdown: body, + text_content: htmlToText(post.meta.html, { wordwrap: false }), + canonical_url: `https://roe.dev${post.path}/`, + tags: post.data.tags.length ? post.data.tags : undefined, + } + }) + } nuxt.hook('modules:done', async () => { await Promise.all([ - nuxt.callHook('markdown:blog-entries', blogPosts), + nuxt.callHook('markdown:blog-entries', blogEntries), nuxt.callHook('markdown:sync-articles', getSyncArticles), ]) }) @@ -112,156 +233,122 @@ export default defineNuxtModule({ addTemplate({ filename: 'markdown/blog-entries.mjs', getContents: () => { - const entries = blogPosts.map(({ slug: _, file: __, body: ___, ...entry }) => entry) + const entries = blogEntries.map(({ slug: _, file: __, ...entry }) => entry) return `export const blogEntries = ${JSON.stringify(entries)}` }, write: true, }) - const parse = createMarkdownParser({ - plugins: [ - headingIds(), - // code blocks always render on a dark background, so both themes match - shiki({ - themes: { light: palenight, dark: palenight }, - }), - ], - }) - - const treeCache = createBuildCache('markdown-trees') - async function parseCached (body: string) { - const key = hashKey(TREE_CACHE_VERSION, body) - const cached = await treeCache.get(key) - if (cached) return cached.value - const tree = await parse(body) - await treeCache.set(key, tree) - return tree - } - - function addBodyTemplate (filename: string, tree: unknown) { - addTemplate({ - filename, - getContents: () => `const tree = ${JSON.stringify(tree)} + /** + * One template per document body, plus an index mapping slugs to their + * loaders. Templates are registered for documents that appear after setup + * too, or the regenerated index would import a file that was never written. + */ + const registeredBodies = new Set() + function registerBodyTemplates () { + for (const source of contentSources) { + for (const slug of slugsFor(source)) { + const filename = `markdown/${source}/${slug}.mjs` + if (registeredBodies.has(filename)) continue + registeredBodies.add(filename) + + addTemplate({ + filename, + getContents: () => `const nodes = ${JSON.stringify(nodes.get(`${source}/${slug}`) ?? [])} export async function getBody () { - return tree + return { nodes } } `, + write: true, + }) + } + } + } + + registerBodyTemplates() + + for (const source of contentSources) { + addTemplate({ + filename: `markdown/${source}/index.mjs`, + getContents: () => { + const slugs = slugsFor(source) + const imports = slugs.map((slug, i) => `import { getBody as getBody${i} } from './${slug}.mjs'`) + const loaders = slugs.map((slug, i) => ` '${slug}': getBody${i},`) + return `${imports.join('\n')}\n\nexport const ${source}BodyLoaders = {\n${loaders.join('\n')}\n}\n` + }, write: true, }) } - await Promise.all([ - ...blogPosts.map(async post => { - addBodyTemplate(`markdown/blog/${post.slug}.mjs`, await parseCached(post.body)) - }), - ...Object.entries(pageBodies).map(async ([slug, body]) => { - addBodyTemplate(`markdown/page/${slug}.mjs`, await parseCached(body)) - }), - ]) + const manifestPath = join(nuxt.options.buildDir, 'markdown/manifest.json') - addTemplate({ - filename: 'markdown/blog/index.mjs', - getContents: () => { - const imports = blogPosts.map( - (p, i) => `import { getBody as getBody${i} } from './${p.slug}.mjs'`, - ).join('\n') - const entries = blogPosts.map( - (p, i) => ` '${p.slug}': getBody${i},`, - ).join('\n') - return `${imports}\n\nexport const blogBodyLoaders = {\n${entries}\n}\n` - }, - write: true, - }) + nuxt.options.nitro.virtual ||= {} + // in dev the manifest is read back from disk so that a content change is a + // template write rather than a rebuild of the server bundle + nuxt.options.nitro.virtual['#content-manifest'] = () => nuxt.options.dev + ? devManifestModule(manifestPath) + : inlineManifestModule(documents) - addTemplate({ - filename: 'markdown/page/index.mjs', - getContents: () => { - const slugs = Object.keys(pageBodies) - const imports = slugs.map( - (slug, i) => `import { getBody as getBody${i} } from './${slug}.mjs'`, - ).join('\n') - const entries = slugs.map( - (slug, i) => ` '${slug}': getBody${i},`, - ).join('\n') - return `${imports}\n\nexport const pageBodyLoaders = {\n${entries}\n}\n` - }, - write: true, - }) + nuxt.options.nitro.externals ||= {} + nuxt.options.nitro.externals.inline ||= [] + nuxt.options.nitro.externals.inline.push('#content-manifest') - // `remark`/`html-to-text` are only needed to render the feed and to feed - // the (production-only) sync providers, so both are loaded on demand - let markdownToHtml: ((markdown: string) => Promise) | undefined - async function toHtml (markdown: string) { - if (!markdownToHtml) { - const [{ remark }, { default: remarkHtml }] = await Promise.all([ - import('remark'), - import('remark-html'), - ]) - const md = remark().use(remarkHtml) - markdownToHtml = async source => String(await md.process(source)) - } - return markdownToHtml(markdown) - } + if (nuxt.options.dev) { + addTemplate({ + filename: 'markdown/manifest.json', + getContents: () => JSON.stringify(documents), + write: true, + }) - nuxt.options.nitro.virtual ||= {} - nuxt.options.nitro.virtual['#metadata.json'] = async () => { - const rssMetadata: Record = {} - for (const post of blogPosts) { - const date = new Date(post.date) - rssMetadata[post.slug] = { - title: post.title, - description: post.description, - tags: post.tags, - html: await toHtml(serialize(post.body)), - date: `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`, - } - } - return `export const metadata = ${JSON.stringify(rssMetadata)}` - } + let vite: ViteDevServer | undefined + nuxt.hook('vite:serverCreated', (server, env) => { + if (env.isClient) vite = server + }) - async function getSyncArticles () { - const { convert: htmlToText } = await import('html-to-text') - return Promise.all(blogPosts - .filter(p => !p.skip_dev) - .map(async post => { - const body = serialize(post.body) - const html = await toHtml(body) - return { - type: 'blog' as const, - title: post.title, - date: post.date, - description: post.description || '', - body_markdown: body, - text_content: htmlToText(html, { wordwrap: false }), - canonical_url: `https://roe.dev/blog/${post.slug}/`, - tags: post.tags.length ? post.tags : undefined, + const unwatch = await content.watch() + + // a single save can surface as more than one watcher event + let pending: ReturnType | undefined + const refresh = () => { + clearTimeout(pending) + pending = setTimeout(async () => { + try { + await collect() + registerBodyTemplates() + await updateTemplates({ + filter: template => !!template.filename?.startsWith('markdown/'), + }) + for (const name of contentIslands) { + vite?.hot.send({ type: 'custom', event: `nuxt-server-component:${name}` }) + } } - })) - } + catch (error) { + // a throw here would take the dev server down with an unhandled + // rejection, and the next save may well fix whatever it was + console.error('[markdown] could not reload content', error) + } + }, 50) + } + + content.hooks.hook('watch:file:update', refresh) + content.hooks.hook('watch:file:remove', refresh) - const rawBlogData = blogPosts.map(post => ({ - slug: post.slug, - title: post.title, - date: typeof post.date === 'string' ? post.date.split('T')[0] : post.date, - tags: post.tags, - description: post.description, - body: mdInternalLinks(mdStripContainers(serialize(post.body))).trim(), - })) - - nuxt.options.nitro.virtual['#md-raw-blog.json'] = () => - `export const rawBlogPosts = ${JSON.stringify(rawBlogData)}` - - const rawPageData: Record = {} - for (const [slug, body] of Object.entries(pageBodies)) { - rawPageData[slug] = mdInternalLinks(mdCleanHtml(mdStripContainers(serialize(body)))) + nuxt.hook('close', async () => { + clearTimeout(pending) + await unwatch() + }) } - nuxt.options.nitro.virtual['#md-raw-pages.json'] = () => - `export const rawPages = ${JSON.stringify(rawPageData)}` + addTypeTemplate({ + filename: 'types/content.d.ts', + getContents: () => generateSourceTypes(content), + }, { nuxt: true, nitro: true }) - nuxt.options.nitro.externals ||= {} - nuxt.options.nitro.externals.inline ||= [] - nuxt.options.nitro.externals.inline.push('#metadata.json', '#md-raw-blog.json', '#md-raw-pages.json') + // this module reads the sources it generates types for, so it needs the + // registry augmentation in the node project too + nuxt.hook('prepare:types', ({ nodeReferences }) => { + nodeReferences.push({ path: join(nuxt.options.buildDir, 'types/content.d.ts') }) + }) addTypeTemplate({ filename: 'types/markdown.d.ts', @@ -282,28 +369,16 @@ declare module '#build/markdown/blog-entries.mjs' { declare module '#build/markdown/blog/index.mjs' { import type { MarkdownDocument } from 'comark' - export const blogBodyLoaders: Record Promise> + export const blogBodyLoaders: Record Promise>> } declare module '#build/markdown/page/index.mjs' { import type { MarkdownDocument } from 'comark' - export const pageBodyLoaders: Record Promise> -} - -declare module '#md-raw-blog.json' { - interface RawBlogPost { - slug: string - title: string - date: string - tags: string[] - description: string - body: string - } - export const rawBlogPosts: RawBlogPost[] + export const pageBodyLoaders: Record Promise>> } -declare module '#md-raw-pages.json' { - export const rawPages: Record +declare module '#content-manifest' { + export function getDocuments (): Array> } declare module '#md-page-meta.json' { diff --git a/modules/md-routes/runtime/server/blog-md.get.ts b/modules/md-routes/runtime/server/blog-md.get.ts index 1bf5aea3..e0bd2b05 100644 --- a/modules/md-routes/runtime/server/blog-md.get.ts +++ b/modules/md-routes/runtime/server/blog-md.get.ts @@ -1,33 +1,23 @@ -import { rawBlogPosts } from '#md-raw-blog.json' - export default defineEventHandler(event => { const url = getRequestURL(event) const match = url.pathname.match(/\/blog\/(.+)\.md$/) const slug = match?.[1] - const post = slug ? rawBlogPosts.find(p => p.slug === slug) : undefined + const post = slug + ? blogPosts().find(p => p.meta.stem === slug) + : undefined if (!post) { throw createError({ statusCode: 404, statusMessage: 'Not found' }) } - const tagStr = post.tags.length - ? `tags: [${post.tags.join(', ')}]` - : 'tags: []' - - const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + const { title, date, tags, description } = post.data const md = [ - '---', - `title: "${esc(post.title)}"`, - `date: ${post.date}`, - tagStr, - `description: "${esc(post.description)}"`, - `url: https://roe.dev/blog/${post.slug}`, - '---', + mdFrontmatter(post.path, { title, description, date: isoDate(date), tags }), '', - post.body, + post.meta.markdown, '', ].join('\n') - return mdResponse(md) + return mdResponse(event, md) }) diff --git a/nuxt.config.ts b/nuxt.config.ts index b57397a9..d2f68cee 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -9,6 +9,30 @@ import { isTest } from 'std-env' import type { HmrOptions } from 'vite' import { pageMeta } from './modules/shared/page-meta' +/** + * Rendered routes served from the SWR cache. Applied outside development only: + * nitro persists SWR responses to `.nuxt/cache`, so in dev an edit to content + * or a component stays invisible until the cached entry expires. Tests opt in + * so they cover what the cache does to a handler's return value. + */ +const renderedSwrRules: Record = { + ...Object.fromEntries(Object.keys(pageMeta).flatMap(path => [ + [path, { swr: 60 * 60 }], + [path + '/_payload.json', { swr: 60 * 60 }], + [path + '.md', { swr: 60 * 60 }], + ])), + '/blog/**': { swr: 60 * 60 }, +} + +/** API routes served from the SWR cache, in dev too, to spare the upstreams. */ +const apiSwrRules: Record = { + '/api/talks': { swr: 60 * 60 }, + '/api/upcoming-conferences': { swr: 60 * 60 }, + '/api/uses': { swr: 60 * 60 }, + '/api/projects': { swr: 60 * 60 }, + '/api/current-location': { swr: 60 * 5 }, +} + export default defineNuxtConfig({ modules: [ 'nuxt-og-image', @@ -50,6 +74,7 @@ export default defineNuxtConfig({ $production: { modules: ['nuxt-security'], + routeRules: renderedSwrRules, }, $test: { @@ -60,6 +85,7 @@ export default defineNuxtConfig({ }) }, ], + routeRules: renderedSwrRules, experimental: { componentIslands: true, }, @@ -159,19 +185,8 @@ export default defineNuxtConfig({ }, routeRules: { - ...Object.fromEntries(Object.keys(pageMeta).flatMap(path => [ - [path, { swr: 60 * 60 }], - [path + '/_payload.json', { swr: 60 * 60 }], - [path + '.md', { swr: 60 * 60 }], - ])), + ...apiSwrRules, '/admin/**': { prerender: false }, - '/blog/**': { swr: 60 * 60 }, - // api routes - '/api/talks': { swr: 60 * 60 }, - '/api/upcoming-conferences': { swr: 60 * 60 }, - '/api/uses': { swr: 60 * 60 }, - '/api/projects': { swr: 60 * 60 }, - '/api/current-location': { swr: 60 * 5 }, '/api/sponsors': { prerender: true }, '/api/hi': { cors: true }, // redirects diff --git a/package.json b/package.json index 7a23098e..d32704a0 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,10 @@ "@vercel/blob": "^2.6.1", "citty": "^0.2.2", "comark": "0.6.2", + "comark-content": "0.3.0", "defu": "^6.1.7", "exsolve": "^1.1.1", "feed": "^6.0.0", - "gray-matter": "4.0.3", "gsap": "^3.15.0", "h3": "^1.15.11", "image-meta": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf4b230a..c95d7302 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: comark: specifier: 0.6.2 version: 0.6.2(shiki@4.4.3) + comark-content: + specifier: 0.3.0 + version: 0.3.0(@vercel/blob@2.8.0)(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(shiki@4.4.3) defu: specifier: ^6.1.7 version: 6.1.7 @@ -96,9 +99,6 @@ importers: feed: specifier: ^6.0.0 version: 6.0.0 - gray-matter: - specifier: 4.0.3 - version: 4.0.3 gsap: specifier: ^3.15.0 version: 3.15.0 @@ -3576,9 +3576,6 @@ packages: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3869,6 +3866,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comark-content@0.3.0: + resolution: {integrity: sha512-t+gq902R8AorpCqAAa3TgDpFc+cimeNt4z41KcKWTReY7IU4ezkPeUaeGHRzS9f0jmBTtQOPSbhx0CzcddjfIQ==} + hasBin: true + comark@0.6.2: resolution: {integrity: sha512-hlWa7KlGPfRxovQavnHZlLlTUKrfwE7/o4ewCY0FzqFtRvVT8VdslrumQwELIiK9utsurpoSWCBFj1yVMtDOtw==} peerDependencies: @@ -4463,11 +4464,6 @@ packages: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -4537,10 +4533,6 @@ packages: exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} - extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -4825,10 +4817,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} - gsap@3.15.0: resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} @@ -5067,10 +5055,6 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true - is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} - engines: {node: '>=0.10.0'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5189,10 +5173,6 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.15.1: - resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} - hasBin: true - js-yaml@4.3.1: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true @@ -6589,10 +6569,6 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} - section-matter@1.0.0: - resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} - engines: {node: '>=4'} - selderee@0.12.0: resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==} @@ -6709,6 +6685,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + smob@1.6.2: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} @@ -6755,9 +6735,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - srvx@0.12.7: resolution: {integrity: sha512-PIaq1pDGg3EU2OGSN3pzRfYVu9MJDzLdojlGN6E3lvhAdxqn/lNMJFMA1xGA38jYkBb0V9xw02QEVVMDb0PExA==} engines: {node: '>=20.16.0'} @@ -6826,10 +6803,6 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} - strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -11328,10 +11301,6 @@ snapshots: are-docs-informative@0.0.2: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} args-tokenizer@0.3.0: {} @@ -11594,6 +11563,43 @@ snapshots: colorette@2.0.20: {} + comark-content@0.3.0(@vercel/blob@2.8.0)(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(shiki@4.4.3): + dependencies: + citty: 0.2.2 + comark: 0.6.2(shiki@4.4.3) + hookable: 6.1.1 + jiti: 2.7.0 + ofetch: 1.5.1 + pathe: 2.0.3 + picomatch: 4.0.7 + slugify: 1.6.9 + ufo: 1.6.4 + unstorage: 1.17.5(@vercel/blob@2.8.0)(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - beautiful-mermaid + - db0 + - idb-keyval + - ioredis + - katex + - rangi + - shiki + - uploadthing + comark@0.6.2(shiki@4.4.3): dependencies: entities: 8.0.0 @@ -12245,8 +12251,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -12315,10 +12319,6 @@ snapshots: exsolve@1.1.1: {} - extend-shallow@2.0.1: - dependencies: - is-extendable: 0.1.1 - extend@3.0.2: {} fake-indexeddb@6.2.5: {} @@ -12657,13 +12657,6 @@ snapshots: graceful-fs@4.2.11: {} - gray-matter@4.0.3: - dependencies: - js-yaml: 3.15.1 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 - gsap@3.15.0: {} gtoken@8.0.0(supports-color@10.2.2): @@ -12921,8 +12914,6 @@ snapshots: is-docker@3.0.0: {} - is-extendable@0.1.1: {} - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -13019,11 +13010,6 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@3.15.1: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -14860,11 +14846,6 @@ snapshots: scule@1.3.0: {} - section-matter@1.0.0: - dependencies: - extend-shallow: 2.0.1 - kind-of: 6.0.3 - selderee@0.12.0: dependencies: parseley: 0.13.1 @@ -15060,6 +15041,8 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slugify@1.6.9: {} + smob@1.6.2: {} sonic-boom@4.2.1: @@ -15094,8 +15077,6 @@ snapshots: split2@4.2.0: {} - sprintf-js@1.0.3: {} - srvx@0.12.7: {} stable-hash-x@0.2.0: {} @@ -15170,8 +15151,6 @@ snapshots: dependencies: ansi-regex: 6.3.0 - strip-bom-string@1.0.0: {} - strip-final-newline@2.0.0: {} strip-final-newline@3.0.0: {} diff --git a/server/routes/ai.md.get.ts b/server/routes/ai.md.get.ts index acf77fa5..270baaf9 100644 --- a/server/routes/ai.md.get.ts +++ b/server/routes/ai.md.get.ts @@ -1,13 +1 @@ -import { rawPages } from '#md-raw-pages.json' -import { pageMeta } from '#md-page-meta.json' - -export default defineEventHandler(() => { - const md = [ - mdFrontmatter('/ai', pageMeta['/ai']!), - '', - rawPages['ai'] || '', - '', - ].join('\n') - - return mdResponse(md) -}) +export default defineEventHandler(event => contentPageResponse(event, '/ai')) diff --git a/server/routes/bio.md.get.ts b/server/routes/bio.md.get.ts index 30a0b6b3..ef6a973f 100644 --- a/server/routes/bio.md.get.ts +++ b/server/routes/bio.md.get.ts @@ -1,13 +1 @@ -import { rawPages } from '#md-raw-pages.json' -import { pageMeta } from '#md-page-meta.json' - -export default defineEventHandler(() => { - const md = [ - mdFrontmatter('/bio', pageMeta['/bio']!), - '', - rawPages['bio'] || '', - '', - ].join('\n') - - return mdResponse(md) -}) +export default defineEventHandler(event => contentPageResponse(event, '/bio')) diff --git a/server/routes/blog.md.get.ts b/server/routes/blog.md.get.ts index 679c1338..4cf496e7 100644 --- a/server/routes/blog.md.get.ts +++ b/server/routes/blog.md.get.ts @@ -1,20 +1,19 @@ -import { rawBlogPosts } from '#md-raw-blog.json' import { pageMeta } from '#md-page-meta.json' -export default defineEventHandler(() => { +export default defineEventHandler(event => { const lines = [ mdFrontmatter('/blog', pageMeta['/blog']!), '', ] - for (const post of rawBlogPosts) { - lines.push(`- [${post.title}](https://roe.dev/blog/${post.slug}.md) — ${post.date}`) - if (post.description) { - lines.push(` ${post.description}`) + for (const post of blogPosts()) { + lines.push(blogListItem(post)) + if (post.data.description) { + lines.push(` ${post.data.description}`) } } lines.push('') - return mdResponse(lines.join('\n')) + return mdResponse(event, lines.join('\n')) }) diff --git a/server/routes/index.md.get.ts b/server/routes/index.md.get.ts index 99bfa504..7de86232 100644 --- a/server/routes/index.md.get.ts +++ b/server/routes/index.md.get.ts @@ -1,10 +1,9 @@ -import { rawBlogPosts } from '#md-raw-blog.json' import { pageMeta } from '#md-page-meta.json' import { links } from '#shared/utils/links' -export default defineEventHandler(async () => { +export default defineEventHandler(async event => { if (import.meta.test) { - return mdResponse('') + return mdResponse(event, '') } const [talks, upcomingConferences] = await Promise.all([ @@ -24,7 +23,7 @@ export default defineEventHandler(async () => { .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) .slice(0, 4) - const recentPosts = rawBlogPosts.slice(0, 4) + const recentPosts = blogPosts().slice(0, 4) const lines = [ mdFrontmatter('/', pageMeta['/']!), @@ -71,7 +70,7 @@ export default defineEventHandler(async () => { lines.push('## Latest from the Blog') lines.push('') for (const post of recentPosts) { - lines.push(`- [${post.title}](https://roe.dev/blog/${post.slug}.md) — ${post.date}`) + lines.push(blogListItem(post)) } lines.push('') lines.push('[More articles](https://roe.dev/blog.md)') @@ -87,5 +86,5 @@ export default defineEventHandler(async () => { lines.push('My open source work is supported by sponsors. [More about how I\'m funded](https://roe.dev/blog/funding.md).') lines.push('') - return mdResponse(lines.join('\n')) + return mdResponse(event, lines.join('\n')) }) diff --git a/server/routes/llms-full.txt.get.ts b/server/routes/llms-full.txt.get.ts index 638659dd..49252de1 100644 --- a/server/routes/llms-full.txt.get.ts +++ b/server/routes/llms-full.txt.get.ts @@ -1,5 +1,3 @@ -import { rawBlogPosts } from '#md-raw-blog.json' -import { rawPages } from '#md-raw-pages.json' import { links } from '#shared/utils/links' export default defineEventHandler(async () => { @@ -12,6 +10,10 @@ export default defineEventHandler(async () => { $fetch('/api/upcoming-conferences').catch(() => [] as Conference[]), ]) + const bio = contentPage('/bio') + const ai = contentPage('/ai') + const posts = blogPosts() + const lines = [ '# Daniel Roe', '', @@ -21,13 +23,13 @@ export default defineEventHandler(async () => { '', '## Bio', '', - rawPages['bio'] || '', + bio?.meta.markdown || '', '', '---', '', '## AI Policy', '', - rawPages['ai'] || '', + ai?.meta.markdown || '', '', '---', '', @@ -74,17 +76,17 @@ export default defineEventHandler(async () => { // All blog posts with descriptions lines.push('## Blog Posts') lines.push('') - for (const post of rawBlogPosts) { - lines.push(`### ${post.title}`) + for (const post of posts) { + lines.push(`### ${post.data.title}`) lines.push('') - lines.push(`- **Date:** ${post.date}`) - lines.push(`- **URL:** https://roe.dev/blog/${post.slug}.md`) - if (post.tags.length) { - lines.push(`- **Tags:** ${post.tags.join(', ')}`) + lines.push(`- **Date:** ${isoDate(post.data.date)}`) + lines.push(`- **URL:** https://roe.dev${post.path}.md`) + if (post.data.tags.length) { + lines.push(`- **Tags:** ${post.data.tags.join(', ')}`) } - if (post.description) { + if (post.data.description) { lines.push('') - lines.push(post.description) + lines.push(post.data.description) } lines.push('') } diff --git a/server/routes/llms.txt.get.ts b/server/routes/llms.txt.get.ts index 28255b2e..4339de9b 100644 --- a/server/routes/llms.txt.get.ts +++ b/server/routes/llms.txt.get.ts @@ -1,4 +1,3 @@ -import { rawBlogPosts } from '#md-raw-blog.json' import { pageMeta } from '#md-page-meta.json' import { links } from '#shared/utils/links' @@ -9,7 +8,7 @@ export default defineEventHandler(async () => { const upcomingConferences = await $fetch('/api/upcoming-conferences').catch(() => [] as Conference[]) - const recentPosts = rawBlogPosts.slice(0, 5) + const recentPosts = blogPosts().slice(0, 5) const pageIndex = Object.entries(pageMeta) .filter(([, meta]) => meta.llmLabel) @@ -48,7 +47,7 @@ export default defineEventHandler(async () => { lines.push('## Recent Blog Posts') lines.push('') for (const post of recentPosts) { - lines.push(`- [${post.title}](https://roe.dev/blog/${post.slug}.md) — ${post.date}`) + lines.push(blogListItem(post)) } lines.push('') } diff --git a/server/routes/projects.md.get.ts b/server/routes/projects.md.get.ts index 299aefb2..b7b4ca42 100644 --- a/server/routes/projects.md.get.ts +++ b/server/routes/projects.md.get.ts @@ -4,7 +4,7 @@ import { getProjects } from '../utils/cms/projects' export default defineEventHandler(async event => { if (import.meta.test) { - return mdResponse('') + return mdResponse(event, '') } const categories = await getProjects(event) @@ -33,5 +33,5 @@ export default defineEventHandler(async event => { lines.push('') } - return mdResponse(lines.join('\n')) + return mdResponse(event, lines.join('\n')) }) diff --git a/server/routes/rss.xml.get.ts b/server/routes/rss.xml.get.ts index 75edf912..8e669d3d 100644 --- a/server/routes/rss.xml.get.ts +++ b/server/routes/rss.xml.get.ts @@ -1,8 +1,5 @@ import { Feed } from 'feed' -// @ts-expect-error virtual file -import { metadata } from '#metadata.json' - export default defineEventHandler(async () => { if (!import.meta.dev && !import.meta.prerender) return @@ -27,14 +24,19 @@ export default defineEventHandler(async () => { }, }) - for (const slug in metadata) { - const blog = metadata[slug] + for (const post of blogPosts()) { + const published = new Date(post.data.date) + + if (!post.meta.html) { + throw createError(`Cannot render the feed: no HTML body was built for \`${post.path}\`.`) + } + feed.addItem({ - title: blog.title, - link: `https://roe.dev/blog/${slug}`, - description: blog.description, - content: blog.html.replace(/ ({ name: tag })), + title: post.data.title, + link: `https://roe.dev${post.path}`, + description: post.data.description, + content: post.meta.html.replace(/ ({ name: tag })), author: [ { name: 'Daniel Roe', @@ -42,8 +44,9 @@ export default defineEventHandler(async () => { link: 'https://roe.dev', }, ], - date: new Date(blog.date), - image: `https://roe.dev/_og/s/blog/${slug}/og.png`, + // feed items are dated to local midnight rather than the time of day the post was published + date: new Date(published.getFullYear(), published.getMonth(), published.getDate()), + image: `https://roe.dev/_og/s${post.path}/og.png`, }) } diff --git a/server/routes/talks.md.get.ts b/server/routes/talks.md.get.ts index bedfe46e..af5164dd 100644 --- a/server/routes/talks.md.get.ts +++ b/server/routes/talks.md.get.ts @@ -1,8 +1,8 @@ import { pageMeta } from '#md-page-meta.json' -export default defineEventHandler(async () => { +export default defineEventHandler(async event => { if (import.meta.test) { - return mdResponse('') + return mdResponse(event, '') } const talks = await $fetch('/api/talks') @@ -68,5 +68,5 @@ export default defineEventHandler(async () => { lines.push('') } - return mdResponse(lines.join('\n')) + return mdResponse(event, lines.join('\n')) }) diff --git a/server/routes/uses.md.get.ts b/server/routes/uses.md.get.ts index 19cf84a5..15f99f11 100644 --- a/server/routes/uses.md.get.ts +++ b/server/routes/uses.md.get.ts @@ -4,7 +4,7 @@ import { getUses } from '../utils/cms/uses' export default defineEventHandler(async event => { if (import.meta.test) { - return mdResponse('') + return mdResponse(event, '') } const categories = await getUses(event) @@ -36,5 +36,5 @@ export default defineEventHandler(async event => { lines.push('') } - return mdResponse(lines.join('\n')) + return mdResponse(event, lines.join('\n')) }) diff --git a/server/utils/content.ts b/server/utils/content.ts new file mode 100644 index 00000000..316fba0e --- /dev/null +++ b/server/utils/content.ts @@ -0,0 +1,48 @@ +import type { ContentListFile, SourceData } from 'comark-content' + +import type { ContentMeta } from '../../content.config' +import { getDocuments } from '#content-manifest' + +export type BlogPost = ContentListFile, ContentMeta> +export type ContentPage = ContentListFile, ContentMeta> +type ContentDocument = BlogPost | ContentPage + +/** + * Documents are parsed at build time and travel as the manifest the CMS built + * from them, so reads here are lookups rather than parses, and the markdown + * parser stays out of the server bundle. + * + * The manifest is immutable in production and replaced wholesale when it + * changes in development, so the derived views are memoised against it. + */ +let source: ContentDocument[] | undefined +let views: { posts: BlogPost[], pages: Map } + +function indexed () { + const documents = getDocuments() as ContentDocument[] + if (documents !== source) { + source = documents + views = { + posts: documents.filter((doc): doc is BlogPost => doc.meta.source === 'blog'), + pages: new Map(documents + .filter((doc): doc is ContentPage => doc.meta.source === 'page') + .map(doc => [doc.path, doc])), + } + } + return views +} + +/** Blog posts, newest first. */ +export function blogPosts (): BlogPost[] { + return indexed().posts +} + +/** A single content page (`/ai`, `/bio`), or `undefined` if there is no such page. */ +export function contentPage (path: string): ContentPage | undefined { + return indexed().pages.get(path) +} + +/** A `- [title](url) — date` line, as used by the markdown listings and `llms.txt`. */ +export function blogListItem (post: BlogPost): string { + return `- [${post.data.title}](${SITE_URL}${post.path}.md) — ${isoDate(post.data.date)}` +} diff --git a/server/utils/md.ts b/server/utils/md.ts index a39e591b..08287721 100644 --- a/server/utils/md.ts +++ b/server/utils/md.ts @@ -1,12 +1,20 @@ -const SITE_URL = 'https://roe.dev' +import type { H3Event } from 'h3' + +import { pageMeta } from '#md-page-meta.json' function yamlEscape (str: string): string { return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') } -export function mdFrontmatter (path: string, meta: { title: string, description?: string }): string { +export function mdFrontmatter (path: string, meta: { title: string, description?: string, date?: string, tags?: string[] }): string { const lines = ['---'] lines.push(`title: "${yamlEscape(meta.title)}"`) + if (meta.date) { + lines.push(`date: ${meta.date}`) + } + if (meta.tags) { + lines.push(`tags: [${meta.tags.map(tag => `"${yamlEscape(tag)}"`).join(', ')}]`) + } if (meta.description) { lines.push(`description: "${yamlEscape(meta.description)}"`) } @@ -15,10 +23,31 @@ export function mdFrontmatter (path: string, meta: { title: string, description? return lines.join('\n') } -export function mdResponse (content: string): Response { - return new Response(content, { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - }) +/** + * Serve markdown as the response body. + * + * The body has to be a string: these routes are `swr` cached and nitro stores + * whatever the handler returns, so a `Response` round-trips through the cache + * as `{}` and every request after the first serves that instead of the document. + */ +export function mdResponse (event: H3Event, content: string): string { + setResponseHeader(event, 'content-type', 'text/markdown; charset=utf-8') + return content +} + +/** Serve a content page (`/ai`, `/bio`) as plain markdown. */ +export function contentPageResponse (event: H3Event, path: string): string { + const page = contentPage(path) + if (!page) { + throw createError({ statusCode: 404, statusMessage: 'Not found' }) + } + + return mdResponse(event, [ + mdFrontmatter(path, pageMeta[path]!), + '', + page.meta.markdown, + '', + ].join('\n')) } export function formatDate (dateStr: string): string { diff --git a/server/utils/site.ts b/server/utils/site.ts new file mode 100644 index 00000000..40bb2a01 --- /dev/null +++ b/server/utils/site.ts @@ -0,0 +1,6 @@ +export const SITE_URL = 'https://roe.dev' + +/** The `YYYY-MM-DD` portion of an ISO date, as used in markdown frontmatter and listings. */ +export function isoDate (value: string | Date): string { + return (typeof value === 'string' ? value : value.toISOString()).slice(0, 10) +} diff --git a/test/unit/behaviour.spec.ts b/test/unit/behaviour.spec.ts index 258cb975..541cd327 100644 --- a/test/unit/behaviour.spec.ts +++ b/test/unit/behaviour.spec.ts @@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { createPage, setup, url } from '@nuxt/test-utils/e2e' +import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e' await setup({ rootDir: fileURLToPath(new URL('../..', import.meta.url)), @@ -67,4 +67,21 @@ describe('site behaviour', { timeout: 10000 }, () => { expect(await page.locator('section p').count()).toBeGreaterThan(0) await page.close() }) + + it('serves the same markdown on a cached second request', async () => { + const expectations = { + '/bio.md': 'Daniel leads the [Nuxt core team](https://nuxt.com)', + '/blog.md': '- [Little oak](https://roe.dev/blog/little-oak.md) — 2024-04-26', + '/blog/little-oak.md': 'a little twig poked out of the ground', + } + + for (const [route, body] of Object.entries(expectations)) { + const first = await $fetch(route) + const second = await $fetch(route) + + expect(first).toContain('title: "') + expect(first).toContain(body) + expect(second).toBe(first) + } + }) }) diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index 0f3d1887..f326fb07 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -77,7 +77,7 @@ describe('project sizes', () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes, 10)) - .toMatchInlineSnapshot(`"1980k"`) + .toMatchInlineSnapshot(`"1930k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) const portableModules = await analyzeSizes( diff --git a/test/unit/sync-items.spec.ts b/test/unit/sync-items.spec.ts index 0f5cd62e..8ee329a3 100644 --- a/test/unit/sync-items.spec.ts +++ b/test/unit/sync-items.spec.ts @@ -1,45 +1,32 @@ -import { readFile } from 'node:fs/promises' -import process from 'node:process' import { describe, it, expect } from 'vitest' -import grayMatter from 'gray-matter' -import { filename } from 'pathe/utils' -import { glob } from 'tinyglobby' -describe('blog content parsing', () => { - it('all blog posts have required frontmatter', async () => { - const files = await glob('./content/blog/**/*.md', { cwd: process.cwd(), absolute: true }) - expect(files.length).toBeGreaterThan(0) +import { content } from '../../content.config' + +const posts = await content.list(['blog']) - for (const file of files) { - const raw = await readFile(file, 'utf-8') - const { data } = grayMatter(raw) - const slug = filename(file) +describe('blog content parsing', () => { + it('all blog posts have required frontmatter', () => { + expect(posts.length).toBeGreaterThan(0) - expect(data.title, `${slug} missing title`).toBeTruthy() - expect(data.date, `${slug} missing date`).toBeTruthy() - expect(Array.isArray(data.tags), `${slug} tags should be an array`).toBe(true) + for (const { path, data } of posts) { + expect(data.title, `${path} missing title`).toBeTruthy() + expect(data.date, `${path} missing date`).toBeTruthy() + expect(Array.isArray(data.tags), `${path} tags should be an array`).toBe(true) } }) - it('blog post dates are valid', async () => { - const files = await glob('./content/blog/**/*.md', { cwd: process.cwd(), absolute: true }) + it('blog post dates are valid', () => { + for (const { path, data } of posts) { + const date = new Date(data.date as string) - for (const file of files) { - const raw = await readFile(file, 'utf-8') - const { data } = grayMatter(raw) - const slug = filename(file) - const date = new Date(data.date) - - expect(date.toString(), `${slug} has invalid date: ${data.date}`).not.toBe('Invalid Date') + expect(date.toString(), `${path} has invalid date: ${data.date}`).not.toBe('Invalid Date') } }) - it('blog post slugs form valid paths', async () => { - const files = await glob('./content/blog/**/*.md', { cwd: process.cwd(), absolute: true }) - - for (const file of files) { - const slug = filename(file) - expect(slug, `${file} has no slug`).toBeTruthy() + it('blog post slugs form valid paths', () => { + for (const { path, meta } of posts) { + const slug = meta.stem + expect(slug, `${path} has no slug`).toBeTruthy() expect(slug).toMatch(/^[a-z0-9-]+$/) } }) -- 2.51.2