From f3403f74bc3a123da80d7fab16a55f3e8de4ee9f Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Tue, 6 Oct 2026 10:47:20 -0400 Subject: [PATCH] refactor(types,web): migrate usc_title to string for alphanumeric titles and appendices Support alphanumeric title keys (such as 18a, 5a, 26a) and appendices across transformer frontmatter, Astro collections, routes, and natural collation. Closes #278 --- apps/web/src/components/SummaryBox.astro | 2 +- apps/web/src/content.config.ts | 2 +- apps/web/src/data/title-names.ts | 8 ++++++- apps/web/src/pages/browse/[title].astro | 2 +- apps/web/src/pages/browse/index.astro | 8 ++++--- apps/web/src/pages/statute/[...slug].astro | 4 ++-- .../src/__tests__/transformer.test.ts | 22 ++++++++++++++++--- .../transformer/src/markdown-generator.ts | 20 ++++++++++++----- 8 files changed, 51 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/SummaryBox.astro b/apps/web/src/components/SummaryBox.astro index 1a6056e..65c223b 100644 --- a/apps/web/src/components/SummaryBox.astro +++ b/apps/web/src/components/SummaryBox.astro @@ -2,7 +2,7 @@ // USWDS-aligned Statutory Summary Box (usa-summary-box) interface Props { classification: string; - uscTitle: number; + uscTitle: number | string; uscSection: string; chapter: number; currentThrough: string; diff --git a/apps/web/src/content.config.ts b/apps/web/src/content.config.ts index b9a92e3..15aa9ea 100644 --- a/apps/web/src/content.config.ts +++ b/apps/web/src/content.config.ts @@ -6,7 +6,7 @@ const statutes = defineCollection({ loader: glob({ pattern: '**/*.md', base: './content-data/statutes' }), schema: z.object({ title: z.string(), - usc_title: z.number(), + usc_title: z.union([z.string(), z.number()]).transform((val) => String(val)), usc_section: z.string(), chapter: z.number(), current_through: z.string(), diff --git a/apps/web/src/data/title-names.ts b/apps/web/src/data/title-names.ts index 2210564..0b05739 100644 --- a/apps/web/src/data/title-names.ts +++ b/apps/web/src/data/title-names.ts @@ -1,4 +1,4 @@ -export const TITLE_NAMES: Record = { +export const TITLE_NAMES: Record = { 1: 'General Provisions', 2: 'The Congress', 3: 'The President', @@ -53,4 +53,10 @@ export const TITLE_NAMES: Record = { 52: 'Voting and Elections', 53: 'Reserved', 54: 'National Park Service and Related Programs', + '5a': 'Government Organization and Employees (Appendix)', + '11a': 'Bankruptcy (Appendix)', + '18a': 'Crimes and Criminal Procedure (Appendix)', + '26a': 'Internal Revenue Code (Appendix)', + '28a': 'Judiciary and Judicial Procedure (Appendix)', + '50a': 'War and National Defense (Appendix)', }; diff --git a/apps/web/src/pages/browse/[title].astro b/apps/web/src/pages/browse/[title].astro index 5537283..9bd54eb 100644 --- a/apps/web/src/pages/browse/[title].astro +++ b/apps/web/src/pages/browse/[title].astro @@ -10,7 +10,7 @@ export async function getStaticPaths() { const entries = await getCollection('statutes'); // Group by usc_title - const byTitle = new Map(); + const byTitle = new Map(); for (const entry of entries) { const titleNum = entry.data.usc_title; if (!byTitle.has(titleNum)) { diff --git a/apps/web/src/pages/browse/index.astro b/apps/web/src/pages/browse/index.astro index 5d86f64..cb9e152 100644 --- a/apps/web/src/pages/browse/index.astro +++ b/apps/web/src/pages/browse/index.astro @@ -7,7 +7,7 @@ import { TITLE_NAMES } from '../../data/title-names'; const allStatutes = await getCollection('statutes'); // Group statutes by title number -const byTitle = new Map(); +const byTitle = new Map(); for (const entry of allStatutes) { const titleNum = entry.data.usc_title; if (!byTitle.has(titleNum)) { @@ -16,8 +16,10 @@ for (const entry of allStatutes) { byTitle.get(titleNum)!.push(entry); } -// Sort titles numerically -const sortedTitles = [...byTitle.entries()].sort((a, b) => a[0] - b[0]); +// Sort titles using natural alphanumeric collation (e.g. 18 before 18a before 19) +const sortedTitles = [...byTitle.entries()].sort(([a], [b]) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) +); const totalSections = allStatutes.length; const totalTitles = byTitle.size; diff --git a/apps/web/src/pages/statute/[...slug].astro b/apps/web/src/pages/statute/[...slug].astro index 2f3513b..a872f21 100644 --- a/apps/web/src/pages/statute/[...slug].astro +++ b/apps/web/src/pages/statute/[...slug].astro @@ -28,10 +28,10 @@ export async function getStaticPaths() { } // Pre-compute sorted chapter list per title for cross-chapter nav - const titleChapters = new Map(); + const titleChapters = new Map(); for (const [key] of byTitleChapter) { const [titleStr, chapterStr] = key.split('-'); - const titleNum = parseInt(titleStr ?? '0', 10); + const titleNum = titleStr ?? ''; const chapterNum = parseInt(chapterStr ?? '0', 10); if (!titleChapters.has(titleNum)) titleChapters.set(titleNum, []); titleChapters.get(titleNum)!.push(chapterNum); diff --git a/packages/transformer/src/__tests__/transformer.test.ts b/packages/transformer/src/__tests__/transformer.test.ts index 9319348..337715d 100644 --- a/packages/transformer/src/__tests__/transformer.test.ts +++ b/packages/transformer/src/__tests__/transformer.test.ts @@ -278,6 +278,22 @@ describe('FrontmatterSchema', () => { expect(result.data.current_through).toBe('Unknown'); } }); + + it('accepts alphanumeric title like 18a and transforms to string', () => { + const result = FrontmatterSchema.safeParse({ + title: 'Section 1', + usc_title: '18a', + usc_section: '1', + chapter: 1, + current_through: 'PL 119-1', + classification: '18a U.S.C. ยง 1', + generated_at: 'now', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.usc_title).toBe('18a'); + } + }); }); describe('buildSectionPath', () => { @@ -460,9 +476,9 @@ describe('XmlToMarkdownAdapter', () => { // Appendix sections live under title-18a, distinct from main title-18 expect(section?.path).toBe('statutes/title-18a/chapter-1/section-1.md'); expect(section?.path).not.toContain('title-18/'); - // usc_title frontmatter remains numeric (18) despite the "18a" path - expect(section?.content).toContain('usc_title: 18'); - expect(section?.content).not.toContain('usc_title: 18a'); + // usc_title frontmatter preserves appendix designation ("18a") + expect(section?.content).toContain('usc_title: "18a"'); + expect(section?.content).not.toContain('usc_title: 18\n'); }); it('preserves inline element text in mixed content (cross-references)', () => { diff --git a/packages/transformer/src/markdown-generator.ts b/packages/transformer/src/markdown-generator.ts index 4096d0b..33d2ca0 100644 --- a/packages/transformer/src/markdown-generator.ts +++ b/packages/transformer/src/markdown-generator.ts @@ -12,7 +12,7 @@ export type SectionStatus = z.infer; /** Zod schema for YAML frontmatter validation */ export const FrontmatterSchema = z.object({ title: z.string().min(1), - usc_title: z.number().int().positive(), + usc_title: z.union([z.string().min(1), z.number().int().positive()]).transform((val) => String(val)), usc_section: z.string().min(1), chapter: z.number().int().nonnegative(), current_through: z.string().default('Unknown'), @@ -172,11 +172,22 @@ function yamlQuote(value: string): string { } /** Generate YAML frontmatter string from validated data */ -export function generateFrontmatter(data: Frontmatter): string { +export function generateFrontmatter(data: { + title: string; + usc_title: string | number; + usc_section: string; + chapter: number; + current_through: string; + classification: string; + generated_at: string; + status: SectionStatus; +}): string { + const titleStr = String(data.usc_title); + const formattedTitle = /^\d+$/.test(titleStr) ? titleStr : yamlQuote(titleStr); const lines = [ '---', `title: ${yamlQuote(data.title)}`, - `usc_title: ${data.usc_title}`, + `usc_title: ${formattedTitle}`, `usc_section: ${yamlQuote(data.usc_section)}`, `chapter: ${data.chapter}`, `current_through: ${yamlQuote(data.current_through)}`, @@ -382,13 +393,12 @@ export function generateMarkdownForSection( const heading = extractHeading(sectionChildren); const now = new Date().toISOString(); - const uscTitle = parseInt(titleNum, 10) || 0; const chapterInt = parseInt(chapterNum, 10) || 0; const sectionTitle = `Section ${sectionNum}${heading ? ' - ' + heading : ''}`; const frontmatter = FrontmatterSchema.parse({ title: sectionTitle, - usc_title: Math.max(uscTitle, 1), + usc_title: String(titleNum).trim() || '1', usc_section: sectionNum, chapter: chapterInt, current_through: currentThrough || 'Unknown',