diff --git a/docs/indesign-to-react/README.md b/docs/indesign-to-react/README.md index 77fb8c6..525f038 100644 --- a/docs/indesign-to-react/README.md +++ b/docs/indesign-to-react/README.md @@ -5,8 +5,8 @@ typed React components, design tokens, and assets — a print-first sibling to t Figma/Canva/Screenshot pipelines. > **Status:** in progress. This document tracks the epic and details the shipped -> stages — the **IDML parser + IR**, the **PDF parser** (same IR), and the -> **style & design-token mapper**. +> stages — the **IDML parser + IR**, the **PDF parser** (same IR), the **style & +> design-token mapper**, and the **React component generator**. > See the epic for the full plan: _Add InDesign-to-React conversion pipeline_. ## Pipeline shape (target) @@ -125,10 +125,33 @@ node packages/pipeline/dist/indesign/cli.js your-design.idml --emit-tokens ./src Font fallbacks and out-of-gamut conversions are listed in the generator report. +## Stage 3 — React component generator (shipped) + +Implemented in [`packages/pipeline/src/react`](../../packages/pipeline/src/react). +Turns the IR + tokens into importable React artifacts — one `.tsx` per spread: + +- **Frames → JSX** — text frames become semantic tags (`h1`–`h6` / `p` / + `figcaption`) inferred from the paragraph-style role; image frames become + `` or `next/image`; layout is a token-spaced flow (grid for multi-column). +- **Typed props** — explicit `…Props` for every extracted content field, with the + extracted text / image `src` as defaults, so consumers override content while + keeping layout. +- **Two styling modes** — Tailwind classes that resolve via the mapper's preset, + or CSS Modules referencing the `tokens.css` custom properties. +- **Storybook stories** per component, populated with the extracted content. +- **Generation report** (`indesign-pipeline-report.md`) listing produced files, + staged assets, unmapped IR nodes, and accessibility TODOs. + +Generated components are deterministic and pass `tsc --noEmit` under strict React +JSX (verified in CI for Tailwind, CSS Modules, and Next.js targets). + +```bash +node packages/pipeline/dist/indesign/cli.js brochure.idml \ + --emit-components ./src/components --style tailwind --framework react +``` + ## Roadmap (remaining sub-issues) -- [ ] React component generator (TSX output, optional Tailwind/CSS Modules, - Storybook stories) - [ ] `indesign-to-react` Claude Code agent + skill + end-to-end CLI command ## References diff --git a/packages/pipeline/README.md b/packages/pipeline/README.md index 6c04e85..d76d623 100644 --- a/packages/pipeline/README.md +++ b/packages/pipeline/README.md @@ -2,13 +2,15 @@ Core library for the Aurelius design-to-code pipeline. It reads Adobe InDesign sources — both `.idml` packages and exported `.pdf` files — into a single -normalized, typed intermediate representation (IR), and maps that IR to a -coherent **design-token set** (`tokens.ts`, `tokens.css`, a Tailwind preset, and -a Style Dictionary JSON). +normalized, typed intermediate representation (IR), maps that IR to a coherent +**design-token set** (`tokens.ts`, `tokens.css`, a Tailwind preset, and a Style +Dictionary JSON), and generates **typed React components** (`.tsx` + Storybook +stories) from it. > Part of the [InDesign-to-React pipeline](../../docs/indesign-to-react/README.md) -> epic. Shipped so far: the IDML parser + IR, the PDF parser (same IR), and the -> design-token mapper. The React component generator is next. +> epic. Shipped so far: the IDML parser + IR, the PDF parser (same IR), the +> design-token mapper, and the React component generator. A Claude Code agent + +> skill that orchestrate the full pipeline are next. ## Designer hands you a PDF @@ -137,6 +139,35 @@ The mapper: `tokens.ts` is emitted self-contained (`as const`) so it type-checks anywhere; the token shape is also published as the zod `DesignTokensSchema`. +## React components + +Generate typed React components (one per spread) plus Storybook stories, a +barrel `index.ts`, and a generation report: + +```ts +import { parseIdmlFile } from "@aurelius/pipeline/indesign"; +import { mapDocumentToTokens } from "@aurelius/pipeline/tokens"; +import { generateComponents } from "@aurelius/pipeline/react"; + +const { document } = parseIdmlFile("brochure.idml"); +const tokens = mapDocumentToTokens(document); +const { files, report, a11y } = generateComponents(document, tokens, { + styleMode: "tailwind", // or "css-modules" + framework: "react", // or "next" (uses next/image) +}); +// files: [{ path: "Spread1.tsx", contents }, { path: "Spread1.stories.tsx", … }, "index.ts", report] +``` + +- **Text frames** → semantic tags (`h1`–`h6` / `p` / `figcaption`) inferred from + the paragraph-style role; **image frames** → `` or `next/image`. +- **Explicit prop types** for all extracted content, so consumers override + content while keeping layout; extracted text/`src` become the prop defaults. +- **Tailwind** classes resolve via the mapper's preset; **CSS Modules** emit a + co-located `.module.css` referencing the `tokens.css` custom properties. +- **Deterministic** — the same IR always produces byte-identical output. +- The report lists produced files, staged assets, unmapped IR nodes, and + accessibility TODOs (e.g. images missing alt text). + ## CLI The package ships an `aurelius-indesign` binary (built to `dist/indesign/cli.js`): @@ -151,14 +182,20 @@ node dist/indesign/cli.js brochure.idml --json --pretty > ir.json # Map the IR to design tokens and write the four artifacts into a directory node dist/indesign/cli.js brochure.idml --emit-tokens ./src/tokens +# Generate React components (+ stories, barrel, report) from the IR +node dist/indesign/cli.js brochure.idml --emit-components ./src/components --style tailwind + # Options -# --json emit the IR as JSON on stdout -# --pretty pretty-print JSON -# --dpi pixel conversion DPI (default 96) -# --no-validate skip zod validation of the produced IR -# --emit-tokens map the IR to tokens and write the four artifacts -# --no-tailwind with --emit-tokens, skip tailwind.preset.ts -# --font-map JSON file of font fallback overrides +# --json emit the IR as JSON on stdout +# --pretty pretty-print JSON +# --dpi pixel conversion DPI (default 96) +# --no-validate skip zod validation of the produced IR +# --emit-tokens map the IR to tokens and write the four artifacts +# --no-tailwind with --emit-tokens, skip tailwind.preset.ts +# --emit-components generate .tsx components, stories, and a report +# --style component styling: tailwind | css-modules +# --framework target framework: react | next +# --font-map JSON file of font fallback overrides ``` Example report: diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json index 1928e0d..2b5a03a 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -24,6 +24,10 @@ "./pdf": { "types": "./dist/pdf/index.d.ts", "import": "./dist/pdf/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js" } }, "files": [ @@ -46,7 +50,11 @@ "devDependencies": { "@types/node": "^20.17.0", "@types/pngjs": "^6.0.5", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "pdf-lib": "^1.17.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", "typescript": "^5.7.2", "vitest": "^4.1.2" } diff --git a/packages/pipeline/src/indesign/cli.ts b/packages/pipeline/src/indesign/cli.ts index 89fb4fa..60a0d2e 100644 --- a/packages/pipeline/src/indesign/cli.ts +++ b/packages/pipeline/src/indesign/cli.ts @@ -9,6 +9,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { generateComponents } from "../react/index.js"; +import type { Framework, GenerationResult, StyleMode } from "../react/model.js"; import { parseSourceFile, type SourcePriority } from "../source.js"; import { emitAll } from "../tokens/emit.js"; import type { FontMapOverrides } from "../tokens/font-map.js"; @@ -39,6 +41,9 @@ Options: --emit-tokens Map the IR to design tokens and write tokens.ts, tokens.css, tailwind.preset.ts, design-tokens.json --no-tailwind With --emit-tokens, skip tailwind.preset.ts + --emit-components Generate .tsx components, stories, and a report + --style Component styling: tailwind | css-modules (default tailwind) + --framework Target framework: react | next (default react) --font-map JSON file of font fallback overrides -h, --help Show this help `; @@ -52,6 +57,9 @@ interface ParsedArgs { tailwind: boolean; dpi?: number; emitTokens?: string; + emitComponents?: string; + styleMode?: StyleMode; + framework?: Framework; fontMap?: string; sourcePriority?: SourcePriority; assets?: string; @@ -100,6 +108,25 @@ function parseArgs(argv: string[]): ParsedArgs { else args.emitTokens = dir; break; } + case "--emit-components": { + const dir = argv[++i]; + if (!dir) args.error = "--emit-components requires a directory"; + else args.emitComponents = dir; + break; + } + case "--style": { + const value = argv[++i]; + if (value === "tailwind" || value === "css-modules") args.styleMode = value; + else + args.error = `Invalid --style: ${value ?? "(missing)"} (expected tailwind|css-modules)`; + break; + } + case "--framework": { + const value = argv[++i]; + if (value === "react" || value === "next") args.framework = value; + else args.error = `Invalid --framework: ${value ?? "(missing)"} (expected react|next)`; + break; + } case "--font-map": { const file = argv[++i]; if (!file) args.error = "--font-map requires a file path"; @@ -157,6 +184,10 @@ export async function runCli(argv: string[]): Promise { return { code: 1, stdout: "", stderr: `${errorLabel(err)}: ${toMessage(err)}\n` }; } + if (args.emitComponents) { + return emitComponentsFlow(document, args); + } + if (args.emitTokens) { return emitTokens(document, args); } @@ -210,6 +241,53 @@ function emitTokens(document: Document, args: ParsedArgs): CliResult { }; } +function emitComponentsFlow(document: Document, args: ParsedArgs): CliResult { + const tokens = mapDocumentToTokens(document, { ...(args.dpi ? { dpi: args.dpi } : {}) }); + const result = generateComponents(document, tokens, { + ...(args.styleMode ? { styleMode: args.styleMode } : {}), + ...(args.framework ? { framework: args.framework } : {}), + }); + + try { + mkdirSync(args.emitComponents!, { recursive: true }); + for (const file of result.files) { + writeFileSync(join(args.emitComponents!, file.path), file.contents); + } + } catch (err) { + return { + code: 1, + stdout: "", + stderr: `Error: cannot write components to "${args.emitComponents}": ${toMessage(err)}\n`, + }; + } + + return { + code: 0, + stdout: formatComponentReport(result, args.emitComponents!, args.styleMode ?? "tailwind"), + stderr: formatWarningSummary(document.warnings), + }; +} + +function formatComponentReport(result: GenerationResult, dir: string, styleMode: string): string { + const lines: string[] = []; + lines.push("InDesign → React Components"); + lines.push(` output: ${dir}`); + lines.push(` style mode: ${styleMode}`); + lines.push( + ` components: ${result.componentNames.length} (${result.componentNames.join(", ") || "none"})`, + ); + lines.push(` files: ${result.files.length}`); + lines.push(` assets: ${result.assets.length}`); + lines.push(` unmapped: ${result.unmapped.length}`); + lines.push(` a11y TODOs: ${result.a11y.length}`); + lines.push(` report: ${join(dir, "indesign-pipeline-report.md")}`); + if (result.a11y.length > 0) { + lines.push("", "Accessibility TODOs"); + for (const item of result.a11y) lines.push(` ${item}`); + } + return `${lines.join("\n")}\n`; +} + interface FrameCounts { text: number; image: number; diff --git a/packages/pipeline/src/index.ts b/packages/pipeline/src/index.ts index 044b880..436dba8 100644 --- a/packages/pipeline/src/index.ts +++ b/packages/pipeline/src/index.ts @@ -7,6 +7,7 @@ */ export * as indesign from "./indesign/index.js"; export * as pdf from "./pdf/index.js"; +export * as react from "./react/index.js"; export * as tokens from "./tokens/index.js"; export { parseSourceFile, diff --git a/packages/pipeline/src/react/__tests__/__snapshots__/generator.test.ts.snap b/packages/pipeline/src/react/__tests__/__snapshots__/generator.test.ts.snap new file mode 100644 index 0000000..bd37add --- /dev/null +++ b/packages/pipeline/src/react/__tests__/__snapshots__/generator.test.ts.snap @@ -0,0 +1,59 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`deterministic JSX snapshots > image-heavy spread 1`] = ` +"// AUTO-GENERATED by @aurelius/pipeline. Do not edit by hand. + +export type Spread1Props = { + image?: string; + imageAlt?: string; + image2?: string; + image2Alt?: string; + body?: string; +}; + +export function Spread1({ + image = "/indesign/spread1-image.png", + imageAlt = "", + image2 = "/indesign/spread1-image2.png", + image2Alt = "", + body = "Photography by the studio.", +}: Spread1Props) { + return ( +
+ {imageAlt} + {image2Alt} +

