From 00b0b755aef5a2feec8c38db560053cd675e718c Mon Sep 17 00:00:00 2001 From: Fatih Date: Sun, 20 Sep 2026 04:00:46 +0300 Subject: [PATCH 1/8] fix(seo): serve one URL per page, and give the blog its engine axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from a crawl of all 122 pages, all of them structural. **One URL per page.** `trailingSlash: 'ignore'` with `build.format: 'directory'` meant the host served `/features/` and answered `/features` with a 301 — but every link, every canonical and the RSS feed were written without the slash. So all 2,949 internal links spent a redirect, and 121 pages carried a canonical naming the redirect rather than the page it was stamped on. The sitemap, meanwhile, listed the slashed form: three signals, three answers. `pagePath()` in src/lib/site.ts is now the single place that decides, and it leaves assets alone — `/og/default.png/` is not a file. **Posts that relate to nothing.** "Keep reading" took the two newest posts and showed them to all 104, so a reader on a Postgres page was offered the same two links as a reader on a Redis page and never the other eight Postgres posts. The blog has always grouped by engine — nine PostgreSQL, eight MySQL — but the grouping lived in the filename where nothing could reach it. src/lib/posts.ts reads it from there, sourcing the id list from engines.ts rather than keeping a second copy, and matching longest-first because sqlserver, sqlite and libsql all contain `sql`. That gives each post same-engine recommendations, sequential prev/next within its engine, and a clickable chip. The declared tags stay plain text on purpose: "Engineering" is on all 104 posts and "Databases" on all but four, so a link on either leads to a page that duplicates /blog instead of narrowing it. **Archives.** /blog/engine// for the seventeen engines with two or more posts. One post is the post, not an archive of it. Also: BlogPosting gains the image it needed for Article rich results (og:image was already computed one line away), BreadcrumbList lands on every post and archive, mainEntityOfPage names the served URL, and the sitemap carries lastmod where a date is actually known — the posts' own front matter, and each archive's newest post. Marketing pages stay undated; a lastmod refreshed on every deploy is the signal Google learns to ignore. `updatedAt` is optional and uses the preprocessor pattern, not a bare `.optional()` — tests/content.test.ts explains why. Tests 166 → 198. The three new suites assert the built output, because the helpers were never the part that broke. Not done, deliberately: aggregateRating. It would put stars in the SERP, but Google requires it to come from real reviews on the site, and there are none. --- astro.config.mjs | 49 +++- src/components/blog/PostCard.astro | 35 ++- src/components/layout/CookieConsent.astro | 2 +- src/components/layout/SiteHeader.astro | 2 +- src/content.config.ts | 15 ++ src/data/home.ts | 34 +-- src/data/platform.ts | 2 +- src/data/redirects.ts | 12 +- src/lib/posts.ts | 65 ++++++ src/lib/site.ts | 22 +- src/pages/404.astro | 4 +- src/pages/blog/[...id].astro | 141 +++++++++++- src/pages/blog/engine/[engine].astro | 263 ++++++++++++++++++++++ src/pages/code-signing-policy.astro | 4 +- src/pages/compare.astro | 2 +- src/pages/docker-compose.astro | 4 +- src/pages/libredb-database.astro | 2 +- src/pages/open-source.astro | 2 +- src/pages/platform.astro | 4 +- src/pages/rss.xml.ts | 6 +- src/pages/security.astro | 2 +- src/pages/support.astro | 2 +- src/pages/supporters.astro | 2 +- tests/code-signing.test.ts | 4 +- tests/dist-smoke.test.ts | 13 +- tests/engine-archives.test.ts | 168 ++++++++++++++ tests/post-relations.test.ts | 153 +++++++++++++ tests/trailing-slash.test.ts | 94 ++++++++ 28 files changed, 1044 insertions(+), 64 deletions(-) create mode 100644 src/lib/posts.ts create mode 100644 src/pages/blog/engine/[engine].astro create mode 100644 tests/engine-archives.test.ts create mode 100644 tests/post-relations.test.ts create mode 100644 tests/trailing-slash.test.ts diff --git a/astro.config.mjs b/astro.config.mjs index e425cf7..c7a0afd 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,6 +3,23 @@ import { defineConfig } from 'astro/config'; import sitemap from '@astrojs/sitemap'; import site from './site.config.json' with { type: 'json' }; import { redirectPaths } from './src/data/redirects.ts'; +import { readFileSync, readdirSync } from 'node:fs'; + +// Read from the posts themselves rather than a generated list: a second copy of +// this mapping is exactly the drift CLAUDE.md warns about. `updatedAt` wins when +// a post declares one, otherwise the publication date is the last time it +// changed. +const POSTS = './outstatic/content/posts'; +const postDates = Object.fromEntries( + readdirSync(POSTS) + .filter((f) => f.endsWith('.md')) + .map((f) => { + const front = readFileSync(`${POSTS}/${f}`, 'utf8').split('---')[1] ?? ''; + const pick = (key) => new RegExp(`^${key}:\\s*['"]?([0-9T:.Z+-]+)`, 'm').exec(front)?.[1]; + return [f.replace(/\.md$/, ''), pick('updatedAt') ?? pick('publishedAt')]; + }) + .filter(([, date]) => Boolean(date)), +); // Custom domain (libredb.org) => the site is served from the root, so `base` // stays at its default. Setting it would double-prefix every asset and route; @@ -10,7 +27,13 @@ import { redirectPaths } from './src/data/redirects.ts'; export default defineConfig({ site: site.url, output: 'static', - trailingSlash: 'ignore', + + // 'ignore' let links, canonicals and the sitemap disagree: the build emits + // directories, so the host serves `/features/` and 301s `/features` to it. + // Every internal link spent a redirect and every canonical pointed at one. + // 'always' makes the dev server agree with the deployed host, and + // `pagePath()` in src/lib/site.ts is what writes the slash into links. + trailingSlash: 'always', // Astro 7 defaults compressHTML to 'jsx', which strips newline-containing // whitespace between inline elements. That silently welded the hero headline @@ -29,6 +52,30 @@ export default defineConfig({ sitemap({ filter: (page) => !page.includes('/404') && !redirectPaths.some((p) => new URL(page).pathname.replace(/\/$/, '') === p), + + // lastmod only where a date is actually known — the posts' own front + // matter. Stamping every URL with the build date would tell Google the + // whole site changed on every deploy, which is the fastest way to have + // the signal ignored. Marketing pages carry no date and get none. + serialize: (item) => { + const path = new URL(item.url).pathname; + + // An engine archive is as fresh as its newest post: it changes when one + // is published and at no other time. + const engine = /^\/blog\/engine\/([^/]+)\/?$/.exec(path)?.[1]; + if (engine) { + const dates = Object.entries(postDates) + .filter(([slug]) => slug === engine || slug.startsWith(`${engine}-`)) + .map(([, d]) => d) + .sort(); + const newest = dates.at(-1); + return newest ? { ...item, lastmod: newest } : item; + } + + const slug = /^\/blog\/([^/]+)\/?$/.exec(path)?.[1]; + const date = slug ? postDates[slug] : undefined; + return date ? { ...item, lastmod: date } : item; + }, }), ], diff --git a/src/components/blog/PostCard.astro b/src/components/blog/PostCard.astro index e1f4a90..ec27bc7 100644 --- a/src/components/blog/PostCard.astro +++ b/src/components/blog/PostCard.astro @@ -1,15 +1,27 @@ --- import { formatDate, isoDate } from '../../lib/format'; import type { CollectionEntry } from 'astro:content'; +import { pagePath } from '../../lib/site'; +import { engineName, postEngine } from '../../lib/posts'; interface Props { post: CollectionEntry<'posts'>; /** the first card on the index gets the wider treatment */ featured?: boolean; + /** an engine archive already says which engine these are; the chip would + * link every card back to the page the reader is standing on */ + hideEngine?: boolean; } -const { post, featured = false } = Astro.props; +const { post, featured = false, hideEngine = false } = Astro.props; const { title, description, publishedAt, tags, coverImage } = post.data; -const href = `/blog/${post.id}`; +const href = pagePath(`/blog/${post.id}`); + +// The engine is the axis the blog is actually organised on, so it is the one +// chip that navigates. The declared tags stay plain text: "Engineering" is on +// every post and "Databases" on all but four, so a link on either would lead to +// a page that duplicates /blog rather than narrowing anything. +const engine = hideEngine ? undefined : postEngine(post.id); +const engineLabel = engine ? engineName(engine) : undefined; ---
@@ -30,6 +42,13 @@ const href = `/blog/${post.id}`; ) } + { + engine && engineLabel && ( + + {engineLabel} + + ) + } {tags.map((t) => {t.label})}

