From b79b0505d266af74ba3d71bcf9688333b9ddc22c Mon Sep 17 00:00:00 2001 From: Martin Harris <1488244+nakomis@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:06:31 +0100 Subject: [PATCH] feat(blog-app): prerender posts so crawlers see the content (BAPP-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blog.nakomis.com was a client-rendered SPA, so every URL returned the same ~2.3KB empty shell. Crawlers, link unfurlers and the AdSense reviewer — which is already running against these pages — all saw markup with no content in it. Two independent halves. Surfacing the markdown. deploy.sh has always synced content/blog/*.md to /posts/, and CloudFront has always served it, but nothing linked to it, so the cleanest representation of every post was undiscoverable. buildContent.ts now emits llms.txt from the same loop that writes the sitemap, so the two cannot drift. The sitemap stays HTML-only: sitemaps are for canonical pages, and listing both representations would muddle canonicalisation. Prerendering. A second Vite pass builds src/entry-server.tsx, and scripts/prerender.ts renders every route to its own HTML file with per-page title, description, og:* and canonical tags — plus a rel=alternate pointing at the markdown. Pages go from 2.3KB of nothing to 12-38KB of article. That required making the content load synchronous. getBlogPosts() was async over a compile-time constant and was awaited in a useEffect, which never runs under renderToString — prerendering it as-was would have baked in "Loading post...". It does no I/O, so the effect, the loading state and the error state all go; readers stop seeing a loading frame on content that was already in the bundle. ThemeToggle seeded useState from localStorage, which runs during render and so during SSR. It is caught (it was written for private mode) but the server would emit the 'system' icon whilst the client's first render emitted 'dark', which is a hydration mismatch for anyone who has picked a theme. It now starts at 'system' and resolves in an effect. Nothing flashes — the inline script in index.html still sets the real theme before first paint. Infrastructure. The URI rewrite has to be merged into the existing viewer- request function rather than added alongside it, because CloudFront permits only one function per event type per behaviour. // is rewritten rather than 301'd: a redirect drops the query string, which would silently eat utm_* and gclid on any ad click landing on the slashed form. The rel=canonical on every page is the right tool for the duplicate. The error responses mapped 404 and 403 to /index.html with status 200. That was necessary while the SPA had to boot and route client-side, but it is a soft 404 — every typo URL looked like a valid page carrying thin content. Now that every route is a real file, misses get a real 404 and a real error page. Verified against a local server that reproduces the CloudFront behaviour: correct status codes, no hydration warnings with a theme stored, theme persistence, client-side navigation, and the rewrite logic exercised over assets, markdown, trailing slashes and both hostnames. --- infra/lib/blog-stack.ts | 69 ++++++++++-- web/.gitignore | 2 + web/package.json | 4 +- web/scripts/buildContent.ts | 39 ++++++- web/scripts/prerender.ts | 139 +++++++++++++++++++++++ web/src/App.tsx | 170 ++++++++--------------------- web/src/components/ThemeToggle.tsx | 17 ++- web/src/entry-server.tsx | 75 +++++++++++++ web/src/main.tsx | 23 +++- web/src/siteConfig.ts | 20 ++++ web/src/utils/contentProcessor.ts | 15 ++- 11 files changed, 427 insertions(+), 146 deletions(-) create mode 100644 web/scripts/prerender.ts create mode 100644 web/src/entry-server.tsx create mode 100644 web/src/siteConfig.ts diff --git a/infra/lib/blog-stack.ts b/infra/lib/blog-stack.ts index a48f31d..bfed327 100644 --- a/infra/lib/blog-stack.ts +++ b/infra/lib/blog-stack.ts @@ -39,19 +39,57 @@ export class BlogStack extends Stack { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, }); - // Redirect legacy blog.nakom.is requests to the canonical domain, preserving the path - const legacyRedirectFunction = new cloudfront.Function(this, 'LegacyDomainRedirect', { + // Two jobs in one function, because CloudFront permits only one function + // per event type per behaviour and both have to happen on viewer-request: + // + // 1. Redirect legacy blog.nakom.is requests to the canonical domain, + // preserving the path. + // 2. Rewrite extensionless paths to the prerendered .html file (BAPP-13). + // The site is prerendered to dist/.html, but readers and crawlers + // ask for /. Without this rewrite the S3 key misses and the + // request falls through to the error responses below — which is exactly + // the empty-shell behaviour prerendering exists to end. + const viewerRequestFunction = new cloudfront.Function(this, 'LegacyDomainRedirect', { functionName: 'blog-legacy-domain-redirect', code: cloudfront.FunctionCode.fromInline(` function handler(event) { - if (event.request.headers.host.value === '${LEGACY_DOMAIN}') { + var request = event.request; + + if (request.headers.host.value === '${LEGACY_DOMAIN}') { return { statusCode: 301, statusDescription: 'Moved Permanently', - headers: { location: { value: 'https://${CANONICAL_DOMAIN}' + event.request.uri } } + headers: { location: { value: 'https://${CANONICAL_DOMAIN}' + request.uri } } }; } - return event.request; + + var uri = request.uri; + + // Leave the root alone — defaultRootObject already maps it to index.html. + if (uri === '/') { + return request; + } + + // Serve // the same page as /, by rewrite rather than redirect. + // A 301 here would be tidier for canonicalisation but would drop the query + // string — CloudFront does not carry it into the location header — and that + // silently eats utm_* and gclid on any ad click that lands on the slashed + // form. The emitted on every prerendered page is the + // mechanism for telling search engines which URL is the real one. + if (uri.endsWith('/')) { + uri = uri.slice(0, -1); + } + + // Anything with a file extension in its last segment is a real asset — + // /assets/index-abc123.js, /posts/some-post.md, /favicon.ico — and is + // fetched from S3 unchanged. + var lastSegment = uri.slice(uri.lastIndexOf('/') + 1); + if (lastSegment.indexOf('.') === -1) { + uri = uri + '.html'; + } + + request.uri = uri; + return request; } `), runtime: cloudfront.FunctionRuntime.JS_2_0, @@ -106,7 +144,7 @@ function handler(event) { cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, compress: true, functionAssociations: [{ - function: legacyRedirectFunction, + function: viewerRequestFunction, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST, }], }, @@ -141,17 +179,28 @@ function handler(event) { domainNames: [CANONICAL_DOMAIN, LEGACY_DOMAIN], certificate: certificate, defaultRootObject: 'index.html', + // Every route is prerendered to its own HTML file (BAPP-13), so a miss is + // now a genuine miss and says so. + // + // This previously mapped both statuses to /index.html with status **200** + // — necessary when the SPA had to boot and route client-side, but a soft + // 404: every typo URL looked to a crawler like a valid page carrying thin + // content, which Google treats as a quality problem across the site. + // + // 403 is listed as well as 404 because the bucket is private behind OAC + // and grants only s3:GetObject — with no s3:ListBucket, S3 answers a + // missing key with AccessDenied rather than NoSuchKey. errorResponses: [ { httpStatus: 404, - responseHttpStatus: 200, - responsePagePath: '/index.html', + responseHttpStatus: 404, + responsePagePath: '/404.html', ttl: Duration.minutes(5), }, { httpStatus: 403, - responseHttpStatus: 200, - responsePagePath: '/index.html', + responseHttpStatus: 404, + responsePagePath: '/404.html', ttl: Duration.minutes(5), }, ], diff --git a/web/.gitignore b/web/.gitignore index e9d76d7..0f05549 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,7 +1,9 @@ node_modules/ dist/ +dist-ssr/ *.tsbuildinfo .DS_Store *.swp src/content.generated.ts public/sitemap.xml +public/llms.txt diff --git a/web/package.json b/web/package.json index 3c1b03d..5b9db40 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "npm run content && tsc -b && vite build", + "build": "npm run content && tsc -b && vite build && npm run build:ssr && npm run prerender", + "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr", + "prerender": "tsx scripts/prerender.ts", "preview": "vite preview", "content": "tsx scripts/buildContent.ts" }, diff --git a/web/scripts/buildContent.ts b/web/scripts/buildContent.ts index 1fa154a..51171bd 100644 --- a/web/scripts/buildContent.ts +++ b/web/scripts/buildContent.ts @@ -3,6 +3,7 @@ import path from 'path'; import matter from 'gray-matter'; import { applyShortcodes } from '../src/shortcodes.js'; import { createMarkdownProcessor } from '../src/utils/markdownPipeline.js'; +import { BASE_URL, SITE_DESCRIPTION, markdownUrl, postUrl } from '../src/siteConfig.js'; async function processMarkdownContent(markdownContent: string, slug: string) { const { data: frontmatter, content } = matter(markdownContent); @@ -18,8 +19,6 @@ async function processMarkdownContent(markdownContent: string, slug: string) { }; } -const BASE_URL = 'https://blog.nakomis.com'; - async function buildContent() { const contentDir = path.join(process.cwd(), 'content', 'blog'); const outputPath = path.join(process.cwd(), 'src', 'content.generated.ts'); @@ -80,7 +79,7 @@ export const BLOG_POSTS = ${JSON.stringify(posts, null, 2)} as const; // Generate sitemap.xml const sitemapPath = path.join(process.cwd(), 'public', 'sitemap.xml'); const postEntries = posts.map(post => { - const url = post.frontmatter.canonical ?? `${BASE_URL}/${post.slug}`; + const url = post.frontmatter.canonical ?? postUrl(post.slug); const lastmod = post.frontmatter.date ?? new Date().toISOString().split('T')[0]; return ` \n ${url}\n ${lastmod}\n `; }).join('\n'); @@ -97,6 +96,40 @@ ${postEntries} fs.writeFileSync(sitemapPath, sitemap); console.log(`✓ Generated sitemap.xml with ${posts.length + 1} URLs`); + + // Generate llms.txt (BAPP-13). + // + // deploy.sh has always synced the raw markdown to /posts/*.md, but nothing + // linked to it, so the cleanest representation of every post was effectively + // undiscoverable. The sitemap deliberately does not list these — sitemaps are + // for canonical pages, and listing both would muddle canonicalisation. + // + // Generated from the same `posts` array as the sitemap so it cannot drift. + const llmsPath = path.join(process.cwd(), 'public', 'llms.txt'); + const llmsEntries = posts.map(post => { + const excerpt = (post.frontmatter.excerpt ?? '').replace(/\s+/g, ' ').trim(); + return `- [${post.frontmatter.title}](${markdownUrl(post.slug)})${excerpt ? `: ${excerpt}` : ''}`; + }).join('\n'); + + const llms = `# Martin Harris — Blog + +> ${SITE_DESCRIPTION} + +Written by Martin Harris. Every post below links to its raw markdown source, +which is the same text the rendered page is built from — no navigation, no +markup, no scripts. The HTML version of any post is at ${BASE_URL}/. + +## Posts + +${llmsEntries} + +## Other + +- [Sitemap](${BASE_URL}/sitemap.xml): all canonical HTML URLs. +`; + + fs.writeFileSync(llmsPath, llms); + console.log(`✓ Generated llms.txt with ${posts.length} posts`); } buildContent().catch(console.error); \ No newline at end of file diff --git a/web/scripts/prerender.ts b/web/scripts/prerender.ts new file mode 100644 index 0000000..e1a4c15 --- /dev/null +++ b/web/scripts/prerender.ts @@ -0,0 +1,139 @@ +/** + * Prerender every route to a real HTML file (BAPP-13). + * + * Runs after both Vite builds: the client build produces dist/index.html with + * the hashed asset links, and the SSR build produces dist-ssr/entry-server.js. + * This script uses the first as a template and the second as the renderer. + * + * Why this exists: blog.nakomis.com was a client-rendered SPA, so every URL + * returned the same ~2.3KB empty shell. Crawlers, link unfurlers and the + * AdSense reviewer all saw a page with no content on it. + */ +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; +import type { PrerenderRoute } from '../src/entry-server.js'; + +const DIST = path.join(process.cwd(), 'dist'); +const SSR_ENTRY = path.join(process.cwd(), 'dist-ssr', 'entry-server.js'); +const TEMPLATE = path.join(DIST, 'index.html'); + +/** Escape for use inside a double-quoted HTML attribute. */ +function attr(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Escape for use as HTML text content. */ +function text(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function headTags(route: PrerenderRoute): string { + const tags = [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ]; + + // Point machine readers at the markdown source. It is already served from + // /posts/*.md and is a far cleaner representation than the rendered page. + if (route.markdown) { + tags.push(``); + } + + return tags.map(tag => ` ${tag}`).join('\n'); +} + +function buildPage(template: string, route: PrerenderRoute, appHtml: string): string { + let html = template; + + // Replace rather than append: the template already carries the site-wide + // title and description, and two of each would be ambiguous to a crawler. + html = html.replace( + /[\s\S]*?<\/title>/, + `<title>${text(route.title)}`, + ); + html = html.replace( + //, + ``, + ); + + html = html.replace('', `${headTags(route)}\n`); + html = html.replace('
', `
${appHtml}
`); + + return html; +} + +async function prerender() { + if (!fs.existsSync(TEMPLATE)) { + throw new Error(`No client build found at ${TEMPLATE} — run "vite build" first.`); + } + if (!fs.existsSync(SSR_ENTRY)) { + throw new Error(`No SSR build found at ${SSR_ENTRY} — run "vite build --ssr" first.`); + } + + const template = fs.readFileSync(TEMPLATE, 'utf-8'); + + // Guard the two anchors we splice into. If a future index.html edit renames + // or reformats them, the replace above would silently no-op and ship empty + // pages that look like a successful build. + if (!template.includes('
')) { + throw new Error('index.html no longer contains
— prerender cannot inject markup.'); + } + if (!/ string; + getRoutes: () => PrerenderRoute[]; + }; + + const routes = getRoutes(); + console.log(`Prerendering ${routes.length} routes...`); + + for (const route of routes) { + const appHtml = render(route.url); + if (!appHtml.trim()) { + throw new Error(`${route.url} rendered to nothing — refusing to write an empty page.`); + } + const outPath = path.join(DIST, route.outFile); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, buildPage(template, route, appHtml)); + console.log(`✓ ${route.outFile.padEnd(60)} ${(appHtml.length / 1024).toFixed(1)}KB`); + } + + // A dedicated 404 page, served with a real 404 status by CloudFront. The + // distribution used to map every miss to index.html with status 200 — a soft + // 404, which Google treats as a quality problem because every typo URL looks + // like a valid page with thin content. + const notFound: PrerenderRoute = { + url: '/__not-found__', + outFile: '404.html', + title: 'Page not found | Martin Harris', + description: 'That page does not exist.', + canonical: `${routes[0].canonical}`, + ogType: 'website', + }; + fs.writeFileSync( + path.join(DIST, notFound.outFile), + buildPage(template, notFound, render(notFound.url)), + ); + console.log(`✓ ${notFound.outFile}`); + + console.log(`\n✓ Prerendered ${routes.length + 1} pages`); +} + +prerender().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7403eee..fb3aea0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { BrowserRouter as Router, Routes, Route, useParams } from 'react-router-dom'; -import { BlogPost as BlogPostType, BlogPostListItem } from './types'; import { getBlogPostBySlug, getBlogPosts, getBlogPostList } from './utils/contentProcessor'; import BlogHeader from './components/BlogHeader'; import BlogFooter from './components/BlogFooter'; @@ -8,134 +7,59 @@ import BlogHome from './components/BlogHome'; import BlogPost from './components/BlogPost'; import './App.css'; -const PostPage: React.FC = () => { - const { slug } = useParams<{ slug: string }>(); - const [post, setPost] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const loadPost = async () => { - if (!slug) { - setError('No post slug provided'); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - const postData = await getBlogPostBySlug(slug); - setPost(postData); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load post'); - setPost(null); - } finally { - setLoading(false); - } - }; +// The post list is derived from a compile-time constant, so it is itself +// constant. Computing it once at module scope makes that obvious and keeps it +// out of every render. +const POST_LIST = getBlogPostList(getBlogPosts()); - loadPost(); - }, [slug]); +const Shell: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ +
{children}
+ +
+); - if (loading) { - return ( -
- -
-
Loading post...
-
- -
- ); - } +// One component for every way of missing, so the markup is identical whichever +// route produced it. dist/404.html is prerendered from this and then served for +// any unmatched path — including multi-segment ones that `/:slug` never +// matches — so if the two disagreed, hydration would blank the page. +const NotFoundPage: React.FC = () => ( + +
+

Page not found

+

That page doesn’t exist. Back to the blog.

+
+
+); - if (error) { - return ( -
- -
-
Error: {error}
-
- -
- ); - } +const PostPage: React.FC = () => { + const { slug } = useParams<{ slug: string }>(); + // Synchronous: the post either exists in the bundle or it does not. There is + // no loading state to render because there is nothing to wait for. + const post = slug ? getBlogPostBySlug(slug) : null; - if (!post) { - return ( -
- -
-
Post not found
-
- -
- ); - } + if (!post) return ; - return ( -
- -
- -
- -
- ); + return ; }; -const HomePage: React.FC = () => { - const [posts, setPosts] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function loadPosts() { - try { - const allPosts = await getBlogPosts(); - const postList = getBlogPostList(allPosts); - setPosts(postList); - } catch (err) { - console.error('Failed to load posts:', err); - } finally { - setLoading(false); - } - } - loadPosts(); - }, []); - - if (loading) { - return ( -
- -
-
Loading...
-
- -
- ); - } +const HomePage: React.FC = () => ; - return ( -
- -
- -
- -
- ); -}; +// Routes without a router, so the same tree can be driven by BrowserRouter in +// the browser and StaticRouter during prerendering (see entry-server.tsx). +export const AppRoutes: React.FC = () => ( + + } /> + } /> + } /> + +); -const App: React.FC = () => { - return ( - - - } /> - } /> - - - ); -}; +const App: React.FC = () => ( + + + +); -export default App; \ No newline at end of file +export default App; diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index 1a222d2..921d856 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -59,7 +59,22 @@ const LABEL: Record = { const ICON: Record = { system: '◐', light: '☀', dark: '☾' }; export default function ThemeToggle() { - const [choice, setChoice] = useState(readChoice); + // Starts at 'system' rather than readChoice() because this render also + // happens on the server during prerendering (BAPP-13), where there is no + // localStorage. Seeding from storage here would make the server emit the + // 'system' icon whilst the client's first render emitted 'dark', and React + // would report a hydration mismatch on every load for anyone who has chosen + // a theme. The real choice is picked up in the effect below, after + // hydration has matched. + // + // Nothing flashes: the actual theme is applied by the inline script in + // index.html before first paint. Only this button's icon settles a moment + // later. + const [choice, setChoice] = useState('system'); + + useEffect(() => { + setChoice(readChoice()); + }, []); // Keep other tabs in step — the theme is a per-reader preference, not // per-tab, and seeing one tab disagree with another looks like a bug. diff --git a/web/src/entry-server.tsx b/web/src/entry-server.tsx new file mode 100644 index 0000000..e4f77b3 --- /dev/null +++ b/web/src/entry-server.tsx @@ -0,0 +1,75 @@ +/** + * Server entry point for prerendering (BAPP-13). + * + * Built separately by `vite build --ssr` and consumed by scripts/prerender.ts, + * which is what actually writes the HTML files. Nothing in the browser imports + * this. + * + * This renders the same component tree the browser does — the markup has to + * match what React produces on the client or hydration will discard it. + */ +import { StrictMode } from 'react'; +import { renderToString } from 'react-dom/server'; +import { StaticRouter } from 'react-router-dom/server'; +import { AppRoutes } from './App'; +import { getBlogPosts } from './utils/contentProcessor'; +import { + BASE_URL, + SITE_DESCRIPTION, + SITE_TITLE, + markdownUrl, + postUrl, +} from './siteConfig'; + +export interface PrerenderRoute { + /** Request path, e.g. `/` or `/2026-05-15-some-post`. */ + url: string; + /** Output file relative to dist/, e.g. `index.html` or `some-post.html`. */ + outFile: string; + title: string; + description: string; + canonical: string; + /** Raw markdown alternate, for crawlers that would rather have the source. */ + markdown?: string; + ogType: 'website' | 'article'; +} + +/** + * Every route to prerender, with the head metadata each one needs. + * + * Derived from the same BLOG_POSTS constant the app renders from, so a post can + * never be prerendered with metadata that disagrees with its page — or be + * missed entirely. + */ +export function getRoutes(): PrerenderRoute[] { + const home: PrerenderRoute = { + url: '/', + outFile: 'index.html', + title: SITE_TITLE, + description: SITE_DESCRIPTION, + canonical: `${BASE_URL}/`, + ogType: 'website', + }; + + const posts = getBlogPosts().map((post): PrerenderRoute => ({ + url: `/${post.slug}`, + outFile: `${post.slug}.html`, + title: `${post.frontmatter.title} | Martin Harris`, + description: post.frontmatter.excerpt, + canonical: post.frontmatter.canonical ?? postUrl(post.slug), + markdown: markdownUrl(post.slug), + ogType: 'article', + })); + + return [home, ...posts]; +} + +export function render(url: string): string { + return renderToString( + + + + + , + ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index c824b23..11db396 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,25 @@ import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; +import { createRoot, hydrateRoot } from 'react-dom/client'; import 'highlight.js/styles/github-dark.css'; import App from './App.tsx'; -createRoot(document.getElementById('root')!).render( +const app = ( - , -); \ No newline at end of file + +); + +const container = document.getElementById('root')!; + +// Prerendered pages (BAPP-13) arrive with markup already in #root, so hydrate +// rather than re-render — re-rendering would throw the server's HTML away and +// repaint, which is both slower and visible. +// +// `vite dev` serves the unmodified index.html with an empty #root, so fall back +// to a client render there. Hydrating an empty container "works" but logs a +// mismatch on every dev page load. +if (container.hasChildNodes()) { + hydrateRoot(container, app); +} else { + createRoot(container).render(app); +} diff --git a/web/src/siteConfig.ts b/web/src/siteConfig.ts new file mode 100644 index 0000000..7323f0b --- /dev/null +++ b/web/src/siteConfig.ts @@ -0,0 +1,20 @@ +/** + * Single source of truth for the site's identity. + * + * Previously BASE_URL existed only inside scripts/buildContent.ts, which was + * fine while the sitemap was the only thing that needed it. Prerendering + * (BAPP-13) needs the same value to build canonical URLs and og:url, so it + * lives here where both the build scripts and the app can reach it. + */ +export const BASE_URL = 'https://blog.nakomis.com'; + +export const SITE_TITLE = 'Martin Harris - Blog | Wiring hardware to the cloud'; + +export const SITE_DESCRIPTION = + 'Making cloud abstractions tangible through physical hardware. ESP32, AWS, IoT, and infrastructure as code.'; + +/** Where the raw markdown for a post is served from. */ +export const markdownUrl = (slug: string) => `${BASE_URL}/posts/${slug}.md`; + +/** The canonical page URL for a post. */ +export const postUrl = (slug: string) => `${BASE_URL}/${slug}`; diff --git a/web/src/utils/contentProcessor.ts b/web/src/utils/contentProcessor.ts index 213b197..834fe83 100644 --- a/web/src/utils/contentProcessor.ts +++ b/web/src/utils/contentProcessor.ts @@ -4,13 +4,20 @@ import { BlogPost, BlogPostListItem } from '../types'; import { applyShortcodes } from '../shortcodes'; import { BLOG_POSTS } from '../content.generated'; -export async function getBlogPosts(): Promise { +// These are deliberately synchronous. `BLOG_POSTS` is a compile-time constant +// baked into the bundle by scripts/buildContent.ts — there is no I/O to wait +// for, and the Promises these used to return bought nothing but a "Loading..." +// frame on every navigation. +// +// Being synchronous is also what makes prerendering possible (BAPP-13): an +// effect-driven load never runs under renderToString, so the crawler would be +// served the loading state rather than the post. +export function getBlogPosts(): BlogPost[] { return BLOG_POSTS as unknown as BlogPost[]; } -export async function getBlogPostBySlug(slug: string): Promise { - const posts = await getBlogPosts(); - return posts.find(post => post.slug === slug) || null; +export function getBlogPostBySlug(slug: string): BlogPost | null { + return getBlogPosts().find(post => post.slug === slug) || null; } export function getBlogPostList(posts: BlogPost[]): BlogPostListItem[] {