{body}

+
+ ); +} + +export default Spread1; +" +`; + +exports[`deterministic JSX snapshots > text-heavy spread 1`] = ` +"// AUTO-GENERATED by @aurelius/pipeline. Do not edit by hand. + +export type Spread1Props = { + heading?: string; + body?: string; + caption?: string; +}; + +export function Spread1({ + heading = "Annual Report 2026", + body = "Body paragraph line 1 with enough text to read as flowing copy.\\nBody paragraph line 2 with enough text to read as flowing copy.\\nBody paragraph line 3 with enough text to read as flowing copy.\\nBody paragraph line 4 with enough text to read as flowing copy.\\nBody paragraph line 5 with enough text to read as flowing copy.\\nBody paragraph line 6 with enough text to read as flowing copy.\\nBody paragraph line 7 with enough text to read as flowing copy.\\nBody paragraph line 8 with enough text to read as flowing copy.\\nBody paragraph line 9 with enough text to read as flowing copy.\\nBody paragraph line 10 with enough text to read as flowing copy.\\nBody paragraph line 11 with enough text to read as flowing copy.\\nBody paragraph line 12 with enough text to read as flowing copy.\\nBody paragraph line 13 with enough text to read as flowing copy.\\nBody paragraph line 14 with enough text to read as flowing copy.\\nBody paragraph line 15 with enough text to read as flowing copy.\\nBody paragraph line 16 with enough text to read as flowing copy.\\nBody paragraph line 17 with enough text to read as flowing copy.\\nBody paragraph line 18 with enough text to read as flowing copy.\\nBody paragraph line 19 with enough text to read as flowing copy.\\nBody paragraph line 20 with enough text to read as flowing copy.\\nBody paragraph line 21 with enough text to read as flowing copy.\\nBody paragraph line 22 with enough text to read as flowing copy.\\nBody paragraph line 23 with enough text to read as flowing copy.\\nBody paragraph line 24 with enough text to read as flowing copy.", + caption = "Figure 1 — a small caption set in eight point type.", +}: Spread1Props) { + return ( +
+

{heading}

+

{body}

+
{caption}
+
+ ); +} + +export default Spread1; +" +`; diff --git a/packages/pipeline/src/react/__tests__/cli-components.test.ts b/packages/pipeline/src/react/__tests__/cli-components.test.ts new file mode 100644 index 0000000..017f1a3 --- /dev/null +++ b/packages/pipeline/src/react/__tests__/cli-components.test.ts @@ -0,0 +1,57 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildSampleIdml } from "../../indesign/__tests__/idml-fixtures.js"; +import { runCli } from "../../indesign/cli.js"; + +const tempDirs: string[] = []; +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "gen-cli-")); + tempDirs.push(dir); + return dir; +} +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function writeSample(): string { + const dir = tempDir(); + const path = join(dir, "sample.idml"); + writeFileSync(path, buildSampleIdml()); + return path; +} + +describe("CLI --emit-components", () => { + it("writes components, stories, a barrel, and a report", async () => { + const idml = writeSample(); + const out = join(tempDir(), "components"); + const result = await runCli([idml, "--emit-components", out]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("InDesign → React Components"); + const files = readdirSync(out); + expect(files.some((f) => f.endsWith(".tsx") && !f.includes("stories"))).toBe(true); + expect(files.some((f) => f.endsWith(".stories.tsx"))).toBe(true); + expect(files).toContain("index.ts"); + expect(files).toContain("indesign-pipeline-report.md"); + }); + + it("emits co-located CSS with --style css-modules", async () => { + const idml = writeSample(); + const out = join(tempDir(), "components"); + const result = await runCli([idml, "--emit-components", out, "--style", "css-modules"]); + + expect(result.code).toBe(0); + expect(readdirSync(out).some((f) => f.endsWith(".module.css"))).toBe(true); + }); + + it("rejects an invalid --style", async () => { + const result = await runCli(["x.idml", "--emit-components", "y", "--style", "sass"]); + expect(result.code).toBe(2); + expect(result.stderr).toContain("Invalid --style"); + }); +}); diff --git a/packages/pipeline/src/react/__tests__/generator.test.ts b/packages/pipeline/src/react/__tests__/generator.test.ts new file mode 100644 index 0000000..dac4b42 --- /dev/null +++ b/packages/pipeline/src/react/__tests__/generator.test.ts @@ -0,0 +1,178 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { buildSampleIdml } from "../../indesign/__tests__/idml-fixtures.js"; +import { parseIdml } from "../../indesign/parser.js"; +import { mapDocumentToTokens } from "../../tokens/mapper.js"; +import { imageHeavyPdf, textHeavyPdf } from "../../pdf/__tests__/pdf-fixtures.js"; +import { parsePdf } from "../../pdf/parser.js"; +import { generateComponents } from "../generator.js"; +import type { GeneratedFile } from "../model.js"; + +const SHIMS = ` +declare module "*.module.css" { + const classes: { readonly [key: string]: string }; + export default classes; +} +declare module "@storybook/react" { + export interface Meta { component?: T; title?: string } + export type StoryObj = { args?: Record }; +} +declare module "next/image" { + import type { ComponentType } from "react"; + const Image: ComponentType<{ src: string; alt: string; width?: number; height?: number; className?: string }>; + export default Image; +} +`; + +/** Type-check generated .ts/.tsx files with strict tsc + React JSX; return errors. */ +function compile(files: GeneratedFile[]): string[] { + // Place the temp dir under node_modules so `react`/`react/jsx-runtime` resolve. + const dir = mkdtempSync(join(process.cwd(), "node_modules", ".gen-tsc-")); + try { + const roots: string[] = []; + for (const file of files) { + if (!/\.(ts|tsx)$/.test(file.path)) continue; + const target = join(dir, file.path); + writeFileSync(target, file.contents); + roots.push(target); + } + const shim = join(dir, "shims.d.ts"); + writeFileSync(shim, SHIMS); + roots.push(shim); + + const program = ts.createProgram(roots, { + strict: true, + noEmit: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + skipLibCheck: true, + }); + return ts + .getPreEmitDiagnostics(program) + .map( + (d) => + `${d.file?.fileName.split(/[/\\]/).pop() ?? ""}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Transpile a Tailwind component to CJS, evaluate it, and server-render it. */ +function render(tsxSource: string, componentName: string): string { + const js = ts.transpileModule(tsxSource, { + compilerOptions: { + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }).outputText; + + const nodeRequire = createRequire(import.meta.url); + const shimRequire = (id: string): unknown => { + if (id === "react") return nodeRequire("react"); + if (id === "react/jsx-runtime") return nodeRequire("react/jsx-runtime"); + throw new Error(`unexpected import: ${id}`); + }; + const mod = { exports: {} as Record }; + new Function("require", "exports", "module", js)(shimRequire, mod.exports, mod); + + const React = nodeRequire("react") as typeof import("react"); + const { renderToStaticMarkup } = nodeRequire( + "react-dom/server", + ) as typeof import("react-dom/server"); + const Component = (mod.exports[componentName] ?? mod.exports.default) as React.ComponentType; + return renderToStaticMarkup(React.createElement(Component)); +} + +function idmlSample() { + const { document } = parseIdml(buildSampleIdml()); + return { document, tokens: mapDocumentToTokens(document) }; +} + +describe("generateComponents — Tailwind", () => { + it("generates components that type-check under strict tsc with React JSX", () => { + const { document, tokens } = idmlSample(); + const result = generateComponents(document, tokens, { styleMode: "tailwind" }); + expect(compile(result.files)).toEqual([]); + }); + + it("renders the generated component to static markup without runtime errors", () => { + const { document, tokens } = idmlSample(); + const result = generateComponents(document, tokens, { styleMode: "tailwind" }); + const component = result.files.find((f) => f.path === `${result.componentNames[0]}.tsx`)!; + const html = render(component.contents, result.componentNames[0]!); + expect(html).toContain(" { + const { document, tokens } = idmlSample(); + const result = generateComponents(document, tokens); + const name = result.componentNames[0]!; + expect(result.files.some((f) => f.path === "index.ts")).toBe(true); + const story = result.files.find((f) => f.path === `${name}.stories.tsx`)!; + expect(story.contents).toContain("export const Default"); + expect(story.contents).toContain('"Welcome to Aurelius\\nPrint to React, beautifully."'); + }); +}); + +describe("generateComponents — CSS Modules", () => { + it("generates components + co-located CSS that type-check", () => { + const { document, tokens } = idmlSample(); + const result = generateComponents(document, tokens, { styleMode: "css-modules" }); + const css = result.files.find((f) => f.path.endsWith(".module.css")); + expect(css).toBeDefined(); + expect(css!.contents).toContain("var(--font-size-heading)"); + expect(compile(result.files)).toEqual([]); + }); +}); + +describe("generateComponents — Next.js", () => { + it("uses next/image and still type-checks", () => { + const { document, tokens } = idmlSample(); + const result = generateComponents(document, tokens, { framework: "next" }); + const tsx = result.files.find((f) => f.path.endsWith(".tsx") && !f.path.includes("stories"))!; + expect(tsx.contents).toContain('import Image from "next/image"'); + expect(tsx.contents).toContain(" { + it("enumerates files, assets, unmapped nodes, and accessibility TODOs", () => { + const { document, tokens } = idmlSample(); + const { report } = generateComponents(document, tokens); + expect(report).toContain("# InDesign Pipeline — Generation Report"); + expect(report).toContain("## Components"); + expect(report).toContain("`Links/hero.png`"); + expect(report).toContain("## Accessibility TODOs (1)"); + expect(report).toContain("missing alt text"); + }); +}); + +describe("deterministic JSX snapshots", () => { + it("text-heavy spread", async () => { + const { document } = await parsePdf(await textHeavyPdf()); + const tokens = mapDocumentToTokens(document); + const result = generateComponents(document, tokens, { styleMode: "tailwind" }); + const tsx = result.files.find((f) => f.path.endsWith(".tsx") && !f.path.includes("stories"))!; + expect(tsx.contents).toMatchSnapshot(); + }); + + it("image-heavy spread", async () => { + const { document } = await parsePdf(await imageHeavyPdf()); + const tokens = mapDocumentToTokens(document); + const result = generateComponents(document, tokens, { styleMode: "tailwind" }); + const tsx = result.files.find((f) => f.path.endsWith(".tsx") && !f.path.includes("stories"))!; + expect(tsx.contents).toMatchSnapshot(); + }); +}); diff --git a/packages/pipeline/src/react/emit.ts b/packages/pipeline/src/react/emit.ts new file mode 100644 index 0000000..7b0683f --- /dev/null +++ b/packages/pipeline/src/react/emit.ts @@ -0,0 +1,117 @@ +/** + * Emit React source strings from a {@link ComponentPlan}: the `.tsx` component, + * a co-located `.module.css` (css-modules mode), a `.stories.tsx`, and the + * package barrel. Output is deterministic given the same plan. + */ +import type { ComponentPlan, ElementNode, GenerateOptions, StyleMode } from "./model.js"; + +const BANNER = "// AUTO-GENERATED by @aurelius/pipeline. Do not edit by hand."; +const CSS_BANNER = "/* AUTO-GENERATED by @aurelius/pipeline. Do not edit by hand. */"; + +type ResolvedOptions = Required>; + +/** Emit the `.tsx` component module. */ +export function emitComponentTsx(plan: ComponentPlan, options: ResolvedOptions): string { + const imports: string[] = []; + if (options.framework === "next" && plan.elements.some((e) => e.kind === "image")) { + imports.push(`import Image from "next/image";`); + } + if (options.styleMode === "css-modules") { + imports.push(`import styles from "./${plan.name}.module.css";`); + } + + const propsType = emitPropsType(plan); + const signature = emitSignature(plan); + const container = containerClass(plan, options.styleMode); + const body = plan.elements + .map((el) => ` ${renderElement(el, options.styleMode, options.framework)}`) + .join("\n"); + + const lines = [BANNER]; + if (imports.length > 0) lines.push("", ...imports); + lines.push( + "", + propsType, + "", + `${signature} {`, + " return (", + ` `, + body, + " ", + " );", + "}", + "", + `export default ${plan.name};`, + "", + ); + return lines.join("\n"); +} + +function emitPropsType(plan: ComponentPlan): string { + if (plan.props.length === 0) return `export type ${plan.name}Props = Record;`; + const fields = plan.props.map((p) => ` ${p.name}?: string;`).join("\n"); + return `export type ${plan.name}Props = {\n${fields}\n};`; +} + +function emitSignature(plan: ComponentPlan): string { + if (plan.props.length === 0) return `export function ${plan.name}()`; + const params = plan.props.map((p) => ` ${p.name} = ${JSON.stringify(p.default)},`).join("\n"); + return `export function ${plan.name}({\n${params}\n}: ${plan.name}Props)`; +} + +function containerClass(plan: ComponentPlan, mode: StyleMode): string { + return mode === "tailwind" ? ` className="${plan.layout.className}"` : ` className={styles.root}`; +} + +function renderElement(el: ElementNode, mode: StyleMode, framework: string): string { + const cls = + mode === "tailwind" + ? el.className + ? ` className="${el.className}"` + : "" + : ` className={styles.${el.cssClass}}`; + + if (el.kind === "text") { + return `<${el.tag}${cls}>{${el.propName}}`; + } + if (framework === "next" && el.tag === "Image") { + return `{${el.altPropName}}`; + } + return `{${el.altPropName}}${cls}`; +} + +/** Emit the co-located CSS Modules stylesheet. */ +export function emitComponentCss(plan: ComponentPlan): string { + const lines = [CSS_BANNER]; + const root = plan.css["root"]; + if (root) lines.push(`.root {\n ${root}\n}`); + for (const el of plan.elements) { + const body = plan.css[el.cssClass]; + if (body) lines.push(`.${el.cssClass} {\n ${body}\n}`); + } + return `${lines.join("\n\n")}\n`; +} + +/** Emit a Storybook story with extracted content as default args. */ +export function emitStoriesTsx(plan: ComponentPlan): string { + const args = plan.props.map((p) => ` ${p.name}: ${JSON.stringify(p.default)},`).join("\n"); + return [ + BANNER, + `import type { Meta, StoryObj } from "@storybook/react";`, + `import { ${plan.name} } from "./${plan.name}";`, + "", + `const meta = { component: ${plan.name}, title: "InDesign/${plan.name}" } satisfies Meta;`, + "export default meta;", + "", + `export const Default: StoryObj = {`, + plan.props.length > 0 ? ` args: {\n${args}\n },` : " args: {},", + "};", + "", + ].join("\n"); +} + +/** Emit the package barrel re-exporting every component. */ +export function emitIndexTs(componentNames: string[]): string { + const exports = componentNames.map((name) => `export { ${name} } from "./${name}";`); + return [BANNER, ...exports, ""].join("\n"); +} diff --git a/packages/pipeline/src/react/generator.ts b/packages/pipeline/src/react/generator.ts new file mode 100644 index 0000000..fa80b21 --- /dev/null +++ b/packages/pipeline/src/react/generator.ts @@ -0,0 +1,73 @@ +/** + * React component generator. + * + * Turns an IR {@link Document} plus a {@link TokenMapResult} into a directory of + * importable React artifacts: one typed `.tsx` per spread, optional Storybook + * stories, a `.module.css` per component (css-modules mode), a barrel `index.ts`, + * and a Markdown generation report. Deterministic — the same IR always produces + * byte-identical output. + */ +import type { Document } from "../indesign/ir.js"; +import type { TokenMapResult } from "../tokens/model.js"; +import { emitComponentCss, emitComponentTsx, emitIndexTs, emitStoriesTsx } from "./emit.js"; +import type { GeneratedFile, GenerateOptions, GenerationResult, StagedAsset } from "./model.js"; +import { buildComponentPlans } from "./plan.js"; +import { buildReport } from "./report.js"; + +export const REPORT_FILENAME = "indesign-pipeline-report.md"; + +/** Generate React artifacts from the IR and design tokens. */ +export function generateComponents( + document: Document, + tokens: TokenMapResult, + options: GenerateOptions = {}, +): GenerationResult { + const styleMode = options.styleMode ?? "tailwind"; + const framework = options.framework ?? "react"; + const assetPath = options.assetPath ?? "/indesign"; + const stories = options.stories ?? true; + + const plans = buildComponentPlans(document, tokens, { styleMode, framework, assetPath }); + const files: GeneratedFile[] = []; + + for (const plan of plans) { + files.push({ + path: `${plan.name}.tsx`, + contents: emitComponentTsx(plan, { styleMode, framework, assetPath }), + }); + if (styleMode === "css-modules") { + files.push({ path: `${plan.name}.module.css`, contents: emitComponentCss(plan) }); + } + if (stories) { + files.push({ path: `${plan.name}.stories.tsx`, contents: emitStoriesTsx(plan) }); + } + } + + const componentNames = plans.map((p) => p.name); + files.push({ path: "index.ts", contents: emitIndexTs(componentNames) }); + + const assets = dedupeAssets(plans.flatMap((p) => p.assets)); + const unmapped = plans.flatMap((p) => p.unmapped); + const a11y = plans.flatMap((p) => p.a11y); + + const report = buildReport(plans, assets, { + styleMode, + framework, + stories, + ...(document.meta.source ? { source: document.meta.source } : {}), + }); + files.push({ path: REPORT_FILENAME, contents: report }); + + return { files, componentNames, report, assets, unmapped, a11y }; +} + +function dedupeAssets(assets: StagedAsset[]): StagedAsset[] { + const seen = new Set(); + const out: StagedAsset[] = []; + for (const asset of assets) { + if (seen.has(asset.src)) continue; + seen.add(asset.src); + out.push(asset); + } + return out; +} diff --git a/packages/pipeline/src/react/index.ts b/packages/pipeline/src/react/index.ts new file mode 100644 index 0000000..987f8b6 --- /dev/null +++ b/packages/pipeline/src/react/index.ts @@ -0,0 +1,20 @@ +/** + * Public API for the React component generator. + * + * @example + * ```ts + * import { parseIdmlFile } from "@aurelius/pipeline/indesign"; + * import { mapDocumentToTokens } from "@aurelius/pipeline/tokens"; + * import { generateComponents } from "@aurelius/pipeline/react"; + * + * const { document } = parseIdmlFile("brochure.idml"); + * const tokens = mapDocumentToTokens(document); + * const { files, report } = generateComponents(document, tokens, { styleMode: "tailwind" }); + * // files: [{ path: "Spread1.tsx", contents }, { path: "Spread1.stories.tsx", … }, …] + * ``` + */ +export { generateComponents, REPORT_FILENAME } from "./generator.js"; +export { buildComponentPlans } from "./plan.js"; +export { emitComponentTsx, emitComponentCss, emitStoriesTsx, emitIndexTs } from "./emit.js"; +export { buildReport } from "./report.js"; +export type * from "./model.js"; diff --git a/packages/pipeline/src/react/model.ts b/packages/pipeline/src/react/model.ts new file mode 100644 index 0000000..e031940 --- /dev/null +++ b/packages/pipeline/src/react/model.ts @@ -0,0 +1,92 @@ +/** + * Model for the React component generator. + * + * A {@link ComponentPlan} is the framework-agnostic description of one generated + * component (its props, elements, layout, and styling), derived from a spread of + * the IR plus the design tokens. The emitters turn plans into `.tsx`, + * `.module.css`, and `.stories.tsx` strings. + */ +export type StyleMode = "tailwind" | "css-modules"; +export type Framework = "react" | "next"; + +export interface GenerateOptions { + /** Styling strategy. Default `"tailwind"`. */ + styleMode?: StyleMode; + /** Target framework — affects `` vs `next/image`. Default `"react"`. */ + framework?: Framework; + /** Public path prefix for staged assets. Default `"/indesign"`. */ + assetPath?: string; + /** Emit `*.stories.tsx` per component. Default `true`. */ + stories?: boolean; +} + +export interface PropSpec { + name: string; + /** Default value (string literal content). */ + default: string; +} + +export type ElementKind = "text" | "image"; + +export interface ElementNode { + kind: ElementKind; + /** Output tag: `h1`–`h6`, `p`, `figcaption`, `img`, or `Image` (next). */ + tag: string; + /** Content prop bound to this element (text content, or image `src`). */ + propName: string; + defaultValue: string; + /** Tailwind classes (tailwind mode). */ + className: string; + /** CSS Modules class key (css-modules mode). */ + cssClass: string; + /** Alt-text prop name for images. */ + altPropName?: string; + /** Pixel width/height for `next/image`. */ + width?: number; + height?: number; +} + +export interface LayoutSpec { + kind: "flow" | "grid"; + columns: number; + /** Tailwind container classes. */ + className: string; + /** CSS body for the container in css-modules mode. */ + cssBody: string; +} + +export interface ComponentPlan { + name: string; + /** Spread id this component was derived from. */ + sourceId: string; + props: PropSpec[]; + elements: ElementNode[]; + layout: LayoutSpec; + /** Selector (e.g. `"root"`, `"heading"`) → CSS body, for css-modules mode. */ + css: Record; + assets: StagedAsset[]; + unmapped: string[]; + a11y: string[]; +} + +export interface GeneratedFile { + /** Path relative to the output directory. */ + path: string; + contents: string; +} + +export interface StagedAsset { + /** Public `src` referenced by the component. */ + src: string; + /** Package-relative source path the asset was resolved from. */ + from: string; +} + +export interface GenerationResult { + files: GeneratedFile[]; + componentNames: string[]; + report: string; + assets: StagedAsset[]; + unmapped: string[]; + a11y: string[]; +} diff --git a/packages/pipeline/src/react/plan.ts b/packages/pipeline/src/react/plan.ts new file mode 100644 index 0000000..f7eae57 --- /dev/null +++ b/packages/pipeline/src/react/plan.ts @@ -0,0 +1,336 @@ +/** + * Derive {@link ComponentPlan}s from the IR + design tokens. + * + * One plan per spread: frames are flattened in reading order, text frames become + * semantic elements (tag inferred from the paragraph-style role), image frames + * become ``/`next/image`, and styling resolves to Tailwind classes or CSS + * custom properties that match the token names emitted by the mapper. Pure and + * deterministic — the same IR always yields the same plans. + */ +import type { + Document, + Frame, + ImageFrame, + Spread, + Story, + Style, + TextFrame, +} from "../indesign/ir.js"; +import { slug } from "../tokens/mapper.js"; +import type { TokenMapResult } from "../tokens/model.js"; +import type { + ComponentPlan, + ElementNode, + Framework, + LayoutSpec, + PropSpec, + StagedAsset, + StyleMode, +} from "./model.js"; + +interface PlanContext { + document: Document; + tokens: TokenMapResult; + styleMode: StyleMode; + framework: Framework; + assetPath: string; + styleById: Map; + storyById: Map; + swatchNameById: Map; + roleByToken: Map; +} + +interface BuildOptions { + styleMode: StyleMode; + framework: Framework; + assetPath: string; +} + +/** Build one component plan per spread. */ +export function buildComponentPlans( + document: Document, + tokens: TokenMapResult, + options: BuildOptions, +): ComponentPlan[] { + const ctx: PlanContext = { + document, + tokens, + styleMode: options.styleMode, + framework: options.framework, + assetPath: options.assetPath, + styleById: new Map(document.paragraphStyles.map((s) => [s.id, s])), + storyById: new Map(document.stories.map((s) => [s.id, s])), + swatchNameById: new Map(document.swatches.map((s) => [s.id, s.name])), + roleByToken: buildRoleMap(tokens), + }; + + const used = new Set(); + return document.spreads.map((spread, index) => planForSpread(spread, index, ctx, used)); +} + +function planForSpread( + spread: Spread, + index: number, + ctx: PlanContext, + usedNames: Set, +): ComponentPlan { + const name = uniqueName(componentName(spread, index), usedNames); + const unmapped: string[] = []; + const frames = orderForReading(flatten(spread.frames, spread.id, unmapped)); + + const elements: ElementNode[] = []; + const props: PropSpec[] = []; + const css: Record = {}; + const assets: StagedAsset[] = []; + const a11y: string[] = []; + const usedProps = new Set(); + let imageIndex = 0; + + for (const frame of frames) { + if (frame.type === "text") { + const element = textElement(frame, ctx, usedProps, css); + if (!element) continue; + elements.push(element); + props.push({ name: element.propName, default: element.defaultValue }); + } else { + imageIndex++; + const element = imageElement(frame, ctx, usedProps, css, name); + elements.push(element); + props.push({ name: element.propName, default: element.defaultValue }); + if (element.altPropName) props.push({ name: element.altPropName, default: "" }); + assets.push({ src: element.defaultValue, from: frame.image.resolvedPath ?? "(embedded)" }); + a11y.push( + `${name}: image #${imageIndex} is missing alt text — set the \`${element.altPropName}\` prop`, + ); + } + } + + const layout = layoutFor(frames, ctx); + if (ctx.styleMode === "css-modules") css["root"] = layout.cssBody; + + return { name, sourceId: spread.id, props, elements, layout, css, assets, unmapped, a11y }; +} + +/* ── Frame flattening & ordering ─────────────────────────────────────────── */ + +function flatten( + frames: Frame[], + spreadId: string, + unmapped: string[], +): (TextFrame | ImageFrame)[] { + const out: (TextFrame | ImageFrame)[] = []; + for (const frame of frames) { + if (frame.type === "text" || frame.type === "image") { + out.push(frame); + } else if (frame.type === "group") { + out.push(...flatten(frame.children, spreadId, unmapped)); + } else { + unmapped.push( + `${spreadId}: ${frame.type} frame "${frame.name ?? frame.id}" has no JSX mapping`, + ); + } + } + return out; +} + +function orderForReading(frames: T[]): T[] { + return [...frames].sort((a, b) => a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x); +} + +/* ── Elements ────────────────────────────────────────────────────────────── */ + +function textElement( + frame: TextFrame, + ctx: PlanContext, + usedProps: Set, + css: Record, +): ElementNode | undefined { + const story = frame.storyId ? ctx.storyById.get(frame.storyId) : undefined; + const text = (story?.plainText ?? "").trim(); + if (!text) return undefined; + + const style = resolveStyle(frame, ctx); + const role = style ? ctx.roleByToken.get(slug(style.name)) : undefined; + const kind = role?.role ?? "body"; + const tag = tagFor(kind, role?.level ?? 1); + const propName = uniqueName(kind, usedProps); + + const cssClass = propName; + if (ctx.styleMode === "css-modules") css[cssClass] = cssBodyForStyle(style, ctx); + + return { + kind: "text", + tag, + propName, + defaultValue: text, + className: ctx.styleMode === "tailwind" ? tailwindTextClasses(style, ctx) : "", + cssClass, + }; +} + +function imageElement( + frame: ImageFrame, + ctx: PlanContext, + usedProps: Set, + css: Record, + componentSlug: string, +): ElementNode { + const propName = uniqueName("image", usedProps); + const altPropName = `${propName}Alt`; + usedProps.add(altPropName); + const src = assetSrc(frame, ctx, `${slug(componentSlug)}-${propName}`); + + const cssClass = propName; + if (ctx.styleMode === "css-modules") css[cssClass] = "max-width: 100%;\n height: auto;"; + + const element: ElementNode = { + kind: "image", + tag: ctx.framework === "next" ? "Image" : "img", + propName, + defaultValue: src, + altPropName, + className: ctx.styleMode === "tailwind" ? "max-w-full h-auto" : "", + cssClass, + width: Math.round(frame.bounds.width) || 1, + height: Math.round(frame.bounds.height) || 1, + }; + return element; +} + +function assetSrc(frame: ImageFrame, ctx: PlanContext, fallbackBase: string): string { + const resolved = frame.image.resolvedPath; + const base = resolved ? basename(resolved) : `${fallbackBase}.png`; + return `${ctx.assetPath}/${base}`; +} + +/* ── Styling resolution ──────────────────────────────────────────────────── */ + +function tailwindTextClasses(style: Style | undefined, ctx: PlanContext): string { + if (!style) return ""; + const t = ctx.tokens.tokens; + const classes: string[] = []; + const sizeKey = slug(style.name); + if (t.fontSizes[sizeKey]) classes.push(`text-${sizeKey}`); + if (t.lineHeights[sizeKey]) classes.push(`leading-${sizeKey}`); + if (t.letterSpacing[sizeKey]) classes.push(`tracking-${sizeKey}`); + + const family = style.properties.fontFamily; + if (family && t.fontFamilies[slug(family)]) classes.push(`font-${slug(family)}`); + + const colorKey = colorTokenKey(style, ctx); + if (colorKey) classes.push(`text-${colorKey}`); + return classes.join(" "); +} + +function cssBodyForStyle(style: Style | undefined, ctx: PlanContext): string { + if (!style) return ""; + const t = ctx.tokens.tokens; + const lines: string[] = []; + const sizeKey = slug(style.name); + if (t.fontSizes[sizeKey]) lines.push(`font-size: var(--font-size-${sizeKey});`); + if (t.lineHeights[sizeKey]) lines.push(`line-height: var(--line-height-${sizeKey});`); + if (t.letterSpacing[sizeKey]) lines.push(`letter-spacing: var(--letter-spacing-${sizeKey});`); + + const family = style.properties.fontFamily; + if (family && t.fontFamilies[slug(family)]) { + lines.push(`font-family: var(--font-family-${slug(family)});`); + } + const colorKey = colorTokenKey(style, ctx); + if (colorKey) lines.push(`color: var(--color-${colorKey});`); + return lines.join("\n "); +} + +function colorTokenKey(style: Style, ctx: PlanContext): string | undefined { + const fillId = style.properties.fillColor; + if (!fillId) return undefined; + const name = ctx.swatchNameById.get(fillId); + if (!name) return undefined; + const key = slug(name); + return ctx.tokens.tokens.colors[key] ? key : undefined; +} + +/* ── Layout ──────────────────────────────────────────────────────────────── */ + +function layoutFor(frames: Frame[], ctx: PlanContext): LayoutSpec { + const columns = detectColumns(frames); + const spacingKey = Object.keys(ctx.tokens.tokens.spacing)[0]; + const gapClass = spacingKey ? `gap-${spacingKey}` : "gap-6"; + const gapVar = spacingKey ? `var(--spacing-${spacingKey})` : "1.5rem"; + + if (columns >= 2) { + return { + kind: "grid", + columns, + className: `grid grid-cols-${columns} ${gapClass}`, + cssBody: `display: grid;\n grid-template-columns: repeat(${columns}, minmax(0, 1fr));\n gap: ${gapVar};`, + }; + } + return { + kind: "flow", + columns: 1, + className: `flex flex-col ${gapClass}`, + cssBody: `display: flex;\n flex-direction: column;\n gap: ${gapVar};`, + }; +} + +function detectColumns(frames: Frame[]): number { + const xs = [...new Set(frames.map((f) => Math.round(f.bounds.x)))].sort((a, b) => a - b); + let bands = 1; + for (let i = 1; i < xs.length; i++) { + if ((xs[i] ?? 0) - (xs[i - 1] ?? 0) > 120) bands++; + } + return Math.min(bands, 3); +} + +/* ── Names & roles ───────────────────────────────────────────────────────── */ + +function buildRoleMap( + tokens: TokenMapResult, +): Map { + const map = new Map(); + for (const h of tokens.typography.headings) + map.set(h.token, { role: "heading", level: h.level ?? 1 }); + for (const b of tokens.typography.body) map.set(b.token, { role: "body", level: 0 }); + for (const c of tokens.typography.captions) map.set(c.token, { role: "caption", level: 0 }); + return map; +} + +function resolveStyle(frame: TextFrame, ctx: PlanContext): Style | undefined { + const story = frame.storyId ? ctx.storyById.get(frame.storyId) : undefined; + const styleId = story?.paragraphs[0]?.appliedParagraphStyle; + return styleId ? ctx.styleById.get(styleId) : undefined; +} + +function tagFor(role: "heading" | "body" | "caption", level: number): string { + if (role === "heading") return `h${Math.min(Math.max(level, 1), 6)}`; + if (role === "caption") return "figcaption"; + return "p"; +} + +function componentName(spread: Spread, index: number): string { + const page = spread.pages[0]?.name; + const raw = page && !/^\d+$/.test(page) ? page : `Spread ${index + 1}`; + return pascalCase(raw); +} + +function pascalCase(raw: string): string { + const parts = raw + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .split(/\s+/); + const name = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); + return /^[A-Za-z]/.test(name) ? name : `Component${name}`; +} + +function uniqueName(base: string, used: Set): string { + let candidate = base; + let n = 2; + while (used.has(candidate)) candidate = `${base}${n++}`; + used.add(candidate); + return candidate; +} + +function basename(path: string): string { + const i = path.replace(/\\/g, "/").lastIndexOf("/"); + return i >= 0 ? path.slice(i + 1) : path; +} diff --git a/packages/pipeline/src/react/report.ts b/packages/pipeline/src/react/report.ts new file mode 100644 index 0000000..858ec03 --- /dev/null +++ b/packages/pipeline/src/react/report.ts @@ -0,0 +1,59 @@ +/** + * Build the Markdown generation report: produced files, derived props, staged + * assets, unmapped IR nodes, and accessibility follow-ups. Deterministic — no + * timestamps or environment-dependent content. + */ +import type { ComponentPlan, Framework, StagedAsset, StyleMode } from "./model.js"; + +export interface ReportOptions { + styleMode: StyleMode; + framework: Framework; + stories: boolean; + source?: "idml" | "pdf"; +} + +export function buildReport( + plans: ComponentPlan[], + assets: StagedAsset[], + options: ReportOptions, +): string { + const lines: string[] = []; + lines.push("# InDesign Pipeline — Generation Report", ""); + lines.push(`- Source: ${options.source ?? "unknown"}`); + lines.push(`- Style mode: ${options.styleMode}`); + lines.push(`- Framework: ${options.framework}`); + lines.push(`- Components: ${plans.length}`, ""); + + lines.push("## Components", ""); + for (const plan of plans) { + const files = [`\`${plan.name}.tsx\``]; + if (options.styleMode === "css-modules") files.push(`\`${plan.name}.module.css\``); + if (options.stories) files.push(`\`${plan.name}.stories.tsx\``); + lines.push(`### ${plan.name}`); + lines.push(`- From spread: \`${plan.sourceId}\``); + lines.push(`- Files: ${files.join(", ")}`); + lines.push(`- Props: ${plan.props.map((p) => p.name).join(", ") || "(none)"}`); + lines.push(`- Elements: ${plan.elements.map((e) => e.tag).join(", ") || "(none)"}`); + lines.push(""); + } + + const allUnmapped = plans.flatMap((p) => p.unmapped); + const allA11y = plans.flatMap((p) => p.a11y); + + lines.push(`## Assets (${assets.length})`, ""); + if (assets.length === 0) lines.push("- (none)"); + else for (const asset of assets) lines.push(`- \`${asset.src}\` ← \`${asset.from}\``); + lines.push(""); + + lines.push(`## Unmapped IR nodes (${allUnmapped.length})`, ""); + if (allUnmapped.length === 0) lines.push("- (none)"); + else for (const item of allUnmapped) lines.push(`- ${item}`); + lines.push(""); + + lines.push(`## Accessibility TODOs (${allA11y.length})`, ""); + if (allA11y.length === 0) lines.push("- (none)"); + else for (const item of allA11y) lines.push(`- ${item}`); + lines.push(""); + + return lines.join("\n"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87476ff..d69819a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,9 +90,21 @@ importers: '@types/pngjs': specifier: ^6.0.5 version: 6.0.5 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) pdf-lib: specifier: ^1.17.1 version: 1.17.1 + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) typescript: specifier: ^5.7.2 version: 5.9.3 @@ -829,6 +841,14 @@ packages: '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@vitest/expect@4.1.2': resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} @@ -1135,6 +1155,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dargs@7.0.0: resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} engines: {node: '>=8'} @@ -2013,6 +2036,15 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + read-pkg-up@3.0.0: resolution: {integrity: sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==} engines: {node: '>=4'} @@ -2078,6 +2110,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -3264,6 +3299,14 @@ snapshots: dependencies: '@types/node': 20.19.43 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@vitest/expect@4.1.2': dependencies: '@standard-schema/spec': 1.1.0 @@ -3615,6 +3658,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + dargs@7.0.0: {} dateformat@3.0.3: {} @@ -4390,6 +4435,13 @@ snapshots: quick-lru@4.0.1: {} + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + read-pkg-up@3.0.0: dependencies: find-up: 2.1.0 @@ -4480,6 +4532,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + semver@5.7.2: {} semver@6.3.1: {}