{title}

@@ -81,6 +100,18 @@ const href = `/blog/${post.id}`; .pcard__tag { color: var(--text-brand); } + /* The engine chip navigates; the declared tags do not. Bordering only the + link keeps that difference visible without a second colour. */ + .pcard__tag--engine { + padding-inline: var(--space-03); + border: var(--border-width-1) solid var(--border); + border-radius: var(--radius-pill); + color: var(--text-secondary); + } + .pcard__tag--engine:hover { + border-color: var(--border-strong); + color: var(--text-primary); + } .pcard__title { font-family: var(--font-heading); font-weight: var(--weight-semibold); diff --git a/src/components/layout/CookieConsent.astro b/src/components/layout/CookieConsent.astro index f53e348..6db8ead 100644 --- a/src/components/layout/CookieConsent.astro +++ b/src/components/layout/CookieConsent.astro @@ -26,7 +26,7 @@

We use cookies to analyse site traffic and improve your experience. - See our Privacy Policy for details. + See our Privacy Policy for details.

diff --git a/src/components/layout/SiteHeader.astro b/src/components/layout/SiteHeader.astro index 1632800..b416efb 100644 --- a/src/components/layout/SiteHeader.astro +++ b/src/components/layout/SiteHeader.astro @@ -45,7 +45,7 @@ const href = (h: string) => (h.startsWith('#') && !onHome ? `/${h}` : h); )) } - Blog + Blog diff --git a/src/pages/blog/[...id].astro b/src/pages/blog/[...id].astro index ed21fe2..2c8af09 100644 --- a/src/pages/blog/[...id].astro +++ b/src/pages/blog/[...id].astro @@ -4,7 +4,8 @@ import type { GetStaticPaths } from 'astro'; import BaseLayout from '../../layouts/BaseLayout.astro'; import TableOfContents from '../../components/blog/TableOfContents.astro'; import { formatDate, isoDate, readingTime } from '../../lib/format'; -import { site } from '../../lib/site'; +import { canonicalUrl, pagePath, site } from '../../lib/site'; +import { engineName, postEngine, postNeighbours, relatedPosts } from '../../lib/posts'; // PITFALLS B5: getStaticPaths is extracted into its own module at build time, so // nothing defined in the rest of this frontmatter is visible inside it. Every @@ -23,20 +24,43 @@ const { title, description, publishedAt, author, tags, coverImage } = post.data; const all = (await getCollection('posts', ({ data }) => data.status === 'published')).sort( (a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime(), ); -const others = all.filter((p) => p.id !== post.id).slice(0, 2); +// Same engine first — see src/lib/posts.ts for why the slug is the grouping. +const others = relatedPosts(post, all); +const { prev, next } = postNeighbours(post, all); +const family = postEngine(post.id); +const familyName = family ? engineName(family) : undefined; +const updatedAt = (post.data as { updatedAt?: Date }).updatedAt; const minutes = readingTime(post.body ?? ''); +const url = canonicalUrl(`/blog/${post.id}`); + const schema = [ { '@type': 'BlogPosting', headline: title, description, + // Article rich results want an image, and every page already computes one + // for og:image — emitting only there left 104 posts ineligible for the + // richer listing while the asset sat one line away. + image: canonicalUrl(coverImage || '/og/default.png'), datePublished: publishedAt.toISOString(), + // Only when the post actually says so. A dateModified defaulted to the + // build date tells Google every post changed on every deploy. + ...(updatedAt ? { dateModified: updatedAt.toISOString() } : {}), inLanguage: 'en', author: { '@type': 'Organization', name: author.name || site.name }, publisher: { '@id': `${site.url}/#organization` }, - mainEntityOfPage: `${site.url}/blog/${post.id}`, + // The served URL, not the redirect that reaches it. + mainEntityOfPage: url, + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: canonicalUrl('/') }, + { '@type': 'ListItem', position: 2, name: 'Blog', item: canonicalUrl('/blog') }, + { '@type': 'ListItem', position: 3, name: title, item: url }, + ], }, ]; --- @@ -48,17 +72,29 @@ const schema = [ type="article" image={coverImage || undefined} publishedTime={publishedAt.toISOString()} + modifiedTime={updatedAt?.toISOString()} authors={[author.name || site.name]} schema={schema} >
-

← All posts

+
{ - tags.length > 0 && ( + (tags.length > 0 || family) && ( ) } + + { + (prev || next) && ( + + ) + }
@@ -116,18 +173,40 @@ const schema = [ .post__inner { padding-block: clamp(var(--space-09), 6vh, 72px) clamp(var(--section-y-desktop), 10vh, 112px); } - .post__back { + /* Replaces the old "← All posts" line: same role, but it shows the path the + page sits on, and the BreadcrumbList schema says the same thing. */ + .post__crumbs { font: var(--weight-medium) 12.5px / 1 var(--font-mono); } - .post__back a { + .post__crumbs ol { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-02); + margin: 0; + padding: 0; + list-style: none; + color: var(--text-tertiary); + } + .post__crumbs li { display: inline-flex; align-items: center; min-height: 24px; + } + .post__crumbs li + li::before { + content: '/'; + margin-inline-end: var(--space-02); + color: var(--border-strong); + } + .post__crumbs a { color: var(--text-tertiary); } - .post__back a:hover { + .post__crumbs a:hover { color: var(--text-primary); } + .post__crumbs [aria-current='page'] { + color: var(--text-secondary); + } .post__head { margin-top: var(--space-08); @@ -144,6 +223,15 @@ const schema = [ text-transform: uppercase; color: var(--text-brand); } + .post__tag--engine { + border: var(--border-width-1) solid var(--border); + border-radius: var(--radius-pill); + color: var(--text-secondary); + } + .post__tag--engine:hover { + border-color: var(--border-strong); + color: var(--text-primary); + } .post__title { margin-top: var(--space-04); font-family: var(--font-heading); @@ -186,6 +274,39 @@ const schema = [ align-items: start; } + /* Walks one engine's posts in the order they were written; see src/lib/posts.ts. */ + .post__seq { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); + gap: var(--space-04); + margin-top: var(--space-08); + } + .post__seqlink { + display: flex; + flex-direction: column; + gap: var(--space-02); + padding: var(--space-05); + border: var(--border-width-1) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + } + .post__seqlink:hover { + border-color: var(--border-strong); + } + .post__seqlink--next { + text-align: right; + } + .post__seqdir { + font: var(--weight-semibold) var(--overline-size) / 1 var(--font-mono); + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--text-tertiary); + } + .post__seqtitle { + color: var(--text-primary); + line-height: var(--leading-snug); + } + .post__more { margin-top: clamp(var(--space-11), 8vh, var(--space-13)); padding-top: var(--space-08); diff --git a/src/pages/blog/engine/[engine].astro b/src/pages/blog/engine/[engine].astro new file mode 100644 index 0000000..eff0855 --- /dev/null +++ b/src/pages/blog/engine/[engine].astro @@ -0,0 +1,263 @@ +--- +import type { GetStaticPaths } from 'astro'; +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import PostCard from '../../../components/blog/PostCard.astro'; +import { pagePath, site } from '../../../lib/site'; +import { engineName, postEngine } from '../../../lib/posts'; +import { engines } from '../../../data/engines'; + +/** + * One archive per database engine. + * + * The blog already groups by engine — nine PostgreSQL posts, eight MySQL — but + * the grouping lived in the filename, so a reader who wanted "everything about + * Postgres" had to scan a flat list of 104. These pages give that group an + * address, and give the engine chip on each post somewhere to point. + * + * Only engines that actually have posts get a page: an archive listing nothing + * is a thin page, and shipping eighteen of them to cover four would be padding. + * The threshold is two — one post is the post, not an archive of it. + * + * PITFALLS B5: getStaticPaths is extracted at build time and sees nothing from + * the rest of this frontmatter, so it imports what it needs itself. + */ +export const getStaticPaths = (async () => { + const { getCollection } = await import('astro:content'); + const { postEngine } = await import('../../../lib/posts'); + + const posts = await getCollection('posts', ({ data }) => data.status === 'published'); + const byEngine = new Map(); + for (const post of posts) { + const id = postEngine(post.id); + if (!id) continue; + byEngine.set(id, [...(byEngine.get(id) ?? []), post]); + } + + return [...byEngine.entries()] + .filter(([, list]) => list.length >= 2) + .map(([engine, list]) => ({ + params: { engine }, + props: { + posts: list.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime()), + }, + })); +}) satisfies GetStaticPaths; + +type Props = { posts: Awaited>> }; +const { engine } = Astro.params; +const { posts } = Astro.props; + +const name = engineName(engine!) ?? engine!; +const engineData = engines.find((e) => e.id === engine); + +// Sibling archives, so a reader comparing two engines can cross over without +// going back to the flat list. Built from what actually shipped, not from the +// full engine list — an archive that does not exist must not be linked. +const siblings = (await import('astro:content')) + .getCollection('posts', ({ data }) => data.status === 'published') + .then((all) => { + const counts = new Map(); + for (const p of all) { + const id = postEngine(p.id); + if (id) counts.set(id, (counts.get(id) ?? 0) + 1); + } + return [...counts.entries()] + .filter(([id, n]) => n >= 2 && id !== engine) + .map(([id]) => ({ id, name: engineName(id) ?? id })) + .sort((a, b) => a.name.localeCompare(b.name)); + }); +const others = await siblings; + +const description = `Every LibreDB Studio write-up about ${name}: what the provider does, where it stops, and why. ${posts.length} posts.`; + +const schema = [ + { + '@type': 'CollectionPage', + name: `${name} — LibreDB Studio blog`, + description, + isPartOf: { '@id': `${site.url}/#website` }, + mainEntity: { + '@type': 'ItemList', + numberOfItems: posts.length, + itemListElement: posts.map((p, i) => ({ + '@type': 'ListItem', + position: i + 1, + url: `${site.url}${pagePath(`/blog/${p.id}`)}`, + name: p.data.title, + })), + }, + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: `${site.url}${pagePath('/')}` }, + { '@type': 'ListItem', position: 2, name: 'Blog', item: `${site.url}${pagePath('/blog')}` }, + { '@type': 'ListItem', position: 3, name, item: `${site.url}${pagePath(`/blog/engine/${engine}`)}` }, + ], + }, +]; +--- + + +
+ +
+ + +
+

{posts.length} posts

+

+ Writing on {name} +

+ { + engineData && ( +

+ {engineData.desc} + {engineData.not && Where it stops: {engineData.not}} +

+ ) + } + +
+ +
+ {posts.map((post) => )} +
+ + { + others.length > 0 && ( + + ) + } +
+
+
+ + diff --git a/src/pages/code-signing-policy.astro b/src/pages/code-signing-policy.astro index d6a92bd..610e203 100644 --- a/src/pages/code-signing-policy.astro +++ b/src/pages/code-signing-policy.astro @@ -105,8 +105,8 @@ const schema = [

{p.privacyNote}

The site's own cookie and analytics handling is a separate matter, covered in the - privacy policy. Where those guarantees stop is set out in the - security model. + privacy policy. Where those guarantees stop is set out in the + security model.

diff --git a/src/pages/compare.astro b/src/pages/compare.astro index 2824447..3291232 100644 --- a/src/pages/compare.astro +++ b/src/pages/compare.astro @@ -104,7 +104,7 @@ const schema = [

{closing.body}

The editor itself is MIT and free, so the way to settle this is to run it rather than read about it. - Get started in about ten seconds → · Get started in about ten seconds → · Try it with no install →

diff --git a/src/pages/docker-compose.astro b/src/pages/docker-compose.astro index 584bcb4..ffb79b3 100644 --- a/src/pages/docker-compose.astro +++ b/src/pages/docker-compose.astro @@ -166,8 +166,8 @@ const schema = [
diff --git a/src/pages/libredb-database.astro b/src/pages/libredb-database.astro index 1620a29..fcdf6ed 100644 --- a/src/pages/libredb-database.astro +++ b/src/pages/libredb-database.astro @@ -123,7 +123,7 @@ const escaped = sample.replace(/&/g, '&').replace(//g href={meta.repo} target="_blank" rel="noreferrer">Source on GitHub ↗ · npm ↗ · · npm ↗ · Run it in your browser →

diff --git a/src/pages/open-source.astro b/src/pages/open-source.astro index 455200f..8840151 100644 --- a/src/pages/open-source.astro +++ b/src/pages/open-source.astro @@ -141,7 +141,7 @@ const schema = [

- The work is funded by the company that publishes it, and by sponsors. + The work is funded by the company that publishes it, and by sponsors. Sponsor the project ↗ · Star the repository ↗ diff --git a/src/pages/platform.astro b/src/pages/platform.astro index 44e0fea..de36f2f 100644 --- a/src/pages/platform.astro +++ b/src/pages/platform.astro @@ -99,11 +99,11 @@ const schema = [ Ask about the beta - Or self-host Studio today + Or self-host Studio today

Studio covers everything on this site that is free, and it stays that way whether or not you ever talk to us - about Platform. Read the licence commitment → + about Platform. Read the licence commitment →

diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index f4ab714..4efd327 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -1,6 +1,6 @@ import rss from '@astrojs/rss'; import { getCollection } from 'astro:content'; -import { site } from '../lib/site'; +import { pagePath, site } from '../lib/site'; import type { APIContext } from 'astro'; export async function GET(context: APIContext) { @@ -12,12 +12,12 @@ export async function GET(context: APIContext) { title: `${site.name} — blog`, description: site.description, site: context.site ?? site.url, - trailingSlash: false, + trailingSlash: true, items: posts.map((post) => ({ title: post.data.title, description: post.data.description, pubDate: post.data.publishedAt, - link: `/blog/${post.id}`, + link: pagePath(`/blog/${post.id}`), categories: post.data.tags.map((t) => t.label), author: post.data.author.name || site.name, })), diff --git a/src/pages/security.astro b/src/pages/security.astro index e824395..23f74a7 100644 --- a/src/pages/security.astro +++ b/src/pages/security.astro @@ -92,7 +92,7 @@ const schema = [ Read the security policy ↗

- Who supports this software, on which versions, and for how long: vendor support → + Who supports this software, on which versions, and for how long: vendor support →

diff --git a/src/pages/support.astro b/src/pages/support.astro index af6132a..34a537f 100644 --- a/src/pages/support.astro +++ b/src/pages/support.astro @@ -208,7 +208,7 @@ const channels = [

A vulnerability goes to the address in the security policy, never into a public issue — the policy exists so a - fix can ship before the details do. How the security model works → + fix can ship before the details do. How the security model works →

diff --git a/src/pages/supporters.astro b/src/pages/supporters.astro index 2279bef..7ab133b 100644 --- a/src/pages/supporters.astro +++ b/src/pages/supporters.astro @@ -138,7 +138,7 @@ import { site } from '../lib/site'; · GitHub Sponsors ↗ · - How the project is licensed and run + How the project is licensed and run

diff --git a/tests/code-signing.test.ts b/tests/code-signing.test.ts index 83de5b2..d09330c 100644 --- a/tests/code-signing.test.ts +++ b/tests/code-signing.test.ts @@ -108,7 +108,7 @@ describe('the published page', () => { // "Please add the term 'Code signing policy' on your project's home page" — // as a section header or a link to a dedicated page. This is the link arm. const home = page(HOME); - const link = [...home.querySelectorAll('a')].find((a) => a.getAttribute('href') === '/code-signing-policy'); + const link = [...home.querySelectorAll('a')].find((a) => a.getAttribute('href') === '/code-signing-policy/'); expect(link, 'no link to /code-signing-policy on the home page').toBeTruthy(); expect(link?.textContent?.trim().toLowerCase()).toBe('code signing policy'); }); @@ -130,7 +130,7 @@ describe('the published page', () => { it('links the privacy policy too, which the terms offer as the alternative', () => { const hrefs = [...page(POLICY).querySelectorAll('a')].map((a) => a.getAttribute('href')); - expect(hrefs).toContain('/privacy-policy'); + expect(hrefs).toContain('/privacy-policy/'); }); }); diff --git a/tests/dist-smoke.test.ts b/tests/dist-smoke.test.ts index 170a4a2..8e7d208 100644 --- a/tests/dist-smoke.test.ts +++ b/tests/dist-smoke.test.ts @@ -135,8 +135,11 @@ describe('SEO surface', () => { ['blog', blog, '/blog'], ['post', post, '/blog/the-tool-goes-to-the-data'], ] as const) { + // The canonical must be the URL the host actually serves. The build emits + // directories, so that URL ends in a slash — a canonical without one names + // a 301 rather than the page it sits on. const canonical = doc.querySelector('link[rel=canonical]')?.getAttribute('href'); - expect(canonical, `${name}: canonical`).toBe(path === '/' ? `${site.url}/` : `${site.url}${path}`); + expect(canonical, `${name}: canonical`).toBe(`${site.url}${path === '/' ? '/' : `${path}/`}`); expect(meta(doc, 'meta[name=description]')?.length ?? 0, `${name}: description`).toBeGreaterThan(50); expect(meta(doc, 'meta[property="og:image"]'), `${name}: og:image`).toMatch(/^https:\/\//); expect(meta(doc, 'meta[name="twitter:card"]'), `${name}: twitter card`).toBe('summary_large_image'); @@ -159,8 +162,12 @@ describe('SEO surface', () => { it('publishes a sitemap and an RSS feed carrying every published post', () => { const rss = readFileSync('dist/rss.xml', 'utf8'); const items = (rss.match(//g) ?? []).length; - expect(items).toBe(readdirSync('dist/blog', { withFileTypes: true }).filter((e) => e.isDirectory()).length); - expect(rss).toContain(`${site.url}/blog/the-tool-goes-to-the-data`); + // `engine` holds the per-engine archives; those are pages, not feed items. + const postDirs = readdirSync('dist/blog', { withFileTypes: true }).filter( + (e) => e.isDirectory() && e.name !== 'engine', + ); + expect(items).toBe(postDirs.length); + expect(rss).toContain(`${site.url}/blog/the-tool-goes-to-the-data/`); expect(rss).toContain('en'); const sitemap = readFileSync('dist/sitemap-0.xml', 'utf8'); diff --git a/tests/engine-archives.test.ts b/tests/engine-archives.test.ts new file mode 100644 index 0000000..c071716 --- /dev/null +++ b/tests/engine-archives.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'bun:test'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { postEngine } from '../src/lib/posts'; +import { engines } from '../src/data/engines'; +import { site } from '../src/lib/site'; + +/** + * The per-engine archives, and the cross-links that feed them. + * + * The blog groups by engine and always did, but nothing in the site said so: + * the tag chips were ``s, there was no page for "everything about + * Postgres", and a post naming another engine in prose left it as plain text. + * 104 deep posts sat in one flat list with no way in but the list itself. + */ + +const POSTS = 'outstatic/content/posts'; +const postSlugs = readdirSync(POSTS) + .filter((f) => f.endsWith('.md')) + .map((f) => f.replace(/\.md$/, '')); + +const archives = existsSync('dist/blog/engine') + ? readdirSync('dist/blog/engine', { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + : []; + +const counts = new Map(); +for (const slug of postSlugs) { + const id = postEngine(slug); + if (id) counts.set(id, (counts.get(id) ?? 0) + 1); +} + +const html = (path: string) => readFileSync(path, 'utf8'); +/** The rendered prose, between the body container and the table of contents. */ +const prose = (doc: string) => { + const start = doc.indexOf('post__body prose'); + const end = doc.indexOf('