feat(site): canonical, Open Graph and feed links in every head; sitemap and Atom feeds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 10:27:24 +01:00
1 parent 765b6ba906
commit ab611c2de8
17 files changed
+297 -17

No files matched your search

+4 -1
View File
@@ -16,7 +16,7 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `lazyImages` for Markdown bodies (`post-html.ts`), media extensions (`media.ts`), showcase ids (`showcases.ts`) |
| `src/library/` | Reads and validates the content repo (`load.ts`, `frontmatter.ts`, `types.ts`, `errors.ts`); `RESERVED_PERSONAL_IDS` guards ids that would shadow a page |
| `src/sources/` | Ghost, Trakt and Hardcover clients plus a JSON cache (`cache.ts`); `loadSources` honours `SOURCES_FIXTURES`. Trakt: last film (`history/movies?limit=1` + `ratings/movies`) and recent ratings; no "watching now". ListenBrainz (`LISTENBRAINZ_USER`, no token): the week's top track, counted from the listens |
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (plain page scripts: `preference.ts`, `pref-switch.ts` for the plain-HTML `PreferenceSwitch.astro`, `filter-note.ts`, `gate.ts` for the start page), `data.ts` (`getSiteData`: content plus cached sources, never fetches), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) |
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404, and `[...file].xml.ts` for `/sitemap.xml` and the three Atom feeds), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (plain page scripts: `preference.ts`, `pref-switch.ts` for the plain-HTML `PreferenceSwitch.astro`, `filter-note.ts`, `gate.ts` for the start page), `data.ts` (`getSiteData`: content plus cached sources and `siteUrl` from `SITE_URL`, never fetches), `link-preview.ts` (`SITE_URL` parsing and each page's canonical/Open Graph/feed head tags, rendered by `Base.astro`), `feeds.ts` (the page list for the sitemap and each feed's entries), `xml.ts` (XML escaping, the Atom and sitemap writers; all three pure and build-only), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) |
| `src/runtime/` | `build.ts` (validate, fetch sources once, run Astro, copy media), `refresh.ts` (poll git, timed rebuild, swap `live`), `serve.ts`, `static-server.ts` (`/status`, 404 page), `state.ts`, `git.ts` |
| `src/cli.ts` | Entry point: `validate`, `build`, `preview`, `refresh`, `serve` |
| `fixtures/` | Synthetic content (`content/`, `sparse/`) and source JSON (`sources/`); the only content in this repo |
@@ -41,6 +41,9 @@ Hardcover ──────────┘ │ writes <cache>/sources.
last cached data and `/status` names the failing source.
- Pages read `<SITE_CACHE_DIR>/sources.json` through `getSiteData`; a missing file means every source
reads "not fetched". `SITE_CONTENT_DIR` must be set (the CLI does) or `getSiteData` throws.
- `SITE_URL` (the public origin; the image defaults it) reaches the pages as `siteUrl` in site data. Without it the
build leaves out everything that needs an absolute URL: canonical, `og:url`, `og:image`, the sitemap, the feeds and
the head links to them. An invalid value fails the build.
- `CONTENT_REPO_TOKEN` is not passed to the build child; source credentials are.
## Invariants
+24
View File
@@ -367,3 +367,27 @@ script).
**Consequences:** The 2021 line is in the HTML of every page with the switch, hidden unless "by hand only" (`hidden`
keeps it out of view, the tab order and the accessibility tree). The start page's JS is a few small first-party
modules. Pages that keep React islands still load the runtime.
## 2026-11-11 — Link previews, a sitemap and Atom feeds from `SITE_URL`, written by hand
**Status:** accepted
**Context:** Spec 007, from the 2026-11-11 audit (findings 8 and 9). Shared links showed a bare URL, search engines
had no canonical URL or sitemap, and there was no way to follow the site.
**Decision:** The public address comes from `SITE_URL`, an http(s) origin; the image sets it by default, and an
invalid value (a path, a query, another scheme) fails the build rather than ending up in every preview. Without it,
everything that needs an absolute URL is left out and nothing guesses one. The head tags, the Atom feeds and the
sitemap are pure functions in `src/site/` (`link-preview.ts`, `feeds.ts`, `xml.ts`), not `src/core`, because the
browser never needs them. One endpoint, `pages/[...file].xml.ts`, writes `/sitemap.xml`, `/feed.xml`,
`/work/feed.xml` and `/off-the-clock/feed.xml`; its `getStaticPaths` returns nothing without `SITE_URL`, so no file
is written. Details the spec left open: `og:title` is the page's whole `<title>`; `og:image:alt` is added when the
image has alt text; `twitter:card` is `summary_large_image` exactly when there is an `og:image`. A feed's `<id>` is
its own URL, its title the title of the page it follows (`site.name`, `Work · …`, `Off the clock · …`), its one
`<author>` the site name. An entry's summary is the project summary, the post excerpt or the item's `line`, left out
when empty; its category is the AI level (`unknown` included, as the site shows it) with the site's label. Ties on a
date are ordered by path. An empty feed's `updated` is `1970-01-01T00:00:00Z`, so it stays stable. No `<lastmod>`
in the sitemap: no content date says when a page last changed.
**Alternatives:** `@astrojs/sitemap` and `@astrojs/rss` (new dependencies for a few lines each; RSS rather than
Atom); the request's host as the address (guesses, and wrong behind the proxy); an endpoint per file (four files
that only differ in one line); stripping the ` · site name` suffix for `og:title` (new wording).
**Consequences:** A page's `<body>` does not change. Feeds are served as `application/xml` by the static server
(Bun's type for `.xml`), which feed readers accept. A new page type must be added to `sitePaths` in `feeds.ts`; a
build test compares the sitemap with the files Astro wrote, so a missing one fails.
+1 -1
View File
@@ -29,7 +29,7 @@ Commands: `bun run typecheck`, `bun test`, `bunx playwright test` (`--workers=2`
- [x] 3. `test/site/feeds.test.ts` over `loadSiteData('fixtures/content', …)` with fixture sources: `siteUrl` from the
parameter; `sitePaths` lists every page but the 404; `feedEntries` per feed (members, order, summaries, levels,
no drafts, no live cards). Watch fail, add `src/site/feeds.ts` and `siteUrl` in `src/site/data.ts`. Commit.
- [ ] 4. `test/site/build-meta.test.ts` on the fixture site built with `SITE_URL=https://example.invalid` (and the
- [x] 4. `test/site/build-meta.test.ts` on the fixture site built with `SITE_URL=https://example.invalid` (and the
sparse site without it): head tags on each kind of page, `og:image` exactly where the page has its own image,
404 `noindex`, sitemap = built pages, feeds well-formed with expected entries; sparse: no canonical, no
`.xml` files, no alternate links. Watch fail; wire `Base.astro`, the layouts, the pages and
+19 -1
View File
@@ -8,6 +8,9 @@ import personalFonts from '../styles/fonts-personal.css?url';
import geistSans400 from '@fontsource/geist-sans/files/geist-sans-latin-400-normal.woff2?url';
import geistSans600 from '@fontsource/geist-sans/files/geist-sans-latin-600-normal.woff2?url';
import geistMono400 from '@fontsource/geist-mono/files/geist-mono-latin-400-normal.woff2?url';
import { getSiteData } from '../data';
import { feedLink, type FeedName } from '../feeds.ts';
import { previewTags } from '../link-preview.ts';
interface Props {
title: string;
@@ -15,8 +18,22 @@ interface Props {
theme: 'gate' | 'work' | 'personal' | 'neutral';
/** Lets a page tune its theme's tokens in CSS (`[data-page='simple']`). */
page?: string;
/** Link previews (spec 007): `article` for a project, a post or an item's own page. */
type?: 'website' | 'article';
/** The page's own picture from the content repo (a media file), for og:image. */
image?: { file: string; alt: string } | undefined;
/** The feed the page links in its head. */
feed?: FeedName;
/** Keeps the page out of search engines and previews (the 404). */
noindex?: boolean;
}
const { title, description, theme, page } = Astro.props;
const { title, description, theme, page, type = 'website', image, feed, noindex = false } = Astro.props;
const { site, siteUrl } = await getSiteData();
const preview = previewTags({
siteUrl, siteName: site.name, path: Astro.url.pathname, title, description, type, noindex,
...(image && { image: { path: `/media/${image.file}`, alt: image.alt } }),
...(feed && { feed: feedLink(feed, site.name) }),
});
// Only the fonts this theme needs (self-hosted via @fontsource). ?url keeps the other themes' CSS unlinked.
const fontCss = { neutral: neutralFonts, gate: gateFonts, work: workFonts, personal: personalFonts }[theme];
@@ -30,6 +47,7 @@ const preloads = theme === 'work' ? [geistSans400, geistSans600, geistMono400] :
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
{preview.map(({ tag, attrs }) => (tag === 'link' ? <link {...attrs} /> : <meta {...attrs} />))}
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
{preloads.map((href) => <link rel="preload" href={href} as="font" type="font/woff2" crossorigin />)}
<link rel="stylesheet" href={fontCss} />
+9 -3
View File
@@ -5,11 +5,17 @@ import EdgeToWork from '../components/personal/EdgeToWork.astro';
import type { Site } from '../../library/types.ts';
import '../styles/personal.css';
import '../styles/personal-cards.css';
interface Props { title: string; description: string; site: Site }
const { title, description, site } = Astro.props;
interface Props {
title: string;
description: string;
site: Site;
type?: 'website' | 'article' | undefined;
image?: { file: string; alt: string } | undefined;
}
const { title, description, site, type, image } = Astro.props;
const { links } = site;
---
<Base title={title} description={description} theme="personal">
<Base title={title} description={description} theme="personal" type={type} image={image} feed="off-the-clock">
<EdgeToWork />
<header class="bar">
<div class="in">
+4 -2
View File
@@ -13,11 +13,13 @@ interface Props {
site: Site;
onIndex?: boolean;
sections: WorkSections;
type?: 'website' | 'article' | undefined;
image?: { file: string; alt: string } | undefined;
}
const { title, description, site, onIndex = false, sections } = Astro.props;
const { title, description, site, onIndex = false, sections, type, image } = Astro.props;
const { links } = site;
---
<Base title={title} description={description} theme="work">
<Base title={title} description={description} theme="work" type={type} image={image} feed="work">
<EdgeToPersonal />
<TopBar name={site.name} onIndex={onIndex} hasFeatured={sections.featured} hasList={sections.list} hasWriting={sections.writing} hasCv={sections.cv} />
<main class="work">
+1 -1
View File
@@ -5,6 +5,6 @@ import { getSiteData } from '../data';
const { site } = await getSiteData();
---
<Base title={`Page not found · ${site.name}`} description="This page does not exist." theme="neutral">
<Base title={`Page not found · ${site.name}`} description="This page does not exist." theme="neutral" noindex>
<Placeholder404 />
</Base>
+17
View File
@@ -0,0 +1,17 @@
// The sitemap (/sitemap.xml) and the Atom feeds (/feed.xml, /work/feed.xml, /off-the-clock/feed.xml), spec 007.
// Without SITE_URL there are no paths, so none of these files is written.
import type { APIRoute, GetStaticPaths } from 'astro';
import { getSiteData } from '../data.ts';
import { xmlFiles } from '../feeds.ts';
export const getStaticPaths = (async () => {
const files = xmlFiles(await getSiteData());
return Object.keys(files).map((file) => ({ params: { file } }));
}) satisfies GetStaticPaths;
export const GET: APIRoute = async ({ params }) => {
const render = xmlFiles(await getSiteData())[params.file ?? ''];
if (!render) return new Response(null, { status: 404 });
const type = params.file === 'sitemap' ? 'application/xml' : 'application/atom+xml';
return new Response(render(), { headers: { 'Content-Type': `${type}; charset=utf-8` } });
};
+1 -1
View File
@@ -41,7 +41,7 @@ const years = [...new Set(everything.map((e) => e.date.slice(0, 4)))];
const inYear = (y: string) => everything.filter((e) => e.date.startsWith(y));
const sideCount = (s: string) => everything.filter((e) => e.side === s).length;
---
<Base title={`Everything at once · ${site.name}`} description={`Everything on ${first}'s site in one list: projects and writing from work, and everything from off the clock.`} theme="neutral" page="everything">
<Base title={`Everything at once · ${site.name}`} description={`Everything on ${first}'s site in one list: projects and writing from work, and everything from off the clock.`} theme="neutral" page="everything" feed="all">
<header class="ev-bar">
<div class="ev-in">
<a class="ev-back" href="/" title="back to the start page">← gate</a>
+1 -1
View File
@@ -42,7 +42,7 @@ const surname = rest.join(' ');
// hidden for now, on the owner's request; see docs/TODO.md. /everything/ itself stays up.
const SHOW_EVERYTHING_PILL = false;
---
<Base title={site.name} description={site.tagline || `${site.name}: work and off the clock.`} theme="gate">
<Base title={site.name} description={site.tagline || `${site.name}: work and off the clock.`} theme="gate" feed="all">
<main class="gate" id="gate">
<h1 class="sr-only">{site.name}</h1>
<GateSide side="work" href="/work/" label="Work: technical portfolio" enter="enter work">
+3 -2
View File
@@ -2,7 +2,7 @@
// An item's (or an off-the-clock Ghost post's) own page: the same card as on the wall, already open.
import PersonalLayout from '../../layouts/Personal.astro';
import Card from '../../components/personal/Card.astro';
import { toEntries, type Entry } from '../../components/personal/entries.ts';
import { images, toEntries, type Entry } from '../../components/personal/entries.ts';
import { getSiteData } from '../../data';
export async function getStaticPaths() {
@@ -19,8 +19,9 @@ const { entry, newer, older } = Astro.props as { entry: Entry; newer: Entry | nu
const { site } = await getSiteData();
const first = site.name.split(' ')[0] ?? site.name;
const description = entry.line || entry.excerpt || entry.title;
const picture = images(entry)[0]; // the link preview's image (spec 007)
---
<PersonalLayout title={`${entry.title} · ${site.name}`} description={description.slice(0, 200)} site={site}>
<PersonalLayout title={`${entry.title} · ${site.name}`} description={description.slice(0, 200)} site={site} type="article" image={picture?.file ? { file: picture.file, alt: picture.alt } : undefined}>
<div class="item-page">
<a class="up" href="/off-the-clock/">← the whole wall</a>
{entry.ai && (
+1 -1
View File
@@ -22,7 +22,7 @@ const contact = [
links.bluesky && { label: 'Bluesky', text: host(links.bluesky), href: links.bluesky },
].filter((c): c is { label: string; text: string; href: string } => !!c);
---
<Base title={`${site.name} · the short version`} description={`${site.name}: ${site.tagline}`} theme="neutral" page="simple">
<Base title={`${site.name} · the short version`} description={`${site.name}: ${site.tagline}`} theme="neutral" page="simple" feed="all">
<div class="sm">
<aside class="sm-side">
<h1>{site.name}</h1>
+2
View File
@@ -16,6 +16,8 @@ const { site, projects, workPosts } = await getSiteData();
<WorkLayout
title={`${project.title} · ${site.name}`}
description={project.summary}
type="article"
image={project.image}
site={site}
sections={workSections(site, projects, workPosts)}
>
+1
View File
@@ -17,6 +17,7 @@ const topic = topicTag(post.tags);
<WorkLayout
title={`${post.title} · ${site.name}`}
description={post.excerpt || post.title}
type="article"
site={site}
sections={workSections(site, projects, workPosts)}
>
+179
View File
@@ -0,0 +1,179 @@
import { beforeAll, describe, expect, test } from 'bun:test';
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative, sep } from 'node:path';
import { loadSources } from '../../src/sources/index.ts';
import { loadSiteData, type SiteData } from '../../src/site/data.ts';
import { feedEntries } from '../../src/site/feeds.ts';
import { build, page } from './fixture-sites.ts';
import { child, childrenNamed, parseXml } from './xml-check.ts';
// Spec 007: the fixture site built with a synthetic SITE_URL. One item's title is made hostile (in a copy of
// the fixtures) so escaping is checked end to end.
const SITE = 'https://example.invalid';
const HOSTILE = `Edge & <case> "quoted" it's`;
let out: string;
let data: SiteData;
beforeAll(async () => {
const content = mkdtempSync(join(tmpdir(), 'site-content-'));
cpSync('fixtures/content', content, { recursive: true });
const item = join(content, 'items', 'edge-long-title.md');
writeFileSync(item, readFileSync(item, 'utf8').replace(/^title: .*$/m, `title: '${HOSTILE.replace(/'/g, "''")}'`));
out = mkdtempSync(join(tmpdir(), 'site-build-meta-'));
build(content, out, { SOURCES_FIXTURES: 'fixtures/sources', SITE_URL: SITE });
const cacheDir = mkdtempSync(join(tmpdir(), 'site-cache-'));
const sources = await loadSources({ cacheDir, env: { SOURCES_FIXTURES: 'fixtures/sources' } });
writeFileSync(join(cacheDir, 'sources.json'), JSON.stringify(sources));
data = await loadSiteData(content, cacheDir, undefined, undefined, SITE);
}, 300_000);
const decode = (s: string) =>
s.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
type Tag = Record<string, string>;
/** The <link> and <meta> tags of a page's head, attributes decoded. */
function headTags(html: string): Tag[] {
const head = html.slice(html.indexOf('<head>'), html.indexOf('</head>'));
// Attribute values are double-quoted and may hold a raw `<` or `>` (valid HTML), so match them whole.
return [...head.matchAll(/<(link|meta)\b((?:\s+[\w:-]+(?:="[^"]*")?)*)\s*\/?>/g)].map((m) => {
const attrs: Tag = { tag: m[1]! };
for (const a of m[2]!.matchAll(/([\w:-]+)="([^"]*)"/g)) attrs[a[1]!] = decode(a[2]!);
return attrs;
});
}
const prop = (tags: Tag[], name: string) => tags.filter((t) => t.property === name || t.name === name).map((t) => t.content);
const links = (tags: Tag[], rel: string) => tags.filter((t) => t.tag === 'link' && t.rel === rel);
const titleOf = (html: string) => decode(html.match(/<title>([^<]*)<\/title>/)![1]!);
/** Every built HTML page as its served path; the 404 (`404.html`) as `/404/`. */
function builtPages(root: string): string[] {
const found: string[] = [];
const walk = (dir: string) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) walk(join(dir, e.name));
else if (e.name === 'index.html') found.push(relative(root, dir) ? `/${relative(root, dir).split(sep).join('/')}/` : '/');
else if (e.name.endsWith('.html')) found.push(`/${e.name.replace(/\.html$/, '')}/`);
}
};
walk(root);
return found.sort();
}
const fileOf = (path: string) => join(path.slice(1), 'index.html');
describe('link previews on every page', () => {
test('canonical and og:url are the page address; og:title and og:description are its title and description', () => {
const pages = builtPages(out).filter((p) => p !== '/404/');
expect(pages.length).toBeGreaterThan(20);
for (const p of pages) {
const html = page(out, fileOf(p));
const tags = headTags(html);
expect(links(tags, 'canonical').map((l) => l.href)).toEqual([`${SITE}${p}`]);
expect(prop(tags, 'og:url')).toEqual([`${SITE}${p}`]);
expect(prop(tags, 'og:title')).toEqual([titleOf(html)]);
expect(prop(tags, 'og:description')).toEqual(prop(tags, 'description'));
expect(prop(tags, 'og:site_name')).toEqual([data.site.name]);
expect(prop(tags, 'robots')).toEqual([]);
}
});
test('og:image exactly where the page has its own image, with the large card', () => {
const expected = new Map<string, string>();
for (const p of data.projects) if (p.image) expected.set(`/work/${p.id}/`, p.image.file);
for (const i of data.items) {
const img = i.media.find((m) => m.file && /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(m.file));
if (img) expected.set(`/off-the-clock/${i.id}/`, img.file!);
}
expect(expected.size).toBeGreaterThan(5);
for (const p of builtPages(out).filter((x) => x !== '/404/')) {
const tags = headTags(page(out, fileOf(p)));
const file = expected.get(p);
expect({ p, image: prop(tags, 'og:image') }).toEqual({ p, image: file ? [`${SITE}/media/${file}`] : [] });
expect({ p, card: prop(tags, 'twitter:card') }).toEqual({ p, card: [file ? 'summary_large_image' : 'summary'] });
}
});
test('a page of each kind: its type and its feed', () => {
const kinds: [string, 'website' | 'article', string][] = [
['/', 'website', '/feed.xml'],
['/everything/', 'website', '/feed.xml'],
['/simple/', 'website', '/feed.xml'],
['/work/', 'website', '/work/feed.xml'],
['/work/sample-garden-bot/', 'article', '/work/feed.xml'],
['/work/fixture-dashboard/', 'article', '/work/feed.xml'],
['/work/writing/synthetic-work-post/', 'article', '/work/feed.xml'],
['/off-the-clock/', 'website', '/off-the-clock/feed.xml'],
['/off-the-clock/fixture-comic/', 'article', '/off-the-clock/feed.xml'],
['/off-the-clock/edge-long-title/', 'article', '/off-the-clock/feed.xml'],
['/off-the-clock/synthetic-essay/', 'article', '/off-the-clock/feed.xml'],
['/off-the-clock/issues/', 'website', '/off-the-clock/feed.xml'],
['/off-the-clock/issues/2025-h2/', 'website', '/off-the-clock/feed.xml'],
];
for (const [p, type, feed] of kinds) {
const tags = headTags(page(out, fileOf(p)));
expect({ p, type: prop(tags, 'og:type') }).toEqual({ p, type: [type] });
const alt = links(tags, 'alternate');
expect({ p, feeds: alt.map((l) => [l.type, l.href]) }).toEqual({ p, feeds: [['application/atom+xml', `${SITE}${feed}`]] });
expect(existsSync(join(out, feed.slice(1)))).toBe(true);
}
// A hostile title reaches the head as text.
const hostile = headTags(page(out, fileOf('/off-the-clock/edge-long-title/')));
expect(prop(hostile, 'og:title')).toEqual([`${HOSTILE} · ${data.site.name}`]);
// Astro leaves `<` raw inside a double-quoted attribute (valid HTML); it must never appear as markup.
expect(page(out, fileOf('/off-the-clock/edge-long-title/')).replace(/="[^"]*"/g, '=""')).not.toContain('<case>');
});
test('the 404 is noindex and carries no preview, canonical or feed', () => {
const tags = headTags(page(out, '404.html'));
expect(prop(tags, 'robots')).toEqual(['noindex']);
expect(tags.some((t) => t.property?.startsWith('og:') || t.name === 'twitter:card')).toBe(false);
expect(links(tags, 'canonical')).toEqual([]);
expect(links(tags, 'alternate')).toEqual([]);
});
});
describe('sitemap', () => {
test('lists exactly the built pages, except the 404, with absolute URLs', () => {
const root = parseXml(page(out, 'sitemap.xml'));
const locs = childrenNamed(root, 'url').map((u) => child(u, 'loc')!.text);
expect(new Set(locs).size).toBe(locs.length);
expect([...locs].sort()).toEqual(builtPages(out).filter((p) => p !== '/404/').map((p) => `${SITE}${p}`).sort());
});
});
describe('feeds', () => {
const cases = [
['feed.xml', 'all', (d) => d.site.name],
['work/feed.xml', 'work', (d) => `Work · ${d.site.name}`],
['off-the-clock/feed.xml', 'off-the-clock', (d) => `Off the clock · ${d.site.name}`],
] as const satisfies readonly (readonly [string, 'all' | 'work' | 'off-the-clock', (d: SiteData) => string])[];
test('each is well-formed Atom with the expected entries, order, ids, dates and categories', () => {
for (const [file, name, title] of cases) {
const root = parseXml(page(out, file));
expect(child(root, 'title')!.text).toBe(title(data));
expect(child(root, 'id')!.text).toBe(`${SITE}/${file}`);
const want = feedEntries(data, name);
expect(want.length).toBeGreaterThan(0);
expect(child(root, 'updated')!.text).toBe(`${want[0]!.date}T00:00:00Z`);
const entries = childrenNamed(root, 'entry');
expect(entries.map((e) => child(e, 'id')!.text)).toEqual(want.map((w) => `${SITE}${w.path}`));
expect(entries.map((e) => child(e, 'link')!.attrs.href)).toEqual(want.map((w) => `${SITE}${w.path}`));
expect(entries.map((e) => child(e, 'title')!.text)).toEqual(want.map((w) => w.title));
expect(entries.map((e) => child(e, 'updated')!.text)).toEqual(want.map((w) => `${w.date}T00:00:00Z`));
expect(entries.map((e) => child(e, 'category')?.attrs.term ?? null)).toEqual(want.map((w) => w.ai));
expect(entries.map((e) => child(e, 'summary')?.text)).toEqual(want.map((w) => w.summary));
// Every entry is a page the site built.
for (const w of want) expect(existsSync(join(out, fileOf(w.path)))).toBe(true);
}
});
test('the hostile title is escaped and reads back as written', () => {
const raw = page(out, 'off-the-clock/feed.xml');
expect(raw).not.toContain('<case>');
const titles = childrenNamed(parseXml(raw), 'entry').map((e) => child(e, 'title')!.text);
expect(titles).toContain(HOSTILE);
expect(childrenNamed(parseXml(page(out, 'feed.xml')), 'entry').map((e) => child(e, 'title')!.text)).toContain(HOSTILE);
});
});
+26
View File
@@ -200,3 +200,29 @@ describe('start page without React (spec 006)', () => {
expect([...scriptsOf(out, 'work/index.html').values()].some(isReact)).toBe(true);
});
});
describe('without SITE_URL (spec 007)', () => {
// Both fixture sites here are built without an address; build-meta.test.ts builds one with it.
const files = (root: string): string[] =>
readdirSync(root, { recursive: true, encoding: 'utf8' }).filter((f) => !f.startsWith('media'));
test('the build succeeds with no sitemap, no feeds and nothing in a head that needs an absolute URL', () => {
for (const root of [out, sparseOut]) {
expect(files(root).filter((f) => f.endsWith('.xml'))).toEqual([]);
const pages = files(root).filter((f) => f.endsWith('.html'));
expect(pages.length).toBeGreaterThan(root === out ? 20 : 5);
for (const f of pages) {
const html = page(root, f);
const head = html.slice(0, html.indexOf('</head>'));
expect(head).not.toContain('rel="canonical"');
expect(head).not.toContain('og:url');
expect(head).not.toContain('og:image');
expect(head).not.toContain('application/atom+xml');
expect(html).not.toMatch(/(feed|sitemap)\.xml/);
// The preview text needs no address, so it is still there (except on the 404).
if (f === '404.html') expect(head).toContain('<meta name="robots" content="noindex">');
else expect(head).toMatch(/<meta property="og:title" content="[^"]+">/);
}
}
});
});
+4 -3
View File
@@ -3,10 +3,11 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Builds the fixture sites with the real CLI (a child process keeps env and cwd changes out of the
// test runner), for tests that check the rendered HTML.
const SOURCE_ENV = ['GHOST_URL', 'GHOST_CONTENT_KEY', 'TRAKT_CLIENT_ID', 'HARDCOVER_TOKEN', 'SOURCES_FIXTURES'];
// test runner), for tests that check the rendered HTML. Source settings and SITE_URL from the shell are
// cleared; a test passes what it needs.
const SOURCE_ENV = ['GHOST_URL', 'GHOST_CONTENT_KEY', 'TRAKT_CLIENT_ID', 'HARDCOVER_TOKEN', 'SOURCES_FIXTURES', 'SITE_URL'];
function build(content: string, target: string, extraEnv: Record<string, string>) {
export function build(content: string, target: string, extraEnv: Record<string, string>) {
const env: Record<string, string | undefined> = { ...process.env };
for (const k of SOURCE_ENV) delete env[k];
const r = Bun.spawnSync(['bun', 'src/cli.ts', 'build', content, target, mkdtempSync(join(tmpdir(), 'site-build-cache-'))], {