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[] {