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:
1 parent
765b6ba906
commit
ab611c2de8
17 files changed
+297
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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` } });
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/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);
|
||||
});
|
||||
});
|
||||
@@ -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="[^"]+">/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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-'))], {
|
||||
|
||||
Reference in new issue
Block a user