From 7c76cae433475ad51918fe1d07a95eeef7e785c4 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 20 Feb 2026 16:19:59 +0000 Subject: [PATCH] perf: move markdown transforms to build-time (#1829) --- modules/markdown.ts | 22 ++---------- .../md-routes/runtime/server/blog-md.get.ts | 2 +- modules/shared/md-transforms.ts | 30 ++++++++++++++++ server/routes/ai.md.get.ts | 4 +-- server/routes/bio.md.get.ts | 4 +-- server/routes/llms-full.txt.get.ts | 4 +-- server/utils/md.ts | 36 ------------------- test/unit/bundle.spec.ts | 2 +- 8 files changed, 39 insertions(+), 65 deletions(-) create mode 100644 modules/shared/md-transforms.ts diff --git a/modules/markdown.ts b/modules/markdown.ts index e6d06e75..677e8b5d 100644 --- a/modules/markdown.ts +++ b/modules/markdown.ts @@ -9,6 +9,7 @@ import remarkHtml from 'remark-html' import { convert as htmlToText } from 'html-to-text' import { serialize } from './shared/serialisers' +import { mdCleanHtml, mdInternalLinks } from './shared/md-transforms' interface BlogFrontmatter { title: string @@ -39,7 +40,6 @@ export default defineNuxtModule({ const nuxt = useNuxt() const rootDir = nuxt.options.rootDir - // Read and parse all content files at build time const [blogFiles, pageFiles] = await Promise.all([ glob('./content/blog/**/*.md', { cwd: rootDir, absolute: true }), glob('./content/*.md', { cwd: rootDir, absolute: true }), @@ -48,7 +48,6 @@ export default defineNuxtModule({ const blogPosts: ParsedBlogPost[] = [] const pageBodies: Record = {} - // Parse blog posts for (const filePath of blogFiles) { const raw = await readFile(filePath, 'utf-8') const { data, content } = grayMatter(raw) @@ -68,7 +67,6 @@ export default defineNuxtModule({ }) } - // Parse page files for (const filePath of pageFiles) { const raw = await readFile(filePath, 'utf-8') const { content } = grayMatter(raw) @@ -76,15 +74,10 @@ export default defineNuxtModule({ pageBodies[slug] = content } - // Sort blog posts by date descending blogPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - // Wait until all modules are loaded before calling the hook, - // so listeners don't depend on module load order. nuxt.hook('modules:done', () => nuxt.callHook('markdown:blog-entries', blogPosts)) - // --- Client-side virtual modules --- - addTemplate({ filename: 'markdown/blog-entries.mjs', getContents: () => { @@ -159,9 +152,6 @@ export async function getBody () { write: true, }) - // --- Server-side virtual modules --- - - // RSS metadata (HTML rendering + frontmatter), replaces the old metadata.ts module const md = remark().use(remarkHtml) const rssMetadata: Record = {} @@ -181,13 +171,11 @@ export async function getBody () { nuxt.options.nitro.virtual['#metadata.json'] = () => `export const metadata = ${JSON.stringify(rssMetadata)}` - // Sync articles data, replaces sync/runtime/server/utils/items.ts reading from disk const syncArticles = await Promise.all(blogPosts .filter(p => !p.skip_dev) .map(async post => { const body = serialize(post.body) const date = new Date(post.date) - // Render markdown to HTML, then convert to faithful plaintext const html = String(await md.process(body)) const textContent = htmlToText(html, { wordwrap: false }) return { @@ -205,23 +193,21 @@ export async function getBody () { nuxt.options.nitro.virtual['#sync-articles.json'] = () => `export const syncArticles = ${JSON.stringify(syncArticles)}` - // Raw blog markdown for .md routes (blog post bodies + frontmatter) 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: serialize(post.body), + body: mdInternalLinks(serialize(post.body)).trim(), })) nuxt.options.nitro.virtual['#md-raw-blog.json'] = () => `export const rawBlogPosts = ${JSON.stringify(rawBlogData)}` - // Raw page markdown for .md routes (ai, bio) const rawPageData: Record = {} for (const [slug, body] of Object.entries(pageBodies)) { - rawPageData[slug] = serialize(body) + rawPageData[slug] = mdInternalLinks(mdCleanHtml(serialize(body))) } nuxt.options.nitro.virtual['#md-raw-pages.json'] = () => @@ -231,8 +217,6 @@ export async function getBody () { nuxt.options.nitro.externals.inline ||= [] nuxt.options.nitro.externals.inline.push('#metadata.json', '#sync-articles.json', '#md-raw-blog.json', '#md-raw-pages.json') - // --- Type declarations --- - addTypeTemplate({ filename: 'types/markdown.d.ts', getContents: () => ` diff --git a/modules/md-routes/runtime/server/blog-md.get.ts b/modules/md-routes/runtime/server/blog-md.get.ts index dbc343e6..1bf5aea3 100644 --- a/modules/md-routes/runtime/server/blog-md.get.ts +++ b/modules/md-routes/runtime/server/blog-md.get.ts @@ -25,7 +25,7 @@ export default defineEventHandler(event => { `url: https://roe.dev/blog/${post.slug}`, '---', '', - mdInternalLinks(post.body.trim()), + post.body, '', ].join('\n') diff --git a/modules/shared/md-transforms.ts b/modules/shared/md-transforms.ts new file mode 100644 index 00000000..25a9f403 --- /dev/null +++ b/modules/shared/md-transforms.ts @@ -0,0 +1,30 @@ +const SITE_URL = 'https://roe.dev' + +/** Rewrite absolute roe.dev links to their .md equivalents. */ +export function mdInternalLinks (content: string): string { + return content.replace( + /\]\(https:\/\/roe\.dev(\/[^)]*?)\)/g, + (_match, path: string) => { + if (/\.(xml|png|jpg|jpeg|svg|webp|pdf|webmanifest|json|css|js)$/i.test(path)) return `](${SITE_URL}${path})` + if (path.startsWith('/.well-known/')) return `](${SITE_URL}${path})` + if (path.endsWith('.md')) return `](${SITE_URL}${path})` + if (path.includes('@')) return `](${SITE_URL}${path})` + + const clean = path.replace(/\/$/, '') || '/index' + return `](${SITE_URL}${clean}.md)` + }, + ) +} + +/** Convert residual HTML elements in markdown to their markdown equivalents. */ +export function mdCleanHtml (content: string): string { + return content + .replace(/]*>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*><\/p>/g, '![$2]($1)') + .replace(/]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/g, '![$2]($1)') + .replace(/<(?:div|span)[^>]*>/g, '') + .replace(/<\/(?:div|span)>/g, '') + .replace(/]*class="[^"]*"[^>]*>/g, '') + .replace(/<\/p>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} diff --git a/server/routes/ai.md.get.ts b/server/routes/ai.md.get.ts index b43e2ae6..acf77fa5 100644 --- a/server/routes/ai.md.get.ts +++ b/server/routes/ai.md.get.ts @@ -2,12 +2,10 @@ import { rawPages } from '#md-raw-pages.json' import { pageMeta } from '#md-page-meta.json' export default defineEventHandler(() => { - const body = rawPages['ai'] || '' - const md = [ mdFrontmatter('/ai', pageMeta['/ai']!), '', - mdInternalLinks(mdCleanHtml(body)), + rawPages['ai'] || '', '', ].join('\n') diff --git a/server/routes/bio.md.get.ts b/server/routes/bio.md.get.ts index 901ba4bf..30a0b6b3 100644 --- a/server/routes/bio.md.get.ts +++ b/server/routes/bio.md.get.ts @@ -2,12 +2,10 @@ import { rawPages } from '#md-raw-pages.json' import { pageMeta } from '#md-page-meta.json' export default defineEventHandler(() => { - const body = rawPages['bio'] || '' - const md = [ mdFrontmatter('/bio', pageMeta['/bio']!), '', - mdInternalLinks(mdCleanHtml(body)), + rawPages['bio'] || '', '', ].join('\n') diff --git a/server/routes/llms-full.txt.get.ts b/server/routes/llms-full.txt.get.ts index 42b7f93f..638659dd 100644 --- a/server/routes/llms-full.txt.get.ts +++ b/server/routes/llms-full.txt.get.ts @@ -21,13 +21,13 @@ export default defineEventHandler(async () => { '', '## Bio', '', - mdCleanHtml(rawPages['bio'] || ''), + rawPages['bio'] || '', '', '---', '', '## AI Policy', '', - mdCleanHtml(rawPages['ai'] || ''), + rawPages['ai'] || '', '', '---', '', diff --git a/server/utils/md.ts b/server/utils/md.ts index 21259eff..71cbaa77 100644 --- a/server/utils/md.ts +++ b/server/utils/md.ts @@ -15,42 +15,6 @@ export function mdFrontmatter (path: string, meta: { title: string, description? return lines.join('\n') } -export function mdInternalLinks (content: string): string { - return content.replace( - /\]\(https:\/\/roe\.dev(\/[^)]*?)\)/g, - (_match, path: string) => { - // Skip non-page resources - if (/\.(xml|png|jpg|jpeg|svg|webp|pdf|webmanifest|json|css|js)$/i.test(path)) return `](${SITE_URL}${path})` - if (path.startsWith('/.well-known/')) return `](${SITE_URL}${path})` - if (path.endsWith('.md')) return `](${SITE_URL}${path})` - // Skip mailto and other non-path links - if (path.includes('@')) return `](${SITE_URL}${path})` - - // Normalise trailing slash - const clean = path.replace(/\/$/, '') || '/index' - return `](${SITE_URL}${clean}.md)` - }, - ) -} - -export function mdCleanHtml (content: string): string { - return content - // Convert wrapped in a

to markdown image - .replace(/]*>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*><\/p>/g, '![$2]($1)') - // Convert standalone to markdown image - .replace(/]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/g, '![$2]($1)') - // Strip opening div/span tags (keep content) - .replace(/<(?:div|span)[^>]*>/g, '') - // Strip closing div/span tags - .replace(/<\/(?:div|span)>/g, '') - // Strip

with only classes (presentational) - .replace(/]*class="[^"]*"[^>]*>/g, '') - .replace(/<\/p>/g, '') - // Collapse multiple blank lines left behind - .replace(/\n{3,}/g, '\n\n') - .trim() -} - export function mdResponse (content: string): Response { return new Response(content, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index e16e8541..bba9dfd1 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -78,7 +78,7 @@ describe('project sizes', () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes)) - .toMatchInlineSnapshot(`"2335k"`) + .toMatchInlineSnapshot(`"2333k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect -- 2.51.2