Skip to content

Commit 55390c4

Browse files
authored
Merge pull request #102 from PMDevSolutions/66-indesign-pipeline-react-component-generator-tsx-output-tailwindcss-modules-storybook-stories
feat(pipeline): add InDesign React component generator
2 parents 57a35c4 + 355fb56 commit 55390c4

15 files changed

Lines changed: 1208 additions & 16 deletions

File tree

docs/indesign-to-react/README.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ typed React components, design tokens, and assets — a print-first sibling to t
55
Figma/Canva/Screenshot pipelines.
66

77
> **Status:** in progress. This document tracks the epic and details the shipped
8-
> stages — the **IDML parser + IR**, the **PDF parser** (same IR), and the
9-
> **style & design-token mapper**.
8+
> stages — the **IDML parser + IR**, the **PDF parser** (same IR), the **style &
9+
> design-token mapper**, and the **React component generator**.
1010
> See the epic for the full plan: _Add InDesign-to-React conversion pipeline_.
1111
1212
## Pipeline shape (target)
@@ -125,10 +125,33 @@ node packages/pipeline/dist/indesign/cli.js your-design.idml --emit-tokens ./src
125125

126126
Font fallbacks and out-of-gamut conversions are listed in the generator report.
127127

128+
## Stage 3 — React component generator (shipped)
129+
130+
Implemented in [`packages/pipeline/src/react`](../../packages/pipeline/src/react).
131+
Turns the IR + tokens into importable React artifacts — one `.tsx` per spread:
132+
133+
- **Frames → JSX** — text frames become semantic tags (`h1``h6` / `p` /
134+
`figcaption`) inferred from the paragraph-style role; image frames become
135+
`<img>` or `next/image`; layout is a token-spaced flow (grid for multi-column).
136+
- **Typed props** — explicit `…Props` for every extracted content field, with the
137+
extracted text / image `src` as defaults, so consumers override content while
138+
keeping layout.
139+
- **Two styling modes** — Tailwind classes that resolve via the mapper's preset,
140+
or CSS Modules referencing the `tokens.css` custom properties.
141+
- **Storybook stories** per component, populated with the extracted content.
142+
- **Generation report** (`indesign-pipeline-report.md`) listing produced files,
143+
staged assets, unmapped IR nodes, and accessibility TODOs.
144+
145+
Generated components are deterministic and pass `tsc --noEmit` under strict React
146+
JSX (verified in CI for Tailwind, CSS Modules, and Next.js targets).
147+
148+
```bash
149+
node packages/pipeline/dist/indesign/cli.js brochure.idml \
150+
--emit-components ./src/components --style tailwind --framework react
151+
```
152+
128153
## Roadmap (remaining sub-issues)
129154

130-
- [ ] React component generator (TSX output, optional Tailwind/CSS Modules,
131-
Storybook stories)
132155
- [ ] `indesign-to-react` Claude Code agent + skill + end-to-end CLI command
133156

134157
## References

packages/pipeline/README.md

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
Core library for the Aurelius design-to-code pipeline. It reads Adobe InDesign
44
sources — both `.idml` packages and exported `.pdf` files — into a single
5-
normalized, typed intermediate representation (IR), and maps that IR to a
6-
coherent **design-token set** (`tokens.ts`, `tokens.css`, a Tailwind preset, and
7-
a Style Dictionary JSON).
5+
normalized, typed intermediate representation (IR), maps that IR to a coherent
6+
**design-token set** (`tokens.ts`, `tokens.css`, a Tailwind preset, and a Style
7+
Dictionary JSON), and generates **typed React components** (`.tsx` + Storybook
8+
stories) from it.
89

910
> Part of the [InDesign-to-React pipeline](../../docs/indesign-to-react/README.md)
10-
> epic. Shipped so far: the IDML parser + IR, the PDF parser (same IR), and the
11-
> design-token mapper. The React component generator is next.
11+
> epic. Shipped so far: the IDML parser + IR, the PDF parser (same IR), the
12+
> design-token mapper, and the React component generator. A Claude Code agent +
13+
> skill that orchestrate the full pipeline are next.
1214
1315
## Designer hands you a PDF
1416

@@ -137,6 +139,35 @@ The mapper:
137139
`tokens.ts` is emitted self-contained (`as const`) so it type-checks anywhere;
138140
the token shape is also published as the zod `DesignTokensSchema`.
139141

142+
## React components
143+
144+
Generate typed React components (one per spread) plus Storybook stories, a
145+
barrel `index.ts`, and a generation report:
146+
147+
```ts
148+
import { parseIdmlFile } from "@aurelius/pipeline/indesign";
149+
import { mapDocumentToTokens } from "@aurelius/pipeline/tokens";
150+
import { generateComponents } from "@aurelius/pipeline/react";
151+
152+
const { document } = parseIdmlFile("brochure.idml");
153+
const tokens = mapDocumentToTokens(document);
154+
const { files, report, a11y } = generateComponents(document, tokens, {
155+
styleMode: "tailwind", // or "css-modules"
156+
framework: "react", // or "next" (uses next/image)
157+
});
158+
// files: [{ path: "Spread1.tsx", contents }, { path: "Spread1.stories.tsx", … }, "index.ts", report]
159+
```
160+
161+
- **Text frames** → semantic tags (`h1``h6` / `p` / `figcaption`) inferred from
162+
the paragraph-style role; **image frames**`<img>` or `next/image`.
163+
- **Explicit prop types** for all extracted content, so consumers override
164+
content while keeping layout; extracted text/`src` become the prop defaults.
165+
- **Tailwind** classes resolve via the mapper's preset; **CSS Modules** emit a
166+
co-located `.module.css` referencing the `tokens.css` custom properties.
167+
- **Deterministic** — the same IR always produces byte-identical output.
168+
- The report lists produced files, staged assets, unmapped IR nodes, and
169+
accessibility TODOs (e.g. images missing alt text).
170+
140171
## CLI
141172

142173
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
151182
# Map the IR to design tokens and write the four artifacts into a directory
152183
node dist/indesign/cli.js brochure.idml --emit-tokens ./src/tokens
153184

185+
# Generate React components (+ stories, barrel, report) from the IR
186+
node dist/indesign/cli.js brochure.idml --emit-components ./src/components --style tailwind
187+
154188
# Options
155-
# --json emit the IR as JSON on stdout
156-
# --pretty pretty-print JSON
157-
# --dpi <number> pixel conversion DPI (default 96)
158-
# --no-validate skip zod validation of the produced IR
159-
# --emit-tokens <dir> map the IR to tokens and write the four artifacts
160-
# --no-tailwind with --emit-tokens, skip tailwind.preset.ts
161-
# --font-map <file> JSON file of font fallback overrides
189+
# --json emit the IR as JSON on stdout
190+
# --pretty pretty-print JSON
191+
# --dpi <number> pixel conversion DPI (default 96)
192+
# --no-validate skip zod validation of the produced IR
193+
# --emit-tokens <dir> map the IR to tokens and write the four artifacts
194+
# --no-tailwind with --emit-tokens, skip tailwind.preset.ts
195+
# --emit-components <dir> generate .tsx components, stories, and a report
196+
# --style <mode> component styling: tailwind | css-modules
197+
# --framework <fw> target framework: react | next
198+
# --font-map <file> JSON file of font fallback overrides
162199
```
163200

164201
Example report:

packages/pipeline/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"./pdf": {
2525
"types": "./dist/pdf/index.d.ts",
2626
"import": "./dist/pdf/index.js"
27+
},
28+
"./react": {
29+
"types": "./dist/react/index.d.ts",
30+
"import": "./dist/react/index.js"
2731
}
2832
},
2933
"files": [
@@ -46,7 +50,11 @@
4650
"devDependencies": {
4751
"@types/node": "^20.17.0",
4852
"@types/pngjs": "^6.0.5",
53+
"@types/react": "^19.2.17",
54+
"@types/react-dom": "^19.2.3",
4955
"pdf-lib": "^1.17.1",
56+
"react": "^19.2.7",
57+
"react-dom": "^19.2.7",
5058
"typescript": "^5.7.2",
5159
"vitest": "^4.1.2"
5260
}

packages/pipeline/src/indesign/cli.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1010
import { join } from "node:path";
1111
import { pathToFileURL } from "node:url";
12+
import { generateComponents } from "../react/index.js";
13+
import type { Framework, GenerationResult, StyleMode } from "../react/model.js";
1214
import { parseSourceFile, type SourcePriority } from "../source.js";
1315
import { emitAll } from "../tokens/emit.js";
1416
import type { FontMapOverrides } from "../tokens/font-map.js";
@@ -39,6 +41,9 @@ Options:
3941
--emit-tokens <dir> Map the IR to design tokens and write tokens.ts,
4042
tokens.css, tailwind.preset.ts, design-tokens.json
4143
--no-tailwind With --emit-tokens, skip tailwind.preset.ts
44+
--emit-components <dir> Generate .tsx components, stories, and a report
45+
--style <mode> Component styling: tailwind | css-modules (default tailwind)
46+
--framework <fw> Target framework: react | next (default react)
4247
--font-map <file> JSON file of font fallback overrides
4348
-h, --help Show this help
4449
`;
@@ -52,6 +57,9 @@ interface ParsedArgs {
5257
tailwind: boolean;
5358
dpi?: number;
5459
emitTokens?: string;
60+
emitComponents?: string;
61+
styleMode?: StyleMode;
62+
framework?: Framework;
5563
fontMap?: string;
5664
sourcePriority?: SourcePriority;
5765
assets?: string;
@@ -100,6 +108,25 @@ function parseArgs(argv: string[]): ParsedArgs {
100108
else args.emitTokens = dir;
101109
break;
102110
}
111+
case "--emit-components": {
112+
const dir = argv[++i];
113+
if (!dir) args.error = "--emit-components requires a directory";
114+
else args.emitComponents = dir;
115+
break;
116+
}
117+
case "--style": {
118+
const value = argv[++i];
119+
if (value === "tailwind" || value === "css-modules") args.styleMode = value;
120+
else
121+
args.error = `Invalid --style: ${value ?? "(missing)"} (expected tailwind|css-modules)`;
122+
break;
123+
}
124+
case "--framework": {
125+
const value = argv[++i];
126+
if (value === "react" || value === "next") args.framework = value;
127+
else args.error = `Invalid --framework: ${value ?? "(missing)"} (expected react|next)`;
128+
break;
129+
}
103130
case "--font-map": {
104131
const file = argv[++i];
105132
if (!file) args.error = "--font-map requires a file path";
@@ -157,6 +184,10 @@ export async function runCli(argv: string[]): Promise<CliResult> {
157184
return { code: 1, stdout: "", stderr: `${errorLabel(err)}: ${toMessage(err)}\n` };
158185
}
159186

187+
if (args.emitComponents) {
188+
return emitComponentsFlow(document, args);
189+
}
190+
160191
if (args.emitTokens) {
161192
return emitTokens(document, args);
162193
}
@@ -210,6 +241,53 @@ function emitTokens(document: Document, args: ParsedArgs): CliResult {
210241
};
211242
}
212243

244+
function emitComponentsFlow(document: Document, args: ParsedArgs): CliResult {
245+
const tokens = mapDocumentToTokens(document, { ...(args.dpi ? { dpi: args.dpi } : {}) });
246+
const result = generateComponents(document, tokens, {
247+
...(args.styleMode ? { styleMode: args.styleMode } : {}),
248+
...(args.framework ? { framework: args.framework } : {}),
249+
});
250+
251+
try {
252+
mkdirSync(args.emitComponents!, { recursive: true });
253+
for (const file of result.files) {
254+
writeFileSync(join(args.emitComponents!, file.path), file.contents);
255+
}
256+
} catch (err) {
257+
return {
258+
code: 1,
259+
stdout: "",
260+
stderr: `Error: cannot write components to "${args.emitComponents}": ${toMessage(err)}\n`,
261+
};
262+
}
263+
264+
return {
265+
code: 0,
266+
stdout: formatComponentReport(result, args.emitComponents!, args.styleMode ?? "tailwind"),
267+
stderr: formatWarningSummary(document.warnings),
268+
};
269+
}
270+
271+
function formatComponentReport(result: GenerationResult, dir: string, styleMode: string): string {
272+
const lines: string[] = [];
273+
lines.push("InDesign → React Components");
274+
lines.push(` output: ${dir}`);
275+
lines.push(` style mode: ${styleMode}`);
276+
lines.push(
277+
` components: ${result.componentNames.length} (${result.componentNames.join(", ") || "none"})`,
278+
);
279+
lines.push(` files: ${result.files.length}`);
280+
lines.push(` assets: ${result.assets.length}`);
281+
lines.push(` unmapped: ${result.unmapped.length}`);
282+
lines.push(` a11y TODOs: ${result.a11y.length}`);
283+
lines.push(` report: ${join(dir, "indesign-pipeline-report.md")}`);
284+
if (result.a11y.length > 0) {
285+
lines.push("", "Accessibility TODOs");
286+
for (const item of result.a11y) lines.push(` ${item}`);
287+
}
288+
return `${lines.join("\n")}\n`;
289+
}
290+
213291
interface FrameCounts {
214292
text: number;
215293
image: number;

packages/pipeline/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
export * as indesign from "./indesign/index.js";
99
export * as pdf from "./pdf/index.js";
10+
export * as react from "./react/index.js";
1011
export * as tokens from "./tokens/index.js";
1112
export {
1213
parseSourceFile,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`deterministic JSX snapshots > image-heavy spread 1`] = `
4+
"// AUTO-GENERATED by @aurelius/pipeline. Do not edit by hand.
5+
6+
export type Spread1Props = {
7+
image?: string;
8+
imageAlt?: string;
9+
image2?: string;
10+
image2Alt?: string;
11+
body?: string;
12+
};
13+
14+
export function Spread1({
15+
image = "/indesign/spread1-image.png",
16+
imageAlt = "",
17+
image2 = "/indesign/spread1-image2.png",
18+
image2Alt = "",
19+
body = "Photography by the studio.",
20+
}: Spread1Props) {
21+
return (
22+
<section className="flex flex-col gap-6">
23+
<img src={image} alt={imageAlt} className="max-w-full h-auto" />
24+
<img src={image2} alt={image2Alt} className="max-w-full h-auto" />
25+
<p className="text-body font-helvetica">{body}</p>
26+
</section>
27+
);
28+
}
29+
30+
export default Spread1;
31+
"
32+
`;
33+
34+
exports[`deterministic JSX snapshots > text-heavy spread 1`] = `
35+
"// AUTO-GENERATED by @aurelius/pipeline. Do not edit by hand.
36+
37+
export type Spread1Props = {
38+
heading?: string;
39+
body?: string;
40+
caption?: string;
41+
};
42+
43+
export function Spread1({
44+
heading = "Annual Report 2026",
45+
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.",
46+
caption = "Figure 1 — a small caption set in eight point type.",
47+
}: Spread1Props) {
48+
return (
49+
<section className="flex flex-col gap-6">
50+
<h1 className="text-heading font-helvetica-bold">{heading}</h1>
51+
<p className="text-body font-helvetica">{body}</p>
52+
<figcaption className="text-caption font-helvetica">{caption}</figcaption>
53+
</section>
54+
);
55+
}
56+
57+
export default Spread1;
58+
"
59+
`;

0 commit comments

Comments
 (0)