Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
658d95f
TKW: give events `aliases` and `mentions`
codebend3r Sep 18, 2026
627a575
TKW: link castles, battles, and events from prose bodies
codebend3r Sep 18, 2026
f4709d5
TKW: render prose links on battle and event pages
codebend3r Sep 18, 2026
9bef051
TKW: curate `mentions` for battle and event bodies
codebend3r Sep 18, 2026
2c29c63
TKW: teach `populate-entry` and the orphan audit the wider linker
codebend3r Sep 18, 2026
b7d4a6e
TKW: let `mentions` settle a shared name in the prose linker
codebend3r Sep 18, 2026
783b6a0
TKW: add regnal aliases to the Targaryen kings
codebend3r Sep 18, 2026
45b2ff1
TKW: populate `jaenara-belaerys.md` from AWOIAF
codebend3r Sep 18, 2026
09a57bc
TKW: populate `aurion.md` from AWOIAF
codebend3r Sep 18, 2026
de05508
TKW: populate `serwyn-of-the-mirror-shield.md` from AWOIAF
codebend3r Sep 18, 2026
a558d5b
TKW: populate `grey-king.md` from AWOIAF
codebend3r Sep 18, 2026
f912cde
TKW: stop pinning the dragon roster in `content.test.ts`
codebend3r Sep 18, 2026
aae2f84
TKW: populate `drogon.md` from AWOIAF
codebend3r Sep 18, 2026
4b4726b
TKW: populate `rhaegal.md` from AWOIAF
codebend3r Sep 18, 2026
4c5350e
TKW: populate `viserion.md` from AWOIAF
codebend3r Sep 18, 2026
384d662
TKW: populate `meleys.md` from AWOIAF
codebend3r Sep 18, 2026
43195da
TKW: populate `tessarion.md` from AWOIAF
codebend3r Sep 18, 2026
c887d15
TKW: populate `silverwing.md` from AWOIAF
codebend3r Sep 18, 2026
f8d6790
TKW: populate `seasmoke.md` from AWOIAF
codebend3r Sep 18, 2026
ed7f7fb
TKW: populate `dreamfyre.md` from AWOIAF
codebend3r Sep 18, 2026
744f01f
TKW: populate `vermax.md` from AWOIAF
codebend3r Sep 18, 2026
2d2acff
TKW: populate `arrax.md` from AWOIAF
codebend3r Sep 18, 2026
b7c7dd7
TKW: populate `syrax.md` from AWOIAF
codebend3r Sep 18, 2026
20dd080
TKW: populate `sheepstealer.md` from AWOIAF
codebend3r Sep 18, 2026
bda92f3
TKW: populate `tyraxes.md` from AWOIAF
codebend3r Sep 18, 2026
3ee8185
TKW: populate `moondancer.md` from AWOIAF
codebend3r Sep 18, 2026
1db3948
TKW: populate `grey-ghost.md` from AWOIAF
codebend3r Sep 18, 2026
84a3781
TKW: populate `quicksilver.md` from AWOIAF
codebend3r Sep 18, 2026
758adef
TKW: populate `stormcloud.md` from AWOIAF
codebend3r Sep 18, 2026
8ba1ff7
TKW: populate `shrykos.md` from AWOIAF
codebend3r Sep 18, 2026
27c71be
TKW: populate `morghul.md` from AWOIAF
codebend3r Sep 18, 2026
5f7fa68
TKW: populate `terrax.md` from AWOIAF
codebend3r Sep 18, 2026
dd37917
TKW: populate `urrax.md` from AWOIAF
codebend3r Sep 18, 2026
de37de0
TKW: populate `nagga.md` from AWOIAF
codebend3r Sep 18, 2026
1986b77
TKW: populate `last-dragon.md` from AWOIAF
codebend3r Sep 18, 2026
1193475
TKW: populate `morning.md` from AWOIAF
codebend3r Sep 18, 2026
93f9a3c
TKW: pin namesake `mentions` on the new dragons
codebend3r Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions .claude/skills/orphan-content/audit-orphans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,10 +377,9 @@ function mentionEdges({
/**
* Which bodies `remarkProseLinks` would turn into a link to each target.
*
* Only `characters`, `houses`, `weapons` and `dragons` render markdown with a
* prose-link index; `app/battles`, `app/castles` and `app/events` call
* `renderMarkdown` with no index, so their bodies emit no links at all and are
* excluded as sources.
* Every detail page except `app/castles` renders markdown with a prose-link
* index, so castle bodies emit no links at all and are excluded as sources.
* Castles, battles and events are still link targets from every other body.
*
* The index is built with an empty `mentions` list on purpose. Mentions only
* widen a target's surface forms (a bare first name, a bare house name), and
Expand All @@ -394,6 +393,9 @@ function proseEdges(collections: Collections): Edge[] {
allHouses: collections.houses,
allWeapons: collections.weapons,
allDragons: collections.dragons,
allCastles: collections.castles,
allBattles: collections.battles,
allEvents: collections.events,
current: { kind: "character", slug: "", mentions: [] },
});

Expand All @@ -402,6 +404,9 @@ function proseEdges(collections: Collections): Edge[] {
house: "houses",
weapon: "weapons",
dragon: "dragons",
castle: "castles",
battle: "battles",
event: "events",
} as const satisfies Record<string, CollectionName>;

// First form wins, matching `compileIndex` in `lib/prose-links.ts`. Two
Expand Down Expand Up @@ -452,16 +457,26 @@ function proseEdges(collections: Collections): Edge[] {
slug: entry.slug,
body: entry.body,
})),
...collections.battles.map((entry) => ({
collection: "battles" as const,
slug: entry.slug,
body: entry.body,
})),
...collections.events.map((entry) => ({
collection: "events" as const,
slug: entry.slug,
body: entry.body,
})),
];

return linkingBodies.flatMap(({ collection, slug, body }) => {
const from = keyOf({ collection, slug });
const matched = body.match(pattern) ?? [];
const hit = matched.reduce<Set<EntryKey>>((seen, form) => {
const to = formToKey.get(form);
// `compileIndex` drops any target sharing the page's own slug, across
// collections, because `selfSlug` is a bare slug.
if (to && !to.endsWith(`/${slug}`)) seen.add(to);
// `compileIndex` drops only the page's own entry; another collection's
// entry with the same slug still links, matching `targetKey`.
if (to && to !== from) seen.add(to);
return seen;
}, new Set());
return [...hit].map((to) => ({ from, field: "prose", to }));
Expand Down
53 changes: 30 additions & 23 deletions .claude/skills/populate-entry/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ The research half of the job is identical across all five: one AWOIAF article, o

**The core insight: an empty field is not automatically a gap.** Several schema fields are empty on every entry in their collection because nothing renders them, so filling one in is noise, not progress:

| Field | Filled | Why it stays empty |
| ---------------------- | ------ | ------------------------------------------------------------------------------------ |
| `castles.sworn-houses` | 0/146 | Only `lib/relations.ts` reads it, and nothing in `app/` calls `buildRelationGraph`. |
| `battles.mentions` | 0/72 | `buildProseLinkIndex` never runs on a battle page, so `mentions` cannot do anything. |
| `events.participants` | 0/53 | The event page renders subtitle, body, and sources. Nothing else. |
| `events.casualties` | 0/53 | Same. |
| Field | Filled | Why it stays empty |
| ---------------------- | ------ | ----------------------------------------------------------------------------------- |
| `castles.sworn-houses` | 0/146 | Only `lib/relations.ts` reads it, and nothing in `app/` calls `buildRelationGraph`. |
| `events.participants` | 0/53 | The event page renders subtitle, body, and sources. Nothing else. |
| `events.casualties` | 0/53 | Same. |

`audit-entries.ts` encodes this: a field only counts against an entry when the rest of its own collection fills it. Score is deviation from the collection norm, not distance from the schema.

Expand Down Expand Up @@ -75,7 +74,11 @@ Voice notes, matching `harrenhal.md`, `dreadfort.md`, and `battle-of-the-blackwa
- Never invent canon. If AWOIAF does not record it, leave it out.
- No em dashes or en dashes anywhere in this repo.

Prose auto-linking runs on characters, houses, weapons, and dragons only. `app/castles/[slug]`, `app/battles/[slug]`, and `app/events/[slug]` call `renderMarkdown` without a `proseLinks` index, so nothing in those three bodies becomes a link. Write names freely there; also do not expect the reader to be able to click them.
Prose auto-linking runs on character, house, weapon, dragon, battle, and event pages. Only `app/castles/[slug]` still calls `renderMarkdown` without a `proseLinks` index, so a castle body never links out. The link targets are characters, houses, weapons, dragons, castles, battles, and events; each is matched on its `name` and `aliases`, and a name that starts with "The" also matches without the article, so "the Twins" and "the Red Wedding" link. Three rules follow from `lib/prose-links.ts`:

- `mentions` widens the match. A character listed there also links on first name alone ("Lord Tywin"), and a house listed there links on its bare name ("the Lannister host"). List one character per first name: a shared first name goes to whichever target registers first, so two Aegons in one `mentions` list means one of them never links.
- A castle that shares its name with a house (Darry, Rosby, the Hightower) never auto-links, because `mentions` carries bare slugs and cannot tell the castle from the house. Write an explicit markdown link when the castle is meant.
- Write `aliases` without a leading article ("Doom", not "the Doom"): the alias would otherwise win the match at "the" and swallow the longer "Doom of Valyria".

## Step 3: frontmatter, per collection

Expand Down Expand Up @@ -113,8 +116,8 @@ The most complex frontmatter in the repo, and every field below renders in `Batt
| `victor` | no | 63/72. The winning `side` label, spelled the same way. Omit only when the outcome was genuinely undecided. |
| `outcome` | no | 69/72. One sentence, present tense. |
| `casualties[]` | no | 33/72. Character slugs. Renders as "Fallen". |
| `aliases[]` | no | 23/72. Renders as "Also called". |
| `mentions[]` | no | Leave empty. See the overview table. |
| `aliases[]` | no | 23/72. Renders as "Also called", and every alias is a prose-link surface form. |
| `mentions[]` | no | Characters the body names by first name only, and houses it names by bare name. One character per first name. |

**The `region` and Essos trap.** `landmassForBattle` in `lib/timeline.ts` puts a battle in the Westeros timeline column when it has any `region`, and otherwise checks a hardcoded `ESSOS_SLUGS` set. So an Essos battle needs **both** no `region` **and** an entry in `ESSOS_SLUGS`. Adding `region: crownlands` to `battle-of-meereen` to clear an audit gap would silently move it to the wrong column. Battles beyond the Wall and realm-wide wars correctly carry no `region` either.

Expand All @@ -127,6 +130,8 @@ The most complex frontmatter in the repo, and every field below renders in `Batt
| `location` | yes | 53/53 store a **display string** ("King's Landing", "Vaes Dothrak"), never a slug. |
| `landmass` | yes | `westeros`, `essos`, `summer-isles`. This alone picks the timeline column; events need no `ESSOS_SLUGS`. |
| `outcome` | no | 53/53 carry it even though nothing renders it. Keep the convention. |
| `aliases` | no | Prose-link surface forms for the event, written without a leading article. |
| `mentions` | no | Same rule as battles: first-name characters and bare-name houses the body uses. One character per first name. |

`buildRelationGraph` in `lib/relations.ts` keys `eventsByLocation` off `location` as though it were a castle slug. No entry stores a slug there, so that map is empty. Do not "fix" one entry to a slug; the page prints `location` verbatim in the subtitle.

Expand Down Expand Up @@ -170,6 +175,7 @@ Seven entries, all populated. Use this section when adding an eighth.
| `dragons.house` | `content/houses/` |
| `dragons.riders[]` | `content/characters/` |
| `weapons.mentions[]`, `dragons.mentions[]` | any entity slug |
| `battles.mentions[]`, `events.mentions[]` | any entity slug |

Check before writing a slug: `ls content/houses/<slug>.md`. Do not create a stub in another collection just to satisfy a reference; drop the reference instead.

Expand Down Expand Up @@ -224,20 +230,21 @@ bun run build # static export; run it after touching cont

## Common mistakes

| Mistake | Why it goes wrong |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Filling every empty field the schema allows | `sworn-houses`, `battles.mentions`, `events.participants`, and `events.casualties` are empty corpus-wide. |
| Adding `region` to an Essos battle to clear a gap | Moves it to the Westeros timeline column. `region` plus `ESSOS_SLUGS` decide the column together. |
| Writing a castle slug into `events.location` | All 53 entries store a display string, and the page prints it verbatim. |
| Putting `## ` headings in a battle, event, weapon, or dragon body | 0 of those 162 entries use headings. Only castles do. |
| Writing a castle body of three or four sections | The longest castle in the repo is 1,097 non-whitespace characters. Median is 593. |
| `precision: exact` on a legendary date | Drops the asterisk the timeline uses to mark approximate dates. |
| Referencing a character or house slug that does not exist | `lib/content-integrity.test.ts` fails the build. There is no graceful fallback here. |
| Expecting names in a castle or battle body to auto-link | Those pages call `renderMarkdown` without a `proseLinks` index. |
| Retrying plain `WebFetch` on awoiaf.westeros.org | Cloudflare 403s every page. Go straight to the CDX pipeline. |
| Citing the `web.archive.org` URL in `sources` | The mirror is the fetch mechanism, not the citation. |
| `bun test` instead of `bun run test` | The script is `bun test --isolate --dots`; the bare form mis-reports the DOM suite. |
| Committing without `bun format` | `oxfmt` covers `.claude/**/*.ts` and markdown, and CI fails on drift. |
| Mistake | Why it goes wrong |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Filling every empty field the schema allows | `sworn-houses`, `events.participants`, and `events.casualties` are empty corpus-wide. |
| Adding `region` to an Essos battle to clear a gap | Moves it to the Westeros timeline column. `region` plus `ESSOS_SLUGS` decide the column together. |
| Writing a castle slug into `events.location` | All 53 entries store a display string, and the page prints it verbatim. |
| Putting `## ` headings in a battle, event, weapon, or dragon body | 0 of those 162 entries use headings. Only castles do. |
| Writing a castle body of three or four sections | The longest castle in the repo is 1,097 non-whitespace characters. Median is 593. |
| `precision: exact` on a legendary date | Drops the asterisk the timeline uses to mark approximate dates. |
| Referencing a character or house slug that does not exist | `lib/content-integrity.test.ts` fails the build. There is no graceful fallback here. |
| Expecting names in a castle body to auto-link | The castle page calls `renderMarkdown` without a `proseLinks` index. Battles and events do link. |
| Listing two characters who share a first name in `mentions` | The first to register takes the bare name; the other never links on it. |
| Retrying plain `WebFetch` on awoiaf.westeros.org | Cloudflare 403s every page. Go straight to the CDX pipeline. |
| Citing the `web.archive.org` URL in `sources` | The mirror is the fetch mechanism, not the citation. |
| `bun test` instead of `bun run test` | The script is `bun test --isolate --dots`; the bare form mis-reports the DOM suite. |
| Committing without `bun format` | `oxfmt` covers `.claude/**/*.ts` and markdown, and CI fails on drift. |

## Related skills

Expand Down
36 changes: 34 additions & 2 deletions app/battles/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import {
loadAllBattles,
loadAllHouses,
loadAllCharacters,
loadAllWeapons,
loadAllDragons,
loadAllCastles,
loadAllEvents,
renderMarkdown,
} from "@/lib/content";
import { buildProseLinkIndex } from "@/lib/prose-links";
import { PlateLayout } from "@/components/PlateLayout";
import { Sources } from "@/components/Sources";
import { BattleInfobox } from "@/components/BattleInfobox";
Expand Down Expand Up @@ -42,10 +47,25 @@ export default async function BattlePage({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const [battle, allHouses, allCharacters, image] = await Promise.all([
const [
battle,
allHouses,
allCharacters,
allWeapons,
allDragons,
allCastles,
allBattles,
allEvents,
image,
] = await Promise.all([
loadBattle(slug).catch(() => null),
loadAllHouses(),
loadAllCharacters(),
loadAllWeapons(),
loadAllDragons(),
loadAllCastles(),
loadAllBattles(),
loadAllEvents(),
findBattleImage(slug),
]);
if (!battle) notFound();
Expand All @@ -54,7 +74,19 @@ export default async function BattlePage({
const charactersBySlug = bySlug(allCharacters);

const fm = battle.frontmatter;
const html = battle.body.trim() ? await renderMarkdown(battle.body) : "";
const proseLinks = buildProseLinkIndex({
allCharacters,
allHouses,
allWeapons,
allDragons,
allCastles,
allBattles,
allEvents,
current: { kind: "battle", slug, mentions: fm.mentions },
});
const html = battle.body.trim()
? await renderMarkdown(battle.body, { proseLinks })
: "";
const subtitle = [formatBattleWhen(fm.start, fm.end), fm.war]
.filter(Boolean)
.join(" · ");
Expand Down
33 changes: 25 additions & 8 deletions app/characters/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
loadAllHouses,
loadAllWeapons,
loadAllDragons,
loadAllCastles,
loadAllBattles,
loadAllEvents,
loadCharacter,
renderMarkdown,
} from "@/lib/content";
Expand Down Expand Up @@ -89,14 +92,25 @@ export default async function CharacterPage({

const fm = character.frontmatter;

const [allCharacters, allHouses, allWeapons, allDragons, portraits] =
await Promise.all([
loadAllCharacters(),
loadAllHouses(),
loadAllWeapons(),
loadAllDragons(),
findPortraitVariants({ slug, name: fm.name, sex: fm.sex }),
]);
const [
allCharacters,
allHouses,
allWeapons,
allDragons,
allCastles,
allBattles,
allEvents,
portraits,
] = await Promise.all([
loadAllCharacters(),
loadAllHouses(),
loadAllWeapons(),
loadAllDragons(),
loadAllCastles(),
loadAllBattles(),
loadAllEvents(),
findPortraitVariants({ slug, name: fm.name, sex: fm.sex }),
]);

const charactersBySlug = bySlug(allCharacters);
const housesBySlug = bySlug(allHouses);
Expand Down Expand Up @@ -152,6 +166,9 @@ export default async function CharacterPage({
slug: d.slug,
frontmatter: d.frontmatter,
})),
allCastles,
allBattles,
allEvents,
current: { kind: "character", slug, mentions: fm.mentions },
});
const html = character.body.trim()
Expand Down
33 changes: 25 additions & 8 deletions app/dragons/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
loadAllWeapons,
loadAllHouses,
loadAllCharacters,
loadAllCastles,
loadAllBattles,
loadAllEvents,
renderMarkdown,
} from "@/lib/content";
import { buildProseLinkIndex } from "@/lib/prose-links";
Expand Down Expand Up @@ -42,14 +45,25 @@ export default async function DragonPage({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const [dragon, allHouses, allCharacters, allWeapons, allDragons] =
await Promise.all([
loadDragon(slug).catch(() => null),
loadAllHouses(),
loadAllCharacters(),
loadAllWeapons(),
loadAllDragons(),
]);
const [
dragon,
allHouses,
allCharacters,
allWeapons,
allDragons,
allCastles,
allBattles,
allEvents,
] = await Promise.all([
loadDragon(slug).catch(() => null),
loadAllHouses(),
loadAllCharacters(),
loadAllWeapons(),
loadAllDragons(),
loadAllCastles(),
loadAllBattles(),
loadAllEvents(),
]);
if (!dragon) notFound();

const housesBySlug = bySlug(allHouses);
Expand All @@ -73,6 +87,9 @@ export default async function DragonPage({
slug: d.slug,
frontmatter: d.frontmatter,
})),
allCastles,
allBattles,
allEvents,
current: { kind: "dragon", slug, mentions: dragon.frontmatter.mentions },
});
const html =
Expand Down
Loading
Loading