From 58221c19a7d76d59f7cb470bd51c7233138c99a3 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Tue, 6 Oct 2026 10:17:51 -0400 Subject: [PATCH] feat(transformer): preserve sourceCredit and legislative history provenance - Add sourceCredit to USLM_ELEMENTS in packages/transformer/src/constants.ts - Implement formatSourceCredit in markdown-generator.ts to parse enactment and amending citations - Automatically generate authoritative GovInfo external links for Statutes at Large (/us/stat/) and Public Law (/us/pl/) references - Cleanly render ## Source Credit in italic typography before Notes section - Add unit and snapshot test coverage in transformer.test.ts and uslm-v1-compat.test.ts - Resolves #277 --- .../src/__tests__/transformer.test.ts | 57 +++++++++++++++++ .../src/__tests__/uslm-v1-compat.test.ts | 5 ++ packages/transformer/src/constants.ts | 2 + packages/transformer/src/index.ts | 1 + .../transformer/src/markdown-generator.ts | 61 ++++++++++++++++++- 5 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/transformer/src/__tests__/transformer.test.ts b/packages/transformer/src/__tests__/transformer.test.ts index d82d48c..9319348 100644 --- a/packages/transformer/src/__tests__/transformer.test.ts +++ b/packages/transformer/src/__tests__/transformer.test.ts @@ -10,6 +10,7 @@ import { generateSectionBody, generateMarkdownForSection, XmlToMarkdownAdapter, + formatSourceCredit, } from '../index.js'; // --- Minimal USLM XML fixtures --- @@ -549,4 +550,60 @@ describe('reformatInlineLists', () => { expect(result).toContain(' - **(1)** In general The Secretary shall allocate funds.'); expect(result).toContain(' - **(2)** Other For any fiscal year.'); }); + + describe('formatSourceCredit', () => { + it('formats source credit with Statutes at Large and Public Law links', () => { + const children = [ + { '#text': '(' }, + { + ref: [{ '#text': 'July 30, 1947, ch. 388' }], + ':@': { href: '/us/act/1947-07-30/ch388' }, + }, + { '#text': ',' }, + { + ref: [{ '#text': '61 Stat. 633' }], + ':@': { href: '/us/stat/61/633' }, + }, + { '#text': ';' }, + { + ref: [{ '#text': 'Pub. L. 111-148' }], + ':@': { href: '/us/pl/111/148' }, + }, + { '#text': '.)' }, + ]; + + const formatted = formatSourceCredit(children); + expect(formatted).toBe( + '*(July 30, 1947, ch. 388, [61 Stat. 633](https://www.govinfo.gov/link/statute/61/633); [Pub. L. 111-148](https://www.govinfo.gov/link/plaw/111/public/148).)*' + ); + }); + + it('handles empty children gracefully', () => { + expect(formatSourceCredit([])).toBe(''); + }); + + it('integrates source credit into section body', () => { + const sectionNodes = [ + { num: [{ '#text': '101' }] }, + { heading: [{ '#text': 'Definitions' }] }, + { content: [{ '#text': 'In this section, terms apply.' }] }, + { + sourceCredit: [ + { '#text': '(' }, + { + ref: [{ '#text': '84 Stat. 1236' }], + ':@': { '@_href': '/us/stat/84/1236' }, + }, + { '#text': '.)' }, + ], + }, + ]; + + const body = generateSectionBody(sectionNodes); + expect(body).toContain('# 101 Definitions'); + expect(body).toContain('In this section, terms apply.'); + expect(body).toContain('## Source Credit'); + expect(body).toContain('*([84 Stat. 1236](https://www.govinfo.gov/link/statute/84/1236).)*'); + }); + }); }); diff --git a/packages/transformer/src/__tests__/uslm-v1-compat.test.ts b/packages/transformer/src/__tests__/uslm-v1-compat.test.ts index 7acc359..01de6d8 100644 --- a/packages/transformer/src/__tests__/uslm-v1-compat.test.ts +++ b/packages/transformer/src/__tests__/uslm-v1-compat.test.ts @@ -59,6 +59,11 @@ describe('USLM 1.0 (uscDoc) compatibility', () => { // Section has body content expect(firstSection.content).toContain('meaning of any Act of Congress'); + + // Section extracts Source Credit with GovInfo links + expect(firstSection.content).toContain('## Source Credit'); + expect(firstSection.content).toContain('https://www.govinfo.gov/link/statute/61/633'); + expect(firstSection.content).toContain('[61 Stat. 633](https://www.govinfo.gov/link/statute/61/633)'); }); it('extracts multiple sections from the excerpt', () => { diff --git a/packages/transformer/src/constants.ts b/packages/transformer/src/constants.ts index c3c559b..14d7ee3 100644 --- a/packages/transformer/src/constants.ts +++ b/packages/transformer/src/constants.ts @@ -44,6 +44,8 @@ export const USLM_ELEMENTS = { chapeau: 'chapeau', /** Note element */ note: 'note', + /** Source credit (enactment and amendment history) */ + sourceCredit: 'sourceCredit', /** Cross-reference */ ref: 'ref', /** Table element */ diff --git a/packages/transformer/src/index.ts b/packages/transformer/src/index.ts index 564c336..7d162bd 100644 --- a/packages/transformer/src/index.ts +++ b/packages/transformer/src/index.ts @@ -12,6 +12,7 @@ export { FrontmatterSchema, SectionStatusSchema, detectSectionStatus, + formatSourceCredit, } from './markdown-generator.js'; export type { Frontmatter, MarkdownFile, SectionStatus } from './markdown-generator.js'; export { USLM_ELEMENTS, INDENT_PER_LEVEL, MAX_NESTING_DEPTH } from './constants.js'; diff --git a/packages/transformer/src/markdown-generator.ts b/packages/transformer/src/markdown-generator.ts index 28a28fc..4096d0b 100644 --- a/packages/transformer/src/markdown-generator.ts +++ b/packages/transformer/src/markdown-generator.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { USLM_ELEMENTS, INDENT_PER_LEVEL, MAX_NESTING_DEPTH } from './constants.js'; -import { extractTextFromNodes, findElements } from './xml-utils.js'; +import { extractTextFromNodes, findElements, getAttributes } from './xml-utils.js'; /** Section status — derived from heading text during transformation */ export const SectionStatusSchema = z.enum([ @@ -271,6 +271,53 @@ function walkListElements( } } +/** Format source credit nodes into markdown with GovInfo statutory citations */ +export function formatSourceCredit(children: unknown[]): string { + const parts: string[] = []; + function walk(nodes: unknown[]): void { + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + const obj = node as Record; + if ('#text' in obj) { + parts.push(String(obj['#text'])); + } else if (USLM_ELEMENTS.ref in obj && Array.isArray(obj[USLM_ELEMENTS.ref])) { + const attrs = getAttributes(node); + const href = attrs['@_href'] || attrs['href'] || ''; + const refText = extractTextFromNodes(obj[USLM_ELEMENTS.ref] as unknown[]).trim(); + const statMatch = /^\/us\/stat\/(\d+)\/(\d+)/.exec(href); + const plMatch = /^\/us\/pl\/(\d+)\/(\d+)/.exec(href); + if (statMatch) { + parts.push(`[${refText}](https://www.govinfo.gov/link/statute/${statMatch[1]}/${statMatch[2]})`); + } else if (plMatch) { + parts.push(`[${refText}](https://www.govinfo.gov/link/plaw/${plMatch[1]}/public/${plMatch[2]})`); + } else { + parts.push(refText); + } + } else { + for (const [k, v] of Object.entries(obj)) { + if (k !== ':@' && Array.isArray(v)) { + walk(v); + } + } + } + } + } + walk(children); + + const cleaned = parts + .join(' ') + .replace(/\s+/g, ' ') + .replace(/\(\s+/g, '(') + .replace(/\s+([,.;:)])/g, '$1') + .trim(); + + if (!cleaned) return ''; + if (cleaned.startsWith('*') && cleaned.endsWith('*')) { + return cleaned; + } + return `*${cleaned}*`; +} + /** Generate markdown body for a single section node (preserveOrder children) */ export function generateSectionBody(sectionChildren: unknown[]): string { const lines: string[] = []; @@ -293,6 +340,18 @@ export function generateSectionBody(sectionChildren: unknown[]): string { // Walk nested list elements walkListElements(sectionChildren, 0, lines); + // Source credit / legislative history + const sourceCredits = findElements(sectionChildren, USLM_ELEMENTS.sourceCredit); + if (sourceCredits.length > 0) { + lines.push(''); + lines.push('## Source Credit'); + lines.push(''); + for (const sc of sourceCredits) { + const credit = formatSourceCredit(sc.children); + if (credit) lines.push(credit); + } + } + // Notes const notes = findElements(sectionChildren, USLM_ELEMENTS.note); if (notes.length > 0) {