From 8e362a09f3620c5f878897e420383d0e54454fc1 Mon Sep 17 00:00:00 2001 From: CJ Rivas Date: Sun, 12 Jul 2026 11:07:08 -0400 Subject: [PATCH 1/5] TKW: enforce content cross-reference integrity - correct malformed and stale entity references\n- add placeholder entries for referenced but unwritten characters\n- validate unique slugs, filename slugs, and cross-collection links --- content/characters/antario-jast.md | 11 ++ content/characters/caggo.md | 11 ++ content/characters/dunstan-drumm.md | 11 ++ content/characters/edmure-tully.md | 11 ++ content/characters/elbert-arryn.md | 11 ++ content/characters/ella-lannister.md | 5 +- content/characters/erik-ironmaker.md | 11 ++ content/characters/garin.md | 11 ++ content/characters/goren-greyjoy.md | 11 ++ content/characters/harras-harlaw.md | 11 ++ content/characters/hugh-hammer.md | 11 ++ content/characters/jon-arryn.md | 2 +- content/characters/larra-rogare.md | 2 +- content/characters/leyton-hightower.md | 11 ++ content/characters/martyn-tyrell.md | 11 ++ content/characters/moredo-rogare.md | 11 ++ content/characters/mya-royce.md | 11 ++ content/characters/robert-arryn.md | 2 +- content/characters/robett-glover.md | 11 ++ content/characters/sandoq.md | 11 ++ content/dragons/caraxes.md | 2 +- content/houses/baratheon.md | 4 +- content/houses/drumm.md | 2 +- content/houses/harlaw-grey-garden.md | 2 +- content/houses/lannister.md | 3 +- content/houses/roxton.md | 2 +- content/weapons/truth.md | 1 - lib/content-integrity.test.ts | 38 ++++ lib/content-integrity.ts | 256 +++++++++++++++++++++++++ 29 files changed, 481 insertions(+), 16 deletions(-) create mode 100644 content/characters/antario-jast.md create mode 100644 content/characters/caggo.md create mode 100644 content/characters/dunstan-drumm.md create mode 100644 content/characters/edmure-tully.md create mode 100644 content/characters/elbert-arryn.md create mode 100644 content/characters/erik-ironmaker.md create mode 100644 content/characters/garin.md create mode 100644 content/characters/goren-greyjoy.md create mode 100644 content/characters/harras-harlaw.md create mode 100644 content/characters/hugh-hammer.md create mode 100644 content/characters/leyton-hightower.md create mode 100644 content/characters/martyn-tyrell.md create mode 100644 content/characters/moredo-rogare.md create mode 100644 content/characters/mya-royce.md create mode 100644 content/characters/robett-glover.md create mode 100644 content/characters/sandoq.md create mode 100644 lib/content-integrity.test.ts create mode 100644 lib/content-integrity.ts diff --git a/content/characters/antario-jast.md b/content/characters/antario-jast.md new file mode 100644 index 00000000..fe945402 --- /dev/null +++ b/content/characters/antario-jast.md @@ -0,0 +1,11 @@ +--- +slug: antario-jast +name: Antario Jast +sex: m +born: null +died: null +primary-house: jast +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/caggo.md b/content/characters/caggo.md new file mode 100644 index 00000000..971e806e --- /dev/null +++ b/content/characters/caggo.md @@ -0,0 +1,11 @@ +--- +slug: caggo +name: Caggo +sex: m +born: null +died: null +primary-house: null +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/dunstan-drumm.md b/content/characters/dunstan-drumm.md new file mode 100644 index 00000000..843cca43 --- /dev/null +++ b/content/characters/dunstan-drumm.md @@ -0,0 +1,11 @@ +--- +slug: dunstan-drumm +name: Dunstan Drumm +sex: m +born: null +died: null +primary-house: drumm +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/edmure-tully.md b/content/characters/edmure-tully.md new file mode 100644 index 00000000..0a5649d4 --- /dev/null +++ b/content/characters/edmure-tully.md @@ -0,0 +1,11 @@ +--- +slug: edmure-tully +name: Edmure Tully +sex: m +born: null +died: null +primary-house: tully +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/elbert-arryn.md b/content/characters/elbert-arryn.md new file mode 100644 index 00000000..198cd670 --- /dev/null +++ b/content/characters/elbert-arryn.md @@ -0,0 +1,11 @@ +--- +slug: elbert-arryn +name: Elbert Arryn +sex: m +born: null +died: null +primary-house: arryn +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/ella-lannister.md b/content/characters/ella-lannister.md index fa804f37..c68ca090 100644 --- a/content/characters/ella-lannister.md +++ b/content/characters/ella-lannister.md @@ -4,9 +4,8 @@ name: Ella Lannister sex: f born: null died: null -primary-house: lannister-of-lannisport -also-of-houses: - - lannister +primary-house: lannister +also-of-houses: [] spouses: - damon-lannister-son-of-jason children: diff --git a/content/characters/erik-ironmaker.md b/content/characters/erik-ironmaker.md new file mode 100644 index 00000000..a01f11eb --- /dev/null +++ b/content/characters/erik-ironmaker.md @@ -0,0 +1,11 @@ +--- +slug: erik-ironmaker +name: Erik Ironmaker +sex: m +born: null +died: null +primary-house: null +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/garin.md b/content/characters/garin.md new file mode 100644 index 00000000..c81cb537 --- /dev/null +++ b/content/characters/garin.md @@ -0,0 +1,11 @@ +--- +slug: garin +name: Prince Garin +sex: m +born: null +died: null +primary-house: null +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/goren-greyjoy.md b/content/characters/goren-greyjoy.md new file mode 100644 index 00000000..fb65a813 --- /dev/null +++ b/content/characters/goren-greyjoy.md @@ -0,0 +1,11 @@ +--- +slug: goren-greyjoy +name: Goren Greyjoy +sex: m +born: null +died: null +primary-house: greyjoy +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/harras-harlaw.md b/content/characters/harras-harlaw.md new file mode 100644 index 00000000..67090526 --- /dev/null +++ b/content/characters/harras-harlaw.md @@ -0,0 +1,11 @@ +--- +slug: harras-harlaw +name: Harras Harlaw +sex: m +born: null +died: null +primary-house: harlaw +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/hugh-hammer.md b/content/characters/hugh-hammer.md new file mode 100644 index 00000000..b6e9ca0f --- /dev/null +++ b/content/characters/hugh-hammer.md @@ -0,0 +1,11 @@ +--- +slug: hugh-hammer +name: Hugh Hammer +sex: m +born: null +died: null +primary-house: null +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/jon-arryn.md b/content/characters/jon-arryn.md index 73873975..bdef0854 100644 --- a/content/characters/jon-arryn.md +++ b/content/characters/jon-arryn.md @@ -14,7 +14,7 @@ primary-house: arryn parents: - jasper-arryn spouses: - - lysa-tully + - lysa-arryn children: - robert-arryn titles: diff --git a/content/characters/larra-rogare.md b/content/characters/larra-rogare.md index d2aa2b7a..4810e602 100644 --- a/content/characters/larra-rogare.md +++ b/content/characters/larra-rogare.md @@ -7,7 +7,7 @@ born: era: AC precision: year died: null -primary-house: rogare +primary-house: null also-of-houses: - targaryen spouses: diff --git a/content/characters/leyton-hightower.md b/content/characters/leyton-hightower.md new file mode 100644 index 00000000..6ce45ad1 --- /dev/null +++ b/content/characters/leyton-hightower.md @@ -0,0 +1,11 @@ +--- +slug: leyton-hightower +name: Leyton Hightower +sex: m +born: null +died: null +primary-house: hightower +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/martyn-tyrell.md b/content/characters/martyn-tyrell.md new file mode 100644 index 00000000..bc9fc696 --- /dev/null +++ b/content/characters/martyn-tyrell.md @@ -0,0 +1,11 @@ +--- +slug: martyn-tyrell +name: Martyn Tyrell +sex: m +born: null +died: null +primary-house: tyrell +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/moredo-rogare.md b/content/characters/moredo-rogare.md new file mode 100644 index 00000000..6b9b7af0 --- /dev/null +++ b/content/characters/moredo-rogare.md @@ -0,0 +1,11 @@ +--- +slug: moredo-rogare +name: Moredo Rogare +sex: m +born: null +died: null +primary-house: null +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/mya-royce.md b/content/characters/mya-royce.md new file mode 100644 index 00000000..1c0c2f6a --- /dev/null +++ b/content/characters/mya-royce.md @@ -0,0 +1,11 @@ +--- +slug: mya-royce +name: Mya Royce +sex: f +born: null +died: null +primary-house: royce +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/robert-arryn.md b/content/characters/robert-arryn.md index 675fb8d0..5a101e97 100644 --- a/content/characters/robert-arryn.md +++ b/content/characters/robert-arryn.md @@ -10,7 +10,7 @@ died: null primary-house: arryn parents: - jon-arryn - - lysa-tully + - lysa-arryn spouses: [] children: [] titles: diff --git a/content/characters/robett-glover.md b/content/characters/robett-glover.md new file mode 100644 index 00000000..965eb841 --- /dev/null +++ b/content/characters/robett-glover.md @@ -0,0 +1,11 @@ +--- +slug: robett-glover +name: Robett Glover +sex: m +born: null +died: null +primary-house: glover +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/characters/sandoq.md b/content/characters/sandoq.md new file mode 100644 index 00000000..414bc2d5 --- /dev/null +++ b/content/characters/sandoq.md @@ -0,0 +1,11 @@ +--- +slug: sandoq +name: Sandoq the Shadow +sex: m +born: null +died: null +primary-house: null +placeholder: true +placeholder-reason: unwritten +draft: false +--- diff --git a/content/dragons/caraxes.md b/content/dragons/caraxes.md index 2b64b786..3fe6de64 100644 --- a/content/dragons/caraxes.md +++ b/content/dragons/caraxes.md @@ -11,7 +11,7 @@ died: status: dead house: targaryen riders: - - aemon-targaryen-prince-of-dragonstone + - aemon-son-of-jaehaerys-i - daemon-targaryen aliases: - The Blood Wyrm diff --git a/content/houses/baratheon.md b/content/houses/baratheon.md index 98ff0411..a0e1282f 100644 --- a/content/houses/baratheon.md +++ b/content/houses/baratheon.md @@ -14,9 +14,7 @@ founded: status: extant rank: royal sworn-from: [] -cadet-houses: - - baratheon-of-kings-landing - - baratheon-of-dragonstone +cadet-houses: [] region: stormlands seats: - name: Storm's End diff --git a/content/houses/drumm.md b/content/houses/drumm.md index 8652ce44..7963e40f 100644 --- a/content/houses/drumm.md +++ b/content/houses/drumm.md @@ -21,7 +21,7 @@ heads: titles: - name: Lord of Old Wyk ancestral-weapons: - - Red Rain + - red-rain notable-members: - name: Denys Drumm - name: Donnel Drumm diff --git a/content/houses/harlaw-grey-garden.md b/content/houses/harlaw-grey-garden.md index 877ae56e..1ae32b5f 100644 --- a/content/houses/harlaw-grey-garden.md +++ b/content/houses/harlaw-grey-garden.md @@ -22,7 +22,7 @@ heads: titles: - name: Knight of Grey Garden ancestral-weapons: - - Nightfall + - nightfall sources: - type: awoiaf url: https://awoiaf.westeros.org/index.php/House_Harlaw diff --git a/content/houses/lannister.md b/content/houses/lannister.md index cfcb98a6..0322b340 100644 --- a/content/houses/lannister.md +++ b/content/houses/lannister.md @@ -14,8 +14,7 @@ founded: status: extant rank: lordly sworn-from: [] -cadet-houses: - - lannister-of-lannisport +cadet-houses: [] heads: - name: "Lord Tywin Lannister" slug: tywin-lannister diff --git a/content/houses/roxton.md b/content/houses/roxton.md index 0ebf29a6..5680a64d 100644 --- a/content/houses/roxton.md +++ b/content/houses/roxton.md @@ -19,7 +19,7 @@ region: reach regions: - name: The Reach ancestral-weapons: - - Orphan-Maker + - orphan-maker notable-members: - name: "Ser Lorence Roxton" - name: "Ser Jon Roxton" diff --git a/content/weapons/truth.md b/content/weapons/truth.md index be85de02..e4156e60 100644 --- a/content/weapons/truth.md +++ b/content/weapons/truth.md @@ -4,7 +4,6 @@ name: Truth type: longsword material: valyrian-steel status: lost -origin-house: rogare current-house: null wielders: - moredo-rogare diff --git a/lib/content-integrity.test.ts b/lib/content-integrity.test.ts new file mode 100644 index 00000000..8349aaee --- /dev/null +++ b/lib/content-integrity.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + loadAllBattles, + loadAllCastles, + loadAllCharacters, + loadAllDragons, + loadAllEvents, + loadAllHouses, + loadAllWeapons, +} from "@/lib/content"; +import { contentIntegrityErrors } from "@/lib/content-integrity"; + +describe("content integrity", () => { + it("has matching, unique slugs and resolvable cross-references", async () => { + const [battles, castles, characters, dragons, events, houses, weapons] = + await Promise.all([ + loadAllBattles(), + loadAllCastles(), + loadAllCharacters(), + loadAllDragons(), + loadAllEvents(), + loadAllHouses(), + loadAllWeapons(), + ]); + + expect( + contentIntegrityErrors({ + battles, + castles, + characters, + dragons, + events, + houses, + weapons, + }), + ).toEqual([]); + }); +}); diff --git a/lib/content-integrity.ts b/lib/content-integrity.ts new file mode 100644 index 00000000..70860c80 --- /dev/null +++ b/lib/content-integrity.ts @@ -0,0 +1,256 @@ +import type { + loadAllBattles, + loadAllCastles, + loadAllCharacters, + loadAllDragons, + loadAllEvents, + loadAllHouses, + loadAllWeapons, +} from "@/lib/content"; + +type Collections = { + battles: Awaited>; + castles: Awaited>; + characters: Awaited>; + dragons: Awaited>; + events: Awaited>; + houses: Awaited>; + weapons: Awaited>; +}; + +type CollectionName = keyof Collections; + +export function contentIntegrityErrors(collections: Collections): string[] { + const slugSets = buildSlugSets(collections); + const allEntitySlugs = new Set( + Object.values(slugSets).flatMap((slugs) => [...slugs]), + ); + const errors = Object.entries(collections).flatMap(([name, entries]) => { + const duplicateErrors = duplicateSlugErrors({ name, entries }); + const filenameErrors = entries.flatMap((entry) => + entry.slug === entry.frontmatter.slug + ? [] + : [ + `${name}/${entry.slug}: frontmatter slug is ${entry.frontmatter.slug}`, + ], + ); + return [...duplicateErrors, ...filenameErrors]; + }); + + const addReference = ({ + source, + field, + values, + targets, + }: { + source: string; + field: string; + values: ReadonlyArray; + targets: ReadonlySet; + }) => { + values.forEach((value) => { + if (value && !targets.has(value)) { + errors.push(`${source}.${field}: missing ${value}`); + } + }); + }; + + collections.characters.forEach(({ slug, frontmatter }) => { + const source = `characters/${slug}`; + addReference({ + source, + field: "primary-house", + values: [frontmatter["primary-house"]], + targets: slugSets.houses, + }); + addReference({ + source, + field: "also-of-houses", + values: frontmatter["also-of-houses"], + targets: slugSets.houses, + }); + const characterReferences = { + parents: frontmatter.parents, + spouses: frontmatter.spouses, + children: frontmatter.children, + }; + Object.entries(characterReferences).forEach(([field, values]) => { + addReference({ + source, + field, + values, + targets: slugSets.characters, + }); + }); + addReference({ + source, + field: "mentions", + values: frontmatter.mentions, + targets: allEntitySlugs, + }); + }); + + collections.houses.forEach(({ slug, frontmatter }) => { + const source = `houses/${slug}`; + const houseReferences = { + liege: [frontmatter.liege], + "sworn-from": frontmatter["sworn-from"], + "cadet-houses": frontmatter["cadet-houses"], + }; + Object.entries(houseReferences).forEach(([field, values]) => { + addReference({ + source, + field, + values, + targets: slugSets.houses, + }); + }); + addReference({ + source, + field: "ancestral-weapons", + values: frontmatter["ancestral-weapons"] ?? [], + targets: slugSets.weapons, + }); + const memberReferences = { + heads: frontmatter.heads ?? [], + "notable-members": frontmatter["notable-members"] ?? [], + }; + Object.entries(memberReferences).forEach(([field, entries]) => { + addReference({ + source, + field, + values: entries.map((entry) => entry.slug), + targets: slugSets.characters, + }); + }); + addReference({ + source, + field: "mentions", + values: frontmatter.mentions, + targets: allEntitySlugs, + }); + }); + + collections.castles.forEach(({ slug, frontmatter }) => { + const source = `castles/${slug}`; + addReference({ + source, + field: "liege-house", + values: [frontmatter["liege-house"]], + targets: slugSets.houses, + }); + addReference({ + source, + field: "sworn-houses", + values: frontmatter["sworn-houses"], + targets: slugSets.houses, + }); + }); + + collections.weapons.forEach(({ slug, frontmatter }) => { + const source = `weapons/${slug}`; + addReference({ + source, + field: "houses", + values: [frontmatter["origin-house"], frontmatter["current-house"]], + targets: slugSets.houses, + }); + addReference({ + source, + field: "wielders", + values: frontmatter.wielders, + targets: slugSets.characters, + }); + addReference({ + source, + field: "mentions", + values: frontmatter.mentions, + targets: allEntitySlugs, + }); + }); + + collections.dragons.forEach(({ slug, frontmatter }) => { + const source = `dragons/${slug}`; + addReference({ + source, + field: "house", + values: [frontmatter.house], + targets: slugSets.houses, + }); + addReference({ + source, + field: "riders", + values: frontmatter.riders, + targets: slugSets.characters, + }); + addReference({ + source, + field: "mentions", + values: frontmatter.mentions, + targets: allEntitySlugs, + }); + }); + + [...collections.battles, ...collections.events].forEach( + ({ slug, frontmatter }) => { + const source = `${"commanders" in frontmatter ? "battles" : "events"}/${slug}`; + if ("commanders" in frontmatter) { + addReference({ + source, + field: "commanders", + values: frontmatter.commanders, + targets: slugSets.characters, + }); + } + frontmatter.participants.forEach((participant, index) => { + addReference({ + source, + field: `participants[${index}].houses`, + values: participant.houses, + targets: slugSets.houses, + }); + }); + }, + ); + + return errors; +} + +function buildSlugSets(collections: Collections) { + return { + battles: new Set( + collections.battles.map((entry) => entry.frontmatter.slug), + ), + castles: new Set( + collections.castles.map((entry) => entry.frontmatter.slug), + ), + characters: new Set( + collections.characters.map((entry) => entry.frontmatter.slug), + ), + dragons: new Set( + collections.dragons.map((entry) => entry.frontmatter.slug), + ), + events: new Set(collections.events.map((entry) => entry.frontmatter.slug)), + houses: new Set(collections.houses.map((entry) => entry.frontmatter.slug)), + weapons: new Set( + collections.weapons.map((entry) => entry.frontmatter.slug), + ), + } satisfies Record>; +} + +function duplicateSlugErrors({ + name, + entries, +}: { + name: string; + entries: ReadonlyArray<{ frontmatter: { slug: string } }>; +}): string[] { + const counts = entries.reduce>((result, entry) => { + const slug = entry.frontmatter.slug; + result.set(slug, (result.get(slug) ?? 0) + 1); + return result; + }, new Map()); + return [...counts.entries()].flatMap(([slug, count]) => + count > 1 ? [`${name}: duplicate slug ${slug}`] : [], + ); +} From b6e3d91113b39456434b63e3df8c3ca8b4cd11f0 Mon Sep 17 00:00:00 2001 From: CJ Rivas Date: Sun, 12 Jul 2026 11:07:59 -0400 Subject: [PATCH 2/5] TKW: contain focus within the site menu - expose the open drawer as a modal dialog\n- loop forward and reverse tab focus within its controls\n- cover dialog semantics and keyboard containment --- components/SiteMenu/SiteMenu.test.tsx | 33 +++++++++++++++++++++++++++ components/SiteMenu/SiteMenu.tsx | 25 +++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/components/SiteMenu/SiteMenu.test.tsx b/components/SiteMenu/SiteMenu.test.tsx index 7ac9be4d..61460884 100644 --- a/components/SiteMenu/SiteMenu.test.tsx +++ b/components/SiteMenu/SiteMenu.test.tsx @@ -105,4 +105,37 @@ describe("SiteMenu", () => { fireEvent.click(screen.getByRole("button", { name: /close menu/i })); expect(trigger.getAttribute("aria-expanded")).toBe("false"); }); + + it("exposes the open drawer as a modal dialog", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /open menu/i })); + + const dialog = screen.getByRole("dialog", { name: /site menu/i }); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("contains forward keyboard focus within the open drawer", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /open menu/i })); + + const close = screen.getByRole("button", { name: /close menu/i }); + const links = screen.getAllByRole("link"); + const lastLink = links[links.length - 1]; + lastLink.focus(); + fireEvent.keyDown(document, { key: "Tab" }); + + expect(document.activeElement).toBe(close); + }); + + it("contains reverse keyboard focus within the open drawer", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /open menu/i })); + + const close = screen.getByRole("button", { name: /close menu/i }); + const links = screen.getAllByRole("link"); + close.focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + + expect(document.activeElement).toBe(links[links.length - 1]); + }); }); diff --git a/components/SiteMenu/SiteMenu.tsx b/components/SiteMenu/SiteMenu.tsx index 37a50e03..410da603 100644 --- a/components/SiteMenu/SiteMenu.tsx +++ b/components/SiteMenu/SiteMenu.tsx @@ -68,13 +68,32 @@ export function SiteMenu() { const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); const closeRef = useRef(null); + const panelRef = useRef(null); const wasOpen = useRef(false); const panelId = useId(); useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setIsOpen(false); + if (e.key === "Escape") { + setIsOpen(false); + return; + } + if (e.key !== "Tab") return; + + const focusable = panelRef.current?.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); @@ -129,8 +148,12 @@ export function SiteMenu() { />