From 549320e8c04b46292d0e784890fcbab2589175df Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Sat, 30 May 2026 03:52:31 -0400 Subject: [PATCH] feat(pipeline): generate FSE themes from the InDesign IR (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the output generator — stage 4 of the InDesign-to-WordPress pipeline. It takes a parsed IR (#62/#63) plus the mapped design tokens (#64) and emits a complete, installable Flavian-compatible FSE theme: - one block pattern per spread under an "InDesign Imports" category - frames → core blocks (heading/paragraph/group, image/cover, columns) referencing token slugs, never inline values - header/footer parts derived from master-spread chrome (web defaults otherwise), index/page/404 templates, the merged theme.json - staged-asset plan + bin/import-media.sh (+ optional bin/seed-content.sh) - indesign-pipeline-report.md enumerating files, unmapped nodes, follow-ups generateTheme() is pure and deterministic — same IR yields byte-identical files, so reruns never churn. Exposed via the new flavian-generate-theme CLI (composes with the parser CLIs or parses .idml/.pdf directly). Tests: schema-valid theme.json, one pattern per spread, token references resolve, deterministic reruns, and markup snapshots for a text-heavy and an image-heavy spread. Also registers the existing flavian-map-tokens bin. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/pipeline/indesign-output-generator.md | 125 +++++++++ packages/pipeline/README.md | 75 +++++- packages/pipeline/bin/generate-theme.mjs | 191 ++++++++++++++ packages/pipeline/package.json | 4 +- .../pipeline/src/indesign/generate/blocks.js | 173 +++++++++++++ .../pipeline/src/indesign/generate/escape.js | 44 ++++ .../pipeline/src/indesign/generate/index.js | 142 ++++++++++ .../pipeline/src/indesign/generate/layout.js | 116 +++++++++ .../pipeline/src/indesign/generate/media.js | 100 +++++++ .../pipeline/src/indesign/generate/parts.js | 78 ++++++ .../src/indesign/generate/patterns.js | 105 ++++++++ .../pipeline/src/indesign/generate/report.js | 80 ++++++ .../pipeline/src/indesign/generate/slugs.js | 48 ++++ .../src/indesign/generate/templates.js | 52 ++++ .../src/indesign/generate/theme-files.js | 103 ++++++++ packages/pipeline/src/indesign/index.js | 1 + .../pipeline/src/indesign/map/typography.js | 17 ++ .../__snapshots__/pattern-image-spread.php | 27 ++ .../__snapshots__/pattern-text-spread.php | 35 +++ .../pipeline/tests/indesign/generate.test.mjs | 245 ++++++++++++++++++ 20 files changed, 1747 insertions(+), 14 deletions(-) create mode 100644 docs/pipeline/indesign-output-generator.md create mode 100644 packages/pipeline/bin/generate-theme.mjs create mode 100644 packages/pipeline/src/indesign/generate/blocks.js create mode 100644 packages/pipeline/src/indesign/generate/escape.js create mode 100644 packages/pipeline/src/indesign/generate/index.js create mode 100644 packages/pipeline/src/indesign/generate/layout.js create mode 100644 packages/pipeline/src/indesign/generate/media.js create mode 100644 packages/pipeline/src/indesign/generate/parts.js create mode 100644 packages/pipeline/src/indesign/generate/patterns.js create mode 100644 packages/pipeline/src/indesign/generate/report.js create mode 100644 packages/pipeline/src/indesign/generate/slugs.js create mode 100644 packages/pipeline/src/indesign/generate/templates.js create mode 100644 packages/pipeline/src/indesign/generate/theme-files.js create mode 100644 packages/pipeline/tests/indesign/__snapshots__/pattern-image-spread.php create mode 100644 packages/pipeline/tests/indesign/__snapshots__/pattern-text-spread.php create mode 100644 packages/pipeline/tests/indesign/generate.test.mjs diff --git a/docs/pipeline/indesign-output-generator.md b/docs/pipeline/indesign-output-generator.md new file mode 100644 index 0000000..bb1df9c --- /dev/null +++ b/docs/pipeline/indesign-output-generator.md @@ -0,0 +1,125 @@ +# InDesign output generator: guide + +The output generator is stage 4 — the final stage — of the +InDesign-to-WordPress pipeline. It takes the +[intermediate representation](../../packages/pipeline/src/indesign/ir.js) +produced by the [IDML parser](../../packages/pipeline/README.md) (#62) or the +[PDF fallback parser](indesign-pdf-fidelity.md) (#63), runs it through the +[token mapper](indesign-token-mapper.md) (#64), and emits a complete, +installable Flavian-compatible FSE theme directory. + +It turns the design into a finished WordPress product: + +| Artifact | What it is | +| --- | --- | +| `theme.json` | The token mapper's merged base + partial — one schema-valid file. | +| `patterns/spread-N.php` | One FSE block pattern per spread, filed under the **InDesign Imports** category. | +| `templates/` | `index.html` (stitches the spread patterns between header/footer), plus `page.html` and `404.html`. | +| `parts/` | `header.html` and `footer.html`, derived from master-spread chrome where present. | +| `style.css`, `functions.php` | Theme header + bootstrap (registers the pattern category, enqueues styles). | +| `bin/import-media.sh` | WP-CLI script that imports staged assets into the media library. | +| `bin/seed-content.sh` | (optional, `--seed-content`) one draft page per spread, populated with its pattern. | +| `indesign-pipeline-report.md` | Produced files, unmapped IR nodes, and manual follow-ups. | + +## Usage + +```js +import { parseIdml, generateTheme } from '@flavian/pipeline'; + +const ir = await parseIdml('./brochure.idml'); +const { files, assets, themeJson, report } = generateTheme(ir, { + // slug, name, // theme slug / display name (default: from the doc name) + // seedContent: true, // also emit bin/seed-content.sh + // base, fontMap, namespace, tolerance, tolerancePx, gridPx, fluid, // token-mapper options + // tokens, // a precomputed mapTokens() result (skips re-mapping) +}); + +// `files` is [{ path, contents, mode? }] — pure data; the CLI does the fs writes. +for (const f of files) console.log(f.path); +console.log(report.markdown); +``` + +`generateTheme` is **pure and deterministic**: no filesystem, clock, or +randomness. The same IR yields byte-identical files every run, so reruns never +produce diff churn in unrelated artifacts. + +From the command line (composes with the parser CLIs, or parses directly): + +```bash +# Compose with a parser CLI… +node packages/pipeline/bin/parse-idml.mjs brochure.idml \ + | node packages/pipeline/bin/generate-theme.mjs - --out-dir themes/brochure + +# …or parse + generate in one step (accepts .idml or .pdf directly). +node packages/pipeline/bin/generate-theme.mjs brochure.idml \ + --out-dir themes/brochure --asset-dir ./extracted-images --seed-content +``` + +### Options + +| Option | CLI flag | Default | Effect | +| --- | --- | --- | --- | +| `slug` | `--slug ` | slug of the document name | Theme directory slug. | +| `name` | `--name ` | the document name | Theme display name. | +| — | `--out-dir ` | _(required)_ | Where the theme directory is written. | +| — | `--asset-dir ` | — | Source of image bytes to copy into `assets/` (matched by basename). | +| `seedContent` | `--seed-content` | off | Emit `bin/seed-content.sh`. | +| `tokens` | — | computed | A precomputed `mapTokens()` result. | + +All [token-mapper options](indesign-token-mapper.md#options) (`--base`, +`--font-map`, `--namespace`, `--grid`, `--tolerance`, `--type-tolerance`, +`--fluid`, `--dpi`) pass straight through. + +## How frames become blocks + +Each spread is laid out top-to-bottom in reading order, then mapped to core +blocks: + +- **Text frames** → `core/heading` / `core/paragraph`, grouped in a + `core/group`. A run's paragraph style decides the role: `Heading N` → + `core/heading` at level N; `Body`/`Caption`/etc. → `core/paragraph`. The + font-size, font-family, and text-color come from the **design tokens** + (preset slugs), never inline values, wherever the mapper produced one. +- **Image frames** → `core/image`, or `core/cover` when one or more text frames + sit on top of the image (a background with overlaid copy). Image URLs resolve + through `get_theme_file_uri()` so the theme stays relocatable (the + pattern-first rule — no broken `src=""` in markup). +- **Side-by-side frames** (overlapping vertical bands) → `core/columns`, one + `core/column` per frame, left to right. + +### Template parts from masters + +A master spread's repeating chrome is split by vertical position: text in the +top band becomes `parts/header.html`, text in the bottom band becomes +`parts/footer.html` (running heads / page-number chrome → a web footer line). +With no usable master, sensible FSE defaults (site title + navigation; a +copyright line) are emitted instead, and the report flags it. + +### Assets + +The generator works from the IR, which carries image *references*, not bytes. +So every image frame gets a deterministic staged filename +(`assets/spread-N-image-K.ext`), and `bin/import-media.sh` imports whatever +lands in `assets/`. Pass `--asset-dir` to have the CLI copy the real bytes in +(matched by basename); otherwise the report lists the expected filenames as a +follow-up. + +## Acceptance criteria + +| Criterion (#65) | How it's met | +| --- | --- | +| End-to-end on a fixture `.idml` produces a theme that loads in the Site Editor without PHP errors. | `generate-theme.mjs` writes a full theme dir; PHP files are a standard header + bootstrap and block markup with only `esc_url( get_theme_file_uri() )` interpolation. | +| ≥1 pattern per spread appears under an "InDesign Imports" category. | One `patterns/spread-N.php` per spread, each `Categories: indesign-imports`; `functions.php` registers the category with the label **InDesign Imports**. | +| `theme.json` round-trips through validation. | It is the token mapper's `merged` output, already validated against the WordPress schema (ajv + zod). | +| Report enumerates produced files and unmapped IR nodes. | `indesign-pipeline-report.md` — see the **Produced artifacts** and **Unmapped IR nodes** sections. | +| Snapshot tests cover two fixture spreads (text-heavy, image-heavy). | `tests/indesign/generate.test.mjs` snapshots `patterns/spread-1.php` (text) and `spread-2.php` (image), stored in `tests/indesign/__snapshots__/`. | +| Patterns are deterministic given the same IR. | `generateTheme` is pure; a determinism test asserts two runs are byte-identical. | + +## Testing + +```bash +pnpm --filter @flavian/pipeline test + +# Re-record the markup snapshots after an intentional change: +UPDATE_SNAPSHOTS=1 node --test packages/pipeline/tests/indesign/generate.test.mjs +``` diff --git a/packages/pipeline/README.md b/packages/pipeline/README.md index 4476ead..d96899e 100644 --- a/packages/pipeline/README.md +++ b/packages/pipeline/README.md @@ -4,7 +4,7 @@ Conversion pipeline for InDesign (and future) sources into WordPress FSE themes. ## Status -This package ships the **IDML parser** (sub-issue #62), the **PDF fallback parser** (sub-issue #63), and the **style + token mapper** (sub-issue #64) of the InDesign-to-WordPress epic. The two parsers emit the same intermediate representation; the mapper turns that IR into WordPress design tokens (a `theme.json` partial, DTCG `design-tokens.json`, and a report). The output generator (#65) will land as a separate PR. +This package ships the **IDML parser** (sub-issue #62), the **PDF fallback parser** (sub-issue #63), the **style + token mapper** (sub-issue #64), and the **output generator** (sub-issue #65) of the InDesign-to-WordPress epic. The two parsers emit the same intermediate representation; the mapper turns that IR into WordPress design tokens (a `theme.json` partial, DTCG `design-tokens.json`, and a report); the output generator turns the IR plus those tokens into a complete, installable FSE theme (patterns, templates, parts, merged `theme.json`, asset scripts, and a generation report). IDML is the primary path (full access to stories, frames, styles, swatches, masters). PDF is a lossy fallback for when only the exported PDF is available, or as a verification source against IDML output — see [`docs/pipeline/indesign-pdf-fidelity.md`](../../docs/pipeline/indesign-pdf-fidelity.md). @@ -15,7 +15,8 @@ packages/pipeline/ ├── bin/ │ ├── parse-idml.mjs CLI: IDML → validated IR JSON on stdout │ ├── parse-pdf.mjs CLI: PDF → reconstructed IR JSON on stdout -│ └── map-tokens.mjs CLI: IR (or .idml/.pdf) → theme.json + design tokens +│ ├── map-tokens.mjs CLI: IR (or .idml/.pdf) → theme.json + design tokens +│ └── generate-theme.mjs CLI: IR (or .idml/.pdf) → complete FSE theme directory ├── config/ │ └── font-map.json InDesign family → web/Google font fallback table └── src/ @@ -41,17 +42,29 @@ packages/pipeline/ │ ├── color.js Re-exports the shared color helpers for the PDF path │ ├── png.js Decoded pixels → PNG via node:zlib (pure) │ └── assets.js Write extracted images to the asset cache - └── map/ IR → WordPress design tokens (token mapper) - ├── index.js mapTokens orchestrator → { partial, designTokens, merged, report } - ├── colors.js Swatches → color palette (convert, dedupe, reuse base) - ├── typography.js Paragraph styles → font-size scale + element/block presets - ├── spacing.js Geometry + paragraph spacing → quantized spacing scale - ├── fonts.js Fonts → font families via config/font-map.json - ├── theme-json.js Assemble partial, deep-merge with base, validate - ├── design-tokens.js DTCG / Style Dictionary emitter - ├── report.js Warnings + provenance aggregation - ├── slug.js Namespaced slug helpers - └── schema/ Vendored WP theme.json schema + zod subset + ├── map/ IR → WordPress design tokens (token mapper) + │ ├── index.js mapTokens orchestrator → { partial, designTokens, merged, report } + │ ├── colors.js Swatches → color palette (convert, dedupe, reuse base) + │ ├── typography.js Paragraph styles → font-size scale + element/block presets + │ ├── spacing.js Geometry + paragraph spacing → quantized spacing scale + │ ├── fonts.js Fonts → font families via config/font-map.json + │ ├── theme-json.js Assemble partial, deep-merge with base, validate + │ ├── design-tokens.js DTCG / Style Dictionary emitter + │ ├── report.js Warnings + provenance aggregation + │ ├── slug.js Namespaced slug helpers + │ └── schema/ Vendored WP theme.json schema + zod subset + └── generate/ IR + tokens → installable FSE theme (output generator) + ├── index.js generateTheme orchestrator → { files, assets, themeJson, report } + ├── layout.js Spread frames → reading-order rows + column/cover detection + ├── blocks.js Frames → core block markup (heading/paragraph/image/cover) + ├── patterns.js Spread → pattern PHP file (one per spread) + ├── parts.js Master spreads → header/footer template parts + ├── templates.js index.html / page.html / 404.html + ├── theme-files.js style.css + functions.php + ├── media.js Asset staging plan + import-media.sh / seed-content.sh + ├── report.js indesign-pipeline-report.md (markdown) + ├── escape.js HTML/PHP escaping + get_theme_file_uri() helpers + └── slugs.js Deterministic theme/pattern/asset naming ``` ## Quick start @@ -130,6 +143,40 @@ node packages/pipeline/bin/map-tokens.mjs brochure.idml --out-dir ./tokens See [`docs/pipeline/indesign-token-mapper.md`](../../docs/pipeline/indesign-token-mapper.md) for the conversion math (CMYK/LAB → sRGB), the font-map format, the warning codes, merge semantics, and how the acceptance criteria are met. +## Output generator + +Turn a parsed IR (plus the mapped tokens) into a complete, installable FSE +theme: one block pattern per spread under an **InDesign Imports** category, +templates and header/footer parts, the merged `theme.json`, asset import +scripts, and a generation report. `generateTheme` is pure and deterministic — +the same IR yields byte-identical files, so reruns never churn. + +```js +import { parseIdml, generateTheme } from '@flavian/pipeline'; + +const ir = await parseIdml('./brochure.idml'); +const { files, assets, report } = generateTheme(ir, { + // slug, name, // theme slug / display name (default: from the doc name) + // seedContent: true, // also emit bin/seed-content.sh + // tokens, // a precomputed mapTokens() result (skips re-mapping) +}); + +for (const f of files) console.log(f.path); // [{ path, contents, mode? }] +``` + +From the command line (composes with the parser CLIs, or parses directly): + +```bash +node packages/pipeline/bin/parse-idml.mjs brochure.idml \ + | node packages/pipeline/bin/generate-theme.mjs - --out-dir themes/brochure + +# or parse + generate in one step, staging image bytes from a source dir +node packages/pipeline/bin/generate-theme.mjs brochure.idml \ + --out-dir themes/brochure --asset-dir ./images --seed-content +``` + +See [`docs/pipeline/indesign-output-generator.md`](../../docs/pipeline/indesign-output-generator.md) for the frame → block mapping, master → parts derivation, asset staging, and how the acceptance criteria are met. + ## IR shape The intermediate representation is described in [`src/indesign/ir.js`](src/indesign/ir.js). At the top level: @@ -170,6 +217,8 @@ Tests build minimal fixtures programmatically — no binary fixtures in git. `te The PDF heuristics (clustering, classification, color, PNG encoding) are split into pure modules under `src/indesign/pdf/` and unit-tested without a PDF engine; only `extract.js` and the orchestrator touch pdfjs. +The output generator's markup is covered by snapshot tests in `tests/indesign/generate.test.mjs`; the snapshots live in `tests/indesign/__snapshots__/`. Re-record them after an intentional change with `UPDATE_SNAPSHOTS=1 node --test tests/indesign/generate.test.mjs`. + ## Adding a new input format When sub-issues for Figma / Canva migrations land, mirror the InDesign layout: a sibling directory under `src/`, its own IR schema, and a `parsers/` subdir for any input-format-specific decoders. The top-level `src/index.js` re-exports each surface so consumers `import { parseIdml, parseFigma } from '@flavian/pipeline'`. diff --git a/packages/pipeline/bin/generate-theme.mjs b/packages/pipeline/bin/generate-theme.mjs new file mode 100644 index 0000000..a51d8ed --- /dev/null +++ b/packages/pipeline/bin/generate-theme.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// CLI: generate a WordPress FSE theme from an InDesign IR (or .idml/.pdf). +// +// flavian-generate-theme --out-dir [options] +// +// Reads a validated IR JSON (from parse-idml / parse-pdf) on a path or stdin, or +// parses an .idml/.pdf directly, then writes a complete theme directory: theme.json, +// patterns (one per spread), templates, parts, style.css, functions.php, an asset +// import script, and a generation report. +// +// Options: +// --out-dir Theme output directory (required). Created if missing. +// --slug Theme slug (default: derived from the document name). +// --name Theme display name (default: derived from the document name). +// --asset-dir Source directory of image bytes to stage into assets/. +// --seed-content Also emit bin/seed-content.sh. +// --base Base theme.json to merge against (default: flavian-shop). +// --font-map Font map JSON (default: bundled config/font-map.json). +// --namespace Derived-token slug prefix (default: id). +// --grid Spacing quantization grid (default: 4). +// --tolerance Color dedupe/reuse squared-distance tolerance. +// --type-tolerance Typography size clustering tolerance (default: 1). +// --dpi DPI when parsing .idml/.pdf directly (default: 96). +// --fluid Emit fluid clamp() font sizes. +// --quiet Suppress the stderr summary. +// -h, --help Show this help. + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { parseIdml } from '../src/indesign/parse-idml.js'; +import { parsePdf } from '../src/indesign/parse-pdf.js'; +import { generateTheme } from '../src/indesign/generate/index.js'; + +const args = process.argv.slice(2); +const opts = { seedContent: false, quiet: false }; +let inputPath; + +let i = 0; +function wantsValue(flag) { + const next = args[i + 1]; + if (next === undefined || next.startsWith('-')) { + process.stderr.write(`${flag} requires a value\n`); + process.exit(2); + } + return next; +} + +for (; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case '--out-dir': opts.outDir = wantsValue(arg); i += 1; break; + case '--slug': opts.slug = wantsValue(arg); i += 1; break; + case '--name': opts.name = wantsValue(arg); i += 1; break; + case '--asset-dir': opts.assetDir = wantsValue(arg); i += 1; break; + case '--seed-content': opts.seedContent = true; break; + case '--base': opts.base = wantsValue(arg); i += 1; break; + case '--font-map': opts.fontMap = wantsValue(arg); i += 1; break; + case '--namespace': opts.namespace = wantsValue(arg); i += 1; break; + case '--grid': opts.gridPx = Number(wantsValue(arg)); i += 1; break; + case '--tolerance': opts.tolerance = Number(wantsValue(arg)); i += 1; break; + case '--type-tolerance': opts.tolerancePx = Number(wantsValue(arg)); i += 1; break; + case '--dpi': opts.dpi = Number(wantsValue(arg)); i += 1; break; + case '--fluid': opts.fluid = true; break; + case '--quiet': opts.quiet = true; break; + case '-h': case '--help': printUsage(); process.exit(0); break; + default: + if (!inputPath && !arg.startsWith('-')) { + inputPath = arg; + } else { + process.stderr.write(`Unknown argument: ${arg}\n`); + printUsage(); + process.exit(2); + } + } +} + +if (!opts.outDir) { + process.stderr.write('--out-dir is required\n'); + printUsage(); + process.exit(2); +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +async function loadIr() { + const dpiOpt = opts.dpi !== undefined ? { dpi: opts.dpi } : undefined; + if (inputPath && /\.idml$/i.test(inputPath)) return parseIdml(inputPath, dpiOpt); + if (inputPath && /\.pdf$/i.test(inputPath)) return parsePdf(inputPath, dpiOpt); + const text = inputPath && inputPath !== '-' ? await fs.readFile(inputPath, 'utf8') : await readStdin(); + return JSON.parse(text); +} + +/** Copy bytes for a planned asset from the source dir, matched by basename. */ +async function stageAsset(asset, assetDir, outDir) { + const candidates = []; + if (asset.href) candidates.push(path.basename(asset.href.replace(/[?#].*$/, ''))); + candidates.push(asset.name); + for (const candidate of candidates) { + const src = path.join(assetDir, candidate); + try { + await fs.access(src); + await fs.mkdir(path.join(outDir, 'assets'), { recursive: true }); + await fs.copyFile(src, path.join(outDir, asset.relPath)); + return true; + } catch { + // try next candidate + } + } + return false; +} + +try { + const ir = await loadIr(); + const result = generateTheme(ir, { + slug: opts.slug, + name: opts.name, + seedContent: opts.seedContent, + base: opts.base, + fontMap: opts.fontMap, + namespace: opts.namespace, + tolerance: opts.tolerance, + tolerancePx: opts.tolerancePx, + gridPx: opts.gridPx, + fluid: opts.fluid, + }); + + const outDir = opts.outDir; + await fs.mkdir(outDir, { recursive: true }); + + // Write every text artifact, creating subdirectories as needed. + for (const file of result.files) { + const dest = path.join(outDir, file.path); + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.writeFile(dest, file.contents); + if (file.mode) await fs.chmod(dest, Number.parseInt(file.mode, 8)).catch(() => {}); + } + + // Stage image bytes when a source directory was supplied. + let staged = 0; + if (opts.assetDir && result.assets.length) { + for (const asset of result.assets) { + if (await stageAsset(asset, opts.assetDir, outDir)) staged += 1; + } + } + + if (!opts.quiet) { + const lines = [ + `theme: ${result.name} (${result.slug}) → ${outDir}`, + `files: ${result.files.length} patterns: ${result.report.data.patterns.length} assets: ${result.assets.length} (staged ${staged})`, + `theme.json valid: ${result.report.data.valid}`, + ]; + if (result.report.data.unmapped.length) lines.push(`unmapped IR nodes: ${result.report.data.unmapped.length}`); + process.stderr.write(`${lines.join('\n')}\n`); + } + + process.exit(result.report.data.valid ? 0 : 1); +} catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); +} + +function printUsage() { + process.stderr.write( + [ + 'Usage: flavian-generate-theme --out-dir [options]', + '', + 'Options:', + ' --out-dir Theme output directory (required)', + ' --slug Theme slug (default: from document name)', + ' --name Theme display name (default: from document name)', + ' --asset-dir Source directory of image bytes to stage into assets/', + ' --seed-content Also emit bin/seed-content.sh', + ' --base Base theme.json to merge against', + ' --font-map Font map JSON (default: bundled config/font-map.json)', + ' --namespace Derived-token slug prefix (default: id)', + ' --grid Spacing quantization grid (default: 4)', + ' --tolerance Color dedupe/reuse squared-distance tolerance', + ' --type-tolerance Typography size clustering tolerance (default: 1)', + ' --dpi DPI when parsing .idml/.pdf directly (default: 96)', + ' --fluid Emit fluid clamp() font sizes', + ' --quiet Suppress the stderr summary', + ' -h, --help Show this help', + '', + ].join('\n'), + ); +} diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json index 9a1b92e..268ce8d 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -10,7 +10,9 @@ }, "bin": { "flavian-parse-idml": "./bin/parse-idml.mjs", - "flavian-parse-pdf": "./bin/parse-pdf.mjs" + "flavian-parse-pdf": "./bin/parse-pdf.mjs", + "flavian-map-tokens": "./bin/map-tokens.mjs", + "flavian-generate-theme": "./bin/generate-theme.mjs" }, "scripts": { "test": "node --test \"tests/**/*.test.mjs\"" diff --git a/packages/pipeline/src/indesign/generate/blocks.js b/packages/pipeline/src/indesign/generate/blocks.js new file mode 100644 index 0000000..3645c97 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/blocks.js @@ -0,0 +1,173 @@ +// Render IR frames into WordPress block markup. Text frames become +// heading/paragraph blocks grouped together; image frames become core/image, +// or core/cover when text is overlaid on them. Design tokens (font-size, color +// slugs) are referenced by slug — never inline values — wherever the mapper +// produced one. + +import { classifyStyleRole } from '../map/typography.js'; +import { escapeHtml, inlineWithBreaks, themeFileUri } from './escape.js'; + +/** Prefix every non-empty line of a block with `pad` (a tab by default). */ +export function indent(block, pad = '\t') { + return String(block) + .split('\n') + .map((line) => (line ? pad + line : line)) + .join('\n'); +} + +function classList(...classes) { + return classes.filter(Boolean).join(' '); +} + +function attrComment(name, attrs) { + const keys = Object.keys(attrs); + const json = keys.length ? ` ${JSON.stringify(attrs)}` : ''; + return ``; +} + +function headingBlock({ level, fontSize, textColor }, html) { + const attrs = {}; + if (level !== 2) attrs.level = level; + if (textColor) attrs.textColor = textColor; + if (fontSize) attrs.fontSize = fontSize; + const cls = classList( + 'wp-block-heading', + textColor && `has-${textColor}-color`, + textColor && 'has-text-color', + fontSize && `has-${fontSize}-font-size`, + ); + return [ + attrComment('heading', attrs), + `${html}`, + '', + ].join('\n'); +} + +function paragraphBlock({ fontSize, textColor, className }, html) { + const attrs = {}; + if (className) attrs.className = className; + if (textColor) attrs.textColor = textColor; + if (fontSize) attrs.fontSize = fontSize; + const cls = classList( + className, + textColor && `has-${textColor}-color`, + textColor && 'has-text-color', + fontSize && `has-${fontSize}-font-size`, + ); + const openTag = cls ? `

` : '

'; + return [attrComment('paragraph', attrs), `${openTag}${html}

`, ''].join('\n'); +} + +/** Resolve a paragraph style ref to its role, font-size slug, and color slug. */ +function resolveStyle(ref, ctx) { + const style = ref ? ctx.stylesById.get(ref) : undefined; + const role = classifyStyleRole(style?.name); + return { + role: role.role, + level: role.level, + fontSize: style ? ctx.styleToSlug[style.id] : undefined, + textColor: style?.fillColorRef ? ctx.swatchToSlug[style.fillColorRef] : undefined, + }; +} + +/** Split a run's text into non-empty logical lines. */ +function lines(text) { + return String(text ?? '') + .replace(/\n+$/, '') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); +} + +/** + * Turn a story's runs into a flat list of block-markup strings. Headings and + * captions collapse hard breaks into a single block; body/generic text emits + * one paragraph per line. + */ +function renderRuns(runs, ctx) { + const blocks = []; + for (const run of runs ?? []) { + const ls = lines(run.text); + if (!ls.length) continue; + const s = resolveStyle(run.paragraphStyleRef, ctx); + if (s.role === 'heading') { + blocks.push(headingBlock({ level: s.level, fontSize: s.fontSize, textColor: s.textColor }, inlineWithBreaks(run.text.replace(/\n+$/, '')))); + } else if (s.role === 'caption') { + blocks.push(paragraphBlock({ fontSize: s.fontSize, textColor: s.textColor, className: 'is-style-caption' }, inlineWithBreaks(run.text.replace(/\n+$/, '')))); + } else { + for (const line of ls) blocks.push(paragraphBlock({ fontSize: s.fontSize, textColor: s.textColor }, escapeHtml(line))); + } + } + return blocks; +} + +/** + * Inner blocks for a text frame (no wrapping container). Returns [] when the + * frame resolves to no renderable text; callers treat that as unmapped. + */ +export function renderTextFrameInner(frame, ctx) { + const story = frame.storyRef ? ctx.storiesById.get(frame.storyRef) : undefined; + if (!story) return []; + return renderRuns(story.runs, ctx); +} + +function imageBlock(assetPath, alt) { + const src = assetPath ? themeFileUri(assetPath) : ''; + return [ + attrComment('image', { sizeSlug: 'large' }), + `
${escapeHtml(alt)}
`, + '', + ].join('\n'); +} + +const COVER_DIM = 50; + +function coverBlock(assetPath, minHeight, innerBlocks) { + const attrs = { dimRatio: COVER_DIM, minHeight, minHeightUnit: 'px', isDark: false, layout: { type: 'constrained' } }; + const src = assetPath ? themeFileUri(assetPath) : ''; + const inner = innerBlocks.length ? indent(innerBlocks.join('\n\n')) : ''; + // Markup mirrors core/cover's save() output (dim class for the ratio, inline + // min-height) so the Site Editor accepts it without a block-recovery prompt. + return [ + attrComment('cover', attrs), + `
`, + ``, + ``, + '
', + ...(inner ? [inner] : []), + '
', + '
', + '', + ].join('\n'); +} + +/** + * Render a single layout item (frame plus any overlaid text) to block markup. + * Pushes to ctx.unmapped for frames that yield nothing renderable. Returns a + * markup string, or null when the frame produced no block. + * + * @param {{ frame: object, coverChildren?: Array }} item + * @param {object} ctx + * @returns {string|null} + */ +export function renderFrame(item, ctx) { + const { frame, coverChildren = [] } = item; + + if (frame.kind === 'image') { + const assetPath = ctx.assetPathById.get(frame.id); + if (!assetPath) ctx.unmapped.push({ id: frame.id, kind: 'image', reason: 'image frame has no linked asset' }); + if (coverChildren.length) { + const inner = coverChildren.flatMap((child) => renderTextFrameInner(child, ctx)); + const minHeight = Math.max(120, Math.round(frame.bounds.height)); + return coverBlock(assetPath, minHeight, inner); + } + return imageBlock(assetPath, ''); + } + + const inner = renderTextFrameInner(frame, ctx); + if (!inner.length) { + ctx.unmapped.push({ id: frame.id, kind: 'text', reason: frame.storyRef ? 'story has no renderable text' : 'text frame has no story' }); + return null; + } + return inner.join('\n\n'); +} diff --git a/packages/pipeline/src/indesign/generate/escape.js b/packages/pipeline/src/indesign/generate/escape.js new file mode 100644 index 0000000..61b16e9 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/escape.js @@ -0,0 +1,44 @@ +// Escaping helpers for the artifacts the generator emits. +// +// Pattern files are PHP, but the design *content* we drop into block markup is +// static — we escape it once at generation time rather than echoing it through +// PHP. Image URLs are the exception: those go through get_theme_file_uri() so a +// relocated theme still resolves its assets (the pattern-first rule). + +/** Escape text for an HTML text node. */ +export function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** Escape text for a double-quoted HTML attribute. */ +export function escapeAttr(value) { + return escapeHtml(value).replace(/"/g, '"'); +} + +/** Escape a value for a single-quoted PHP string literal. */ +export function phpSingleQuoted(value) { + return String(value ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +/** + * A `get_theme_file_uri()` PHP expression for an asset path relative to the + * theme root. Keeps images resolvable wherever the theme is installed. + */ +export function themeFileUri(relPath) { + return ``; +} + +/** + * Collapse a block of imported prose into HTML, preserving hard breaks. A run's + * text may carry embedded newlines (from
or paragraph terminators); inner + * newlines become
, the value is otherwise escaped. + */ +export function inlineWithBreaks(text) { + return String(text ?? '') + .split('\n') + .map((line) => escapeHtml(line)) + .join('
'); +} diff --git a/packages/pipeline/src/indesign/generate/index.js b/packages/pipeline/src/indesign/generate/index.js new file mode 100644 index 0000000..6ed9940 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/index.js @@ -0,0 +1,142 @@ +// Output generator: a validated InDesign IR (+ mapped design tokens) → a +// complete, installable Flavian-compatible FSE theme. +// +// generateTheme(ir, options) → { slug, name, files, assets, themeJson, tokens, report } +// +// files [{ path, contents, mode? }] — every text artifact, theme-relative. +// assets planned image assets (bytes staged by the caller / CLI). +// report { markdown, data } — the generation report. +// +// Pure and deterministic: no fs, no clock, no randomness. The same IR yields +// byte-identical files every run, so reruns never churn unrelated artifacts. + +import { mapTokens } from '../map/index.js'; +import { themeSlug, themeName, themePackage, spreadPatternSlug } from './slugs.js'; +import { planAssets, buildImportScript, buildSeedScript } from './media.js'; +import { buildPatternFile } from './patterns.js'; +import { buildParts } from './parts.js'; +import { buildIndexTemplate, buildPageTemplate, buildNotFoundTemplate } from './templates.js'; +import { buildStyleCss, buildFunctionsPhp } from './theme-files.js'; +import { buildGenerationReport } from './report.js'; + +const REPORT_FILE = 'indesign-pipeline-report.md'; + +function indexBy(items, key) { + const map = new Map(); + for (const item of items ?? []) map.set(item[key], item); + return map; +} + +/** + * @typedef {Object} GenerateThemeOptions + * @property {string} [slug] Theme directory slug (default: from doc name). + * @property {string} [name] Human theme name (default: from doc name). + * @property {string} [version] style.css version (default '0.1.0'). + * @property {object} [tokens] Precomputed mapTokens() result (skips re-mapping). + * @property {boolean} [seedContent] Also emit bin/seed-content.sh. + * @property {object} [base] Token mapper: base theme (object/path). + * @property {object} [fontMap] Token mapper: font map (object/path). + * @property {string} [namespace] Token mapper: derived-token prefix. + * @property {number} [tolerance] Token mapper: color tolerance. + * @property {number} [tolerancePx] Token mapper: typography tolerance. + * @property {number} [gridPx] Token mapper: spacing grid. + * @property {boolean} [fluid] Token mapper: fluid font sizes. + * + * @param {import('../ir.js').DocumentIR} ir + * @param {GenerateThemeOptions} [options] + */ +export function generateTheme(ir, options = {}) { + const tokens = options.tokens ?? mapTokens(ir, { + base: options.base, + fontMap: options.fontMap, + namespace: options.namespace, + tolerance: options.tolerance, + tolerancePx: options.tolerancePx, + gridPx: options.gridPx, + fluid: options.fluid, + }); + + const slug = themeSlug(ir, options.slug); + const name = themeName(ir, options.name); + const pkg = themePackage(slug); + const version = options.version ?? '0.1.0'; + + const { assets, assetPathById } = planAssets(ir); + + const ctx = { + storiesById: indexBy(ir.stories, 'id'), + stylesById: indexBy(ir.styles, 'id'), + styleToSlug: tokens.report?.provenance?.styleToSlug ?? {}, + swatchToSlug: tokens.report?.provenance?.swatchToSlug ?? {}, + fontToSlug: tokens.report?.provenance?.fontToSlug ?? {}, + assetPathById, + unmapped: [], + }; + + // One pattern per spread. + const spreads = ir.spreads ?? []; + const patternMeta = []; + const patternFiles = []; + spreads.forEach((spread, index) => { + const patternSlug = spreadPatternSlug(slug, index); + const title = `${name} — Spread ${index + 1}`; + patternMeta.push({ title, slug: patternSlug }); + patternFiles.push({ + path: `patterns/spread-${index + 1}.php`, + contents: buildPatternFile({ spread, index, themePackage: pkg, title, patternSlug, ctx }), + }); + }); + + const parts = buildParts(ir, ctx); + const description = `FSE theme generated from an InDesign document by @flavian/pipeline.`; + + /** @type {Array<{ path: string, contents: string, mode?: string }>} */ + const files = [ + { path: 'style.css', contents: buildStyleCss({ name, slug, description, version }) }, + { path: 'functions.php', contents: buildFunctionsPhp({ name, slug, themePackage: pkg }) }, + { path: 'theme.json', contents: `${JSON.stringify(tokens.merged, null, 2)}\n` }, + { path: 'templates/index.html', contents: buildIndexTemplate(patternMeta.map((p) => p.slug)) }, + { path: 'templates/page.html', contents: buildPageTemplate() }, + { path: 'templates/404.html', contents: buildNotFoundTemplate() }, + { path: 'parts/header.html', contents: parts.header }, + { path: 'parts/footer.html', contents: parts.footer }, + ...patternFiles, + { path: 'bin/import-media.sh', contents: buildImportScript(), mode: '755' }, + ]; + + if (options.seedContent) { + files.push({ path: 'bin/seed-content.sh', contents: buildSeedScript(patternMeta), mode: '755' }); + } + + const reportMarkdown = buildGenerationReport({ + themeName: name, + themeSlug: slug, + files: [...files.map((f) => f.path), REPORT_FILE], + assets, + unmapped: ctx.unmapped, + tokensReport: tokens.report, + derivedFromMaster: parts.derivedFromMaster, + spreadCount: spreads.length, + assetsStaged: false, + }); + files.push({ path: REPORT_FILE, contents: `${reportMarkdown}\n` }); + + return { + slug, + name, + package: pkg, + files, + assets, + themeJson: tokens.merged, + tokens, + report: { + markdown: reportMarkdown, + data: { + unmapped: ctx.unmapped, + patterns: patternMeta, + assets, + valid: tokens.report?.valid ?? true, + }, + }, + }; +} diff --git a/packages/pipeline/src/indesign/generate/layout.js b/packages/pipeline/src/indesign/generate/layout.js new file mode 100644 index 0000000..99967a5 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/layout.js @@ -0,0 +1,116 @@ +// Turn a spread's flat list of frames into an ordered layout: a top-to-bottom +// sequence of rows, where a row holding more than one frame becomes a +// core/columns block. Pure geometry, fully deterministic for a given IR. +// +// Two refinements on top of plain reading order: +// 1. Column grouping — frames whose vertical spans overlap enough sit in the +// same row and render side by side, left to right. +// 2. Cover nesting — an image frame that largely contains text frames is a +// background; those text frames are pulled out of normal flow and nested +// inside the image's cover block instead of stacking after it. + +/** Vertical overlap of two rects as a fraction of the shorter one's height. */ +function verticalOverlapRatio(a, b) { + const top = Math.max(a.y, b.y); + const bottom = Math.min(a.y + a.height, b.y + b.height); + const overlap = bottom - top; + if (overlap <= 0) return 0; + const shorter = Math.min(a.height, b.height) || 1; + return overlap / shorter; +} + +/** Area of intersection of two rects. */ +function intersectionArea(a, b) { + const w = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)); + const h = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)); + return w * h; +} + +const COVER_CONTAINMENT = 0.6; // a text frame ≥60% inside an image → overlaid +const ROW_OVERLAP = 0.5; // vertical overlap ≥50% → same row + +/** + * Assign each text frame that sits mostly inside an image frame to that image + * (the largest containing image wins). Returns the set of consumed text-frame + * ids and a map of image-frame id → overlaid text frames (in reading order). + */ +function detectCovers(frames) { + const images = frames.filter((f) => f.kind === 'image'); + const texts = frames.filter((f) => f.kind === 'text'); + /** @type {Map>} */ + const coverChildren = new Map(); + const consumed = new Set(); + + for (const text of texts) { + const textArea = text.bounds.width * text.bounds.height || 1; + let best = null; + let bestArea = 0; + for (const image of images) { + const contained = intersectionArea(text.bounds, image.bounds) / textArea; + if (contained < COVER_CONTAINMENT) continue; + const imageArea = image.bounds.width * image.bounds.height; + if (imageArea > bestArea) { + best = image; + bestArea = imageArea; + } + } + if (best) { + consumed.add(text.id); + const list = coverChildren.get(best.id) ?? []; + list.push(text); + coverChildren.set(best.id, list); + } + } + + for (const list of coverChildren.values()) list.sort(readingOrder); + return { coverChildren, consumed }; +} + +/** Reading order: top-to-bottom, then left-to-right, then id for stability. */ +function readingOrder(a, b) { + if (Math.abs(a.bounds.y - b.bounds.y) > 1) return a.bounds.y - b.bounds.y; + if (Math.abs(a.bounds.x - b.bounds.x) > 1) return a.bounds.x - b.bounds.x; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** + * Group already-ordered frames into rows. A frame joins the current row when it + * overlaps every member of that row vertically; otherwise it starts a new row. + */ +function groupRows(frames) { + /** @type {Array>} */ + const rows = []; + for (const frame of frames) { + const row = rows[rows.length - 1]; + const fits = row && row.every((m) => verticalOverlapRatio(frame.bounds, m.bounds) >= ROW_OVERLAP); + if (fits) row.push(frame); + else rows.push([frame]); + } + // Order each row left-to-right so columns read naturally. + for (const row of rows) row.sort((a, b) => a.bounds.x - b.bounds.x || (a.id < b.id ? -1 : 1)); + return rows; +} + +/** + * @typedef {Object} LayoutFrame + * @property {object} frame The IR frame. + * @property {Array} [coverChildren] Text frames overlaid on an image. + * + * @typedef {Object} LayoutRow + * @property {Array} items One entry → block; many → columns. + * + * Build the ordered row layout for one spread. + * @param {Array} frames Frames belonging to the spread. + * @returns {Array} + */ +export function layoutSpread(frames) { + const { coverChildren, consumed } = detectCovers(frames); + const flow = frames.filter((f) => !consumed.has(f.id)).sort(readingOrder); + const rows = groupRows(flow); + return rows.map((row) => ({ + items: row.map((frame) => ({ + frame, + coverChildren: coverChildren.get(frame.id) ?? [], + })), + })); +} diff --git a/packages/pipeline/src/indesign/generate/media.js b/packages/pipeline/src/indesign/generate/media.js new file mode 100644 index 0000000..c798c68 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/media.js @@ -0,0 +1,100 @@ +// Asset staging plan and WP-CLI seeding scripts. +// +// The generator works from the IR, which carries image references (hrefs), not +// bytes. So we compute a deterministic staged filename for every image frame and +// emit bin/import-media.sh to load whatever lands in assets/ into the media +// library. The CLI copies real bytes in when an asset source is available; +// reruns map the same frame to the same filename, so nothing churns. + +import { assetFileName } from './slugs.js'; + +/** + * @typedef {Object} PlannedAsset + * @property {string} frameId + * @property {string} name Filename, e.g. "spread-1-image-1.jpg". + * @property {string} relPath Theme-relative path, e.g. "assets/spread-1-image-1.jpg". + * @property {string} [href] Original IR href. + * @property {boolean} embedded Whether the bytes live inside the source IDML. + * + * Plan staged assets for every image frame across the spreads. + * @param {import('../ir.js').DocumentIR} ir + * @returns {{ assets: Array, assetPathById: Map }} + */ +export function planAssets(ir) { + /** @type {Array} */ + const assets = []; + const assetPathById = new Map(); + + (ir.spreads ?? []).forEach((spread, spreadIndex) => { + let imageIndex = 0; + for (const frame of spread.frames ?? []) { + if (frame.kind !== 'image') continue; + const name = assetFileName(spreadIndex, imageIndex, frame.href); + imageIndex += 1; + const relPath = `assets/${name}`; + assets.push({ + frameId: frame.id, + name, + relPath, + href: frame.href, + embedded: Boolean(frame.embedded), + }); + assetPathById.set(frame.id, relPath); + } + }); + + return { assets, assetPathById }; +} + +/** bin/import-media.sh — import staged assets into the media library. */ +export function buildImportScript() { + return `#!/usr/bin/env bash +# Generated by @flavian/pipeline — seed the WordPress media library with the +# theme's staged assets. Paths resolve relative to the theme directory, so this +# runs from anywhere. +set -euo pipefail + +THEME_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)" +ASSET_DIR="$THEME_DIR/assets" + +if [ ! -d "$ASSET_DIR" ]; then + echo "No assets directory at $ASSET_DIR — nothing to import." >&2 + exit 0 +fi + +shopt -s nullglob +imported=0 +for asset in "$ASSET_DIR"/*; do + echo "Importing $(basename "$asset")" + wp media import "$asset" --title="$(basename "$asset")" --porcelain + imported=$((imported + 1)) +done +echo "Imported $imported asset(s)." +`; +} + +/** + * bin/seed-content.sh — create one draft page per spread, populated with that + * spread's imported pattern. Only emitted when --seed-content is passed. + * + * @param {Array<{ title: string, slug: string }>} patterns + */ +export function buildSeedScript(patterns) { + const lines = patterns.map(({ title, slug }) => { + const safeTitle = String(title).replace(/'/g, "'\\''"); + return `wp post create --post_type=page --post_status=draft \\ + --post_title='${safeTitle}' \\ + --post_content='' \\ + --porcelain`; + }); + return `#!/usr/bin/env bash +# Generated by @flavian/pipeline — seed one draft page per imported InDesign +# spread, each populated with the spread's block pattern. Review and publish in +# wp-admin afterwards. +set -euo pipefail + +${lines.join('\n\n')} + +echo "Seeded ${patterns.length} draft page(s)." +`; +} diff --git a/packages/pipeline/src/indesign/generate/parts.js b/packages/pipeline/src/indesign/generate/parts.js new file mode 100644 index 0000000..36e4ea7 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/parts.js @@ -0,0 +1,78 @@ +// Template parts from master-spread chrome. A master's repeating elements split +// by vertical position: text in the top band becomes the header, text in the +// bottom band becomes the footer (page-number / running-head chrome → a web +// footer line). With no usable master, sensible FSE defaults are emitted so the +// theme still has a working header and footer. + +import { renderTextFrameInner, indent } from './blocks.js'; + +const DEFAULT_HEADER = [ + '', + '
', + '\t', + '\t
', + '\t\t', + '', + '\t\t', + '\t
', + '\t', + '
', + '', + '', +].join('\n'); + +const DEFAULT_FOOTER = [ + '', + '
', + '\t', + '\t

·

', + '\t', + '
', + '', + '', +].join('\n'); + +/** Text-frame inner blocks whose vertical centre falls in [lo, hi) of the page. */ +function bandText(master, ctx, lo, hi) { + const page = master.pages?.[0]?.bounds; + const pageTop = page ? page.y : 0; + const pageHeight = page ? page.height : null; + const frames = (master.frames ?? []) + .filter((f) => f.kind === 'text') + .filter((f) => { + if (!pageHeight) return false; + const centre = f.bounds.y + f.bounds.height / 2; + const rel = (centre - pageTop) / pageHeight; + return rel >= lo && rel < hi; + }) + .sort((a, b) => a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x); + return frames.flatMap((f) => renderTextFrameInner(f, ctx)); +} + +function wrap(tagName, inner) { + return [ + ``, + `<${tagName} class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--40);padding-right:var(--wp--preset--spacing--40);padding-bottom:var(--wp--preset--spacing--40);padding-left:var(--wp--preset--spacing--40)">`, + indent(inner), + ``, + '', + '', + ].join('\n'); +} + +/** + * Build header + footer parts. Uses the first master spread's chrome when it has + * usable text; otherwise emits defaults. + * + * @returns {{ header: string, footer: string, derivedFromMaster: boolean }} + */ +export function buildParts(ir, ctx) { + const master = (ir.masterSpreads ?? [])[0]; + if (!master) return { header: DEFAULT_HEADER, footer: DEFAULT_FOOTER, derivedFromMaster: false }; + + const top = bandText(master, ctx, 0, 0.34); + const bottom = bandText(master, ctx, 0.66, 1.01); + const header = top.length ? wrap('header', top.join('\n\n')) : DEFAULT_HEADER; + const footer = bottom.length ? wrap('footer', bottom.join('\n\n')) : DEFAULT_FOOTER; + return { header, footer, derivedFromMaster: top.length > 0 || bottom.length > 0 }; +} diff --git a/packages/pipeline/src/indesign/generate/patterns.js b/packages/pipeline/src/indesign/generate/patterns.js new file mode 100644 index 0000000..a471df1 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/patterns.js @@ -0,0 +1,105 @@ +// Assemble one FSE block pattern (a PHP file) per spread. Rows become +// core/columns when they hold more than one frame; a lone text frame is wrapped +// in a core/group; the whole spread is a full-width constrained section so it +// drops cleanly into a template or page. + +import { layoutSpread } from './layout.js'; +import { renderFrame, indent } from './blocks.js'; + +const PATTERN_CATEGORY = 'indesign-imports'; + +function groupBlock(inner, { align } = {}) { + const attrs = {}; + if (align) attrs.align = align; + attrs.layout = { type: 'constrained' }; + const cls = align ? `wp-block-group align${align}` : 'wp-block-group'; + return [ + ``, + `
`, + indent(inner), + '
', + '', + ].join('\n'); +} + +function columnBlock(inner) { + return ['', '
', indent(inner), '
', ''].join('\n'); +} + +function columnsBlock(columns) { + return [ + '', + '
', + ...columns.map((c) => indent(columnBlock(c))), + '
', + '', + ].join('\n'); +} + +/** Render one layout row to markup, or null if every frame in it was unmapped. */ +function renderRow(row, ctx) { + if (row.items.length === 1) { + const item = row.items[0]; + const markup = renderFrame(item, ctx); + if (markup == null) return null; + // A bare text frame gets a group container; image/cover stand alone. + return item.frame.kind === 'text' ? groupBlock(markup) : markup; + } + + const columns = row.items + .map((item) => renderFrame(item, ctx)) + .filter((m) => m != null); + if (!columns.length) return null; + if (columns.length === 1) return columns[0]; + return columnsBlock(columns); +} + +/** + * Build the block markup body (no PHP header) for a spread. + * @returns {string} + */ +export function renderSpreadBody(spread, ctx) { + const rows = layoutSpread(spread.frames ?? []); + const blocks = rows.map((row) => renderRow(row, ctx)).filter(Boolean); + const section = blocks.join('\n\n') || '\n\n'; + return groupBlock(section, { align: 'full' }); +} + +/** + * Build a full pattern PHP file for a spread. + * + * @param {Object} input + * @param {object} input.spread + * @param {number} input.index 0-based spread index. + * @param {string} input.themePackage + * @param {string} input.title + * @param {string} input.patternSlug "/spread-N" + * @param {object} input.ctx Render context (stories, styles, slugs…). + * @returns {string} + */ +export function buildPatternFile({ spread, index, themePackage, title, patternSlug, ctx }) { + const body = renderSpreadBody(spread, ctx); + const description = `Imported from InDesign spread ${index + 1}. Replace placeholder text and images after inserting.`; + const header = [ + '', + ].join('\n'); + return `${header}\n${body}\n`; +} + +/** Strip characters that would break out of a PHP docblock header line. */ +function phpDocComment(text) { + return String(text ?? '').replace(/\*\//g, '* /').replace(/[\r\n]+/g, ' '); +} + +export { PATTERN_CATEGORY }; diff --git a/packages/pipeline/src/indesign/generate/report.js b/packages/pipeline/src/indesign/generate/report.js new file mode 100644 index 0000000..11bc6c2 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/report.js @@ -0,0 +1,80 @@ +// The human-facing generation report (indesign-pipeline-report.md). Enumerates +// every produced artifact, the IR nodes that couldn't be mapped, and the manual +// follow-ups (font fallbacks, out-of-gamut colors, Google fonts to enqueue, +// assets whose bytes weren't staged). Deterministic: no timestamps. + +function list(items) { + return items.length ? items.map((i) => `- ${i}`).join('\n') : '_None._'; +} + +function section(title, body) { + return `## ${title}\n\n${body}\n`; +} + +/** + * @param {Object} input + * @param {string} input.themeName + * @param {string} input.themeSlug + * @param {Array} input.files Theme-relative paths produced. + * @param {Array} input.assets Planned assets (PlannedAsset[]). + * @param {Array<{id: string, kind: string, reason: string}>} input.unmapped + * @param {object} input.tokensReport mapTokens() report. + * @param {boolean} input.derivedFromMaster Whether parts came from a master. + * @param {number} input.spreadCount + * @param {boolean} [input.assetsStaged] Whether real bytes were copied in. + * @returns {string} + */ +export function buildGenerationReport(input) { + const { + themeName, + themeSlug, + files, + assets = [], + unmapped = [], + tokensReport = {}, + derivedFromMaster, + spreadCount, + assetsStaged = false, + } = input; + + const followUps = []; + for (const m of tokensReport.fontFallbacks ?? []) followUps.push(`Font fallback: ${m}`); + for (const g of tokensReport.googleFonts ?? []) followUps.push(`Enqueue Google font: ${g.name} (slug \`${g.slug}\`)`); + for (const m of tokensReport.outOfGamut ?? []) followUps.push(`Out-of-gamut color clamped: ${m}`); + if (!assetsStaged && assets.length) { + followUps.push(`${assets.length} image asset(s) referenced but bytes not staged — drop files into \`assets/\` (see names below) and run \`bin/import-media.sh\`.`); + } + if (!derivedFromMaster) { + followUps.push('Header/footer use generated defaults (no usable master-spread chrome found) — review `parts/header.html` and `parts/footer.html`.'); + } + + const unmappedLines = unmapped.map((u) => `\`${u.id}\` (${u.kind}) — ${u.reason}`); + const assetLines = assets.map((a) => `\`${a.relPath}\`${a.href ? ` ← ${a.href}` : ''}${a.embedded ? ' (embedded)' : ''}`); + + const validityNote = tokensReport.valid === false + ? `⚠️ **theme.json failed validation** (${(tokensReport.validationErrors ?? []).length} error(s)).` + : '✅ theme.json validates against the WordPress schema.'; + + return [ + `# InDesign import — generation report`, + '', + `**Theme:** ${themeName} (\`${themeSlug}\`)`, + `**Spreads imported:** ${spreadCount}`, + '', + validityNote, + '', + section('Produced artifacts', list(files.map((f) => `\`${f}\``))), + section('Staged assets', list(assetLines)), + section('Unmapped IR nodes', list(unmappedLines)), + section('Manual follow-ups', list(followUps)), + section( + 'Token summary', + [ + `- Swatches: ${tokensReport.counts?.swatches ?? 0}`, + `- Fonts: ${tokensReport.counts?.fonts ?? 0}`, + `- Styles: ${tokensReport.counts?.styles ?? 0}`, + `- Mapper warnings: ${tokensReport.counts?.warnings ?? 0}`, + ].join('\n'), + ), + ].join('\n'); +} diff --git a/packages/pipeline/src/indesign/generate/slugs.js b/packages/pipeline/src/indesign/generate/slugs.js new file mode 100644 index 0000000..b6d7454 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/slugs.js @@ -0,0 +1,48 @@ +// Deterministic naming for everything the generator emits. Same IR in → same +// slugs and filenames out, so reruns never churn unrelated files. + +import { slugify } from '../map/slug.js'; + +const FALLBACK_THEME_SLUG = 'indesign-import'; + +/** Theme directory slug: explicit option → document name → fallback. */ +export function themeSlug(ir, explicit) { + return slugify(explicit) || slugify(ir?.meta?.name) || FALLBACK_THEME_SLUG; +} + +/** Human-facing theme name. */ +export function themeName(ir, explicit) { + if (explicit && typeof explicit === 'string') return explicit; + const name = ir?.meta?.name; + return name && String(name).trim() ? String(name) : 'InDesign Import'; +} + +/** PHP @package tag, e.g. "Indesign_Import". */ +export function themePackage(slug) { + return slug + .split('-') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('_'); +} + +/** Stable pattern slug for a spread, 1-based: "/spread-N". */ +export function spreadPatternSlug(slug, index) { + return `${slug}/spread-${index + 1}`; +} + +/** File extension for an asset href, defaulting to png. */ +function assetExt(href) { + const m = /\.([a-z0-9]{2,5})(?:[?#]|$)/i.exec(String(href ?? '')); + return m ? m[1].toLowerCase() : 'png'; +} + +/** + * Deterministic asset filename for an image frame. Index-based so a missing or + * duplicated href never collides or reorders: "spread-N-image-K.ext". + */ +export function assetFileName(spreadIndex, frameIndex, href) { + return `spread-${spreadIndex + 1}-image-${frameIndex + 1}.${assetExt(href)}`; +} + +export { slugify }; diff --git a/packages/pipeline/src/indesign/generate/templates.js b/packages/pipeline/src/indesign/generate/templates.js new file mode 100644 index 0000000..ab3ed03 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/templates.js @@ -0,0 +1,52 @@ +// FSE templates. index.html stitches the imported spread patterns between the +// header and footer parts so the design renders end-to-end; page.html and +// 404.html are minimal, valid fallbacks that keep the Site Editor happy. + +function shell(mainInner) { + return [ + '', + '', + '', + '
', + mainInner, + '
', + '', + '', + '', + '', + ].join('\n'); +} + +/** index.html — references every spread pattern in order. */ +export function buildIndexTemplate(patternSlugs) { + const refs = patternSlugs.length + ? patternSlugs.map((slug) => `\t`).join('\n\n') + : '\t'; + return shell(refs); +} + +/** page.html — standard single-page layout. */ +export function buildPageTemplate() { + return shell( + [ + '\t', + '', + '\t', + ].join('\n'), + ); +} + +/** 404.html — friendly not-found page. */ +export function buildNotFoundTemplate() { + return shell( + [ + '\t', + '\t

Page not found

', + '\t', + '', + '\t', + '\t

The page you were looking for is not here.

', + '\t', + ].join('\n'), + ); +} diff --git a/packages/pipeline/src/indesign/generate/theme-files.js b/packages/pipeline/src/indesign/generate/theme-files.js new file mode 100644 index 0000000..a6cd2d2 --- /dev/null +++ b/packages/pipeline/src/indesign/generate/theme-files.js @@ -0,0 +1,103 @@ +// Theme bootstrap files: style.css (the theme header WordPress reads) and +// functions.php (registers the "InDesign Imports" pattern category, loads the +// text domain, enqueues the stylesheet). Both are deterministic — no version +// stamps or dates that would churn between runs. + +import { phpSingleQuoted } from './escape.js'; +import { PATTERN_CATEGORY } from './patterns.js'; + +/** + * style.css with the WordPress theme header. The body is intentionally empty — + * all design lives in theme.json — but the file must exist for the theme to + * register. + */ +export function buildStyleCss({ name, slug, description, version = '0.1.0' }) { + return [ + '/*', + `Theme Name: ${name}`, + 'Theme URI: ', + 'Author: @flavian/pipeline', + 'Author URI: ', + `Description: ${description}`, + `Version: ${version}`, + 'Requires at least: 6.5', + 'Tested up to: 6.7', + 'Requires PHP: 7.4', + 'License: GNU General Public License v2 or later', + 'License URI: http://www.gnu.org/licenses/gpl-2.0.html', + `Text Domain: ${slug}`, + 'Tags: full-site-editing, block-patterns, blog', + '*/', + '', + ].join('\n'); +} + +/** + * functions.php: register the pattern category, load the text domain, enqueue + * the (mostly empty) stylesheet. Prefixed with a slug-derived function prefix to + * avoid collisions. + */ +export function buildFunctionsPhp({ name, slug, themePackage }) { + const fnPrefix = slug.replace(/-/g, '_'); + const constName = `${slug.toUpperCase().replace(/-/g, '_')}_VERSION`; + return ` __( 'InDesign Imports', '${slug}' ) ) + ); +} +add_action( 'init', '${fnPrefix}_register_pattern_categories' ); + +/** + * Enqueue front-end styles. + * + * @return void + */ +function ${fnPrefix}_enqueue_assets() { + wp_enqueue_style( + '${slug}', + get_stylesheet_uri(), + array(), + ${constName} + ); +} +add_action( 'wp_enqueue_scripts', '${fnPrefix}_enqueue_assets' ); +`; +} diff --git a/packages/pipeline/src/indesign/index.js b/packages/pipeline/src/indesign/index.js index 02adad7..8c240aa 100644 --- a/packages/pipeline/src/indesign/index.js +++ b/packages/pipeline/src/indesign/index.js @@ -4,3 +4,4 @@ export * as ir from './ir.js'; export { WarningCollector } from './warnings.js'; export { lengthToPx, ptToPx, roundPx } from './units.js'; export { mapTokens } from './map/index.js'; +export { generateTheme } from './generate/index.js'; diff --git a/packages/pipeline/src/indesign/map/typography.js b/packages/pipeline/src/indesign/map/typography.js index 0a4eea6..aad2676 100644 --- a/packages/pipeline/src/indesign/map/typography.js +++ b/packages/pipeline/src/indesign/map/typography.js @@ -16,6 +16,23 @@ const HEADING_RE = /^h(?:eading)?\s*([1-6])$/i; const BODY_RE = /^(body|paragraph|normal|default|text|copy)\b/i; const CAPTION_RE = /^(caption|footnote|cutline|credit)\b/i; +/** + * Classify a paragraph style by its InDesign name. The output generator reuses + * this so a style that became an `h2` element preset also renders as an + * `core/heading` at level 2 — one source of truth for the name → role mapping. + * + * @param {unknown} name + * @returns {{ role: 'heading'|'body'|'caption'|'generic', level?: number }} + */ +export function classifyStyleRole(name) { + const s = String(name ?? '').trim(); + const h = HEADING_RE.exec(s); + if (h) return { role: 'heading', level: Number(h[1]) }; + if (BODY_RE.test(s)) return { role: 'body' }; + if (CAPTION_RE.test(s)) return { role: 'caption' }; + return { role: 'generic' }; +} + /** @param {number} n @param {number} dp */ function round(n, dp) { const f = 10 ** dp; diff --git a/packages/pipeline/tests/indesign/__snapshots__/pattern-image-spread.php b/packages/pipeline/tests/indesign/__snapshots__/pattern-image-spread.php new file mode 100644 index 0000000..3e71b3c --- /dev/null +++ b/packages/pipeline/tests/indesign/__snapshots__/pattern-image-spread.php @@ -0,0 +1,27 @@ + + +
+ +
+ + +
+ +

On Sale Now

+ +
+
+ +
+ diff --git a/packages/pipeline/tests/indesign/__snapshots__/pattern-text-spread.php b/packages/pipeline/tests/indesign/__snapshots__/pattern-text-spread.php new file mode 100644 index 0000000..e5c9480 --- /dev/null +++ b/packages/pipeline/tests/indesign/__snapshots__/pattern-text-spread.php @@ -0,0 +1,35 @@ + + +
+ +
+ +

Spring Catalog

+ +
+ + + +
+ +

Section heading

+ + + +

First paragraph of body copy.

+ +
+ +
+ diff --git a/packages/pipeline/tests/indesign/generate.test.mjs b/packages/pipeline/tests/indesign/generate.test.mjs new file mode 100644 index 0000000..f76dd68 --- /dev/null +++ b/packages/pipeline/tests/indesign/generate.test.mjs @@ -0,0 +1,245 @@ +// Output generator: a parsed IR → a complete, installable FSE theme. Covers the +// acceptance criteria for sub-issue #65 — schema-valid theme.json, one pattern +// per spread under the "InDesign Imports" category, an enumerating report, and +// snapshot coverage of the generated markup for a text-heavy and an image-heavy +// spread. Snapshots live in __snapshots__/ and update with UPDATE_SNAPSHOTS=1. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { generateTheme } from '../../src/indesign/generate/index.js'; +import { parseIdmlBuffer } from '../../src/indesign/parse-idml.js'; +import { parsePdfBuffer } from '../../src/indesign/parse-pdf.js'; +import { buildIdml } from './helpers/build-idml.js'; +import { buildPdf } from './helpers/build-pdf.js'; + +const SNAP_DIR = fileURLToPath(new URL('./__snapshots__/', import.meta.url)); + +/** Compare against a committed snapshot, or write it when UPDATE_SNAPSHOTS is set. */ +function matchSnapshot(name, actual) { + const file = path.join(SNAP_DIR, name); + if (process.env.UPDATE_SNAPSHOTS) { + mkdirSync(SNAP_DIR, { recursive: true }); + writeFileSync(file, actual); + return; + } + let expected; + try { + expected = readFileSync(file, 'utf8'); + } catch { + throw new Error(`Missing snapshot ${name}. Re-run with UPDATE_SNAPSHOTS=1 to create it.`); + } + assert.equal(actual, expected, `snapshot ${name} drifted`); +} + +/** A two-spread catalog: spread 1 is text-heavy, spread 2 is image-heavy. */ +function buildCatalog() { + return buildIdml({ + name: 'Catalog', + colors: [ + { id: 'col-brand', name: 'Brand', space: 'RGB', values: [10, 80, 200] }, + { id: 'col-ink', name: 'Ink', space: 'CMYK', values: [0, 0, 0, 100] }, + ], + fonts: [{ id: 'f', family: 'Helvetica', style: 'Bold', postScriptName: 'Helvetica-Bold' }], + styles: [ + { id: 'p-h1', name: 'Heading 1', kind: 'paragraph', pointSize: 36, leading: 40, appliedFont: 'f', fillColor: 'col-brand' }, + { id: 'p-h2', name: 'Heading 2', kind: 'paragraph', pointSize: 24, leading: 28, fillColor: 'col-ink' }, + { id: 'p-body', name: 'Body', kind: 'paragraph', pointSize: 12, leading: 18, fillColor: 'col-ink' }, + ], + stories: [ + { id: 's-title', runs: [{ text: 'Spring Catalog', paragraphStyle: 'p-h1' }] }, + { id: 's-copy', runs: [{ text: 'Section heading', paragraphStyle: 'p-h2' }, { text: 'First paragraph of body copy.', paragraphStyle: 'p-body' }] }, + { id: 's-hero', runs: [{ text: 'On Sale Now', paragraphStyle: 'p-h1' }] }, + ], + spreads: [ + { + id: 'text-spread', + pages: [{ id: 'p1', bounds: [0, 0, 792, 612] }], + frames: [ + { kind: 'text', id: 'tf-title', bounds: [60, 60, 140, 540], parentStory: 's-title' }, + { kind: 'text', id: 'tf-copy', bounds: [160, 60, 420, 540], parentStory: 's-copy' }, + ], + }, + { + id: 'image-spread', + pages: [{ id: 'p2', bounds: [0, 0, 792, 612] }], + frames: [ + { kind: 'image', id: 'if-hero', bounds: [0, 0, 612, 792], href: 'file:Links/hero.png' }, + { kind: 'text', id: 'if-overlay', bounds: [200, 100, 320, 500], parentStory: 's-hero' }, + ], + }, + ], + }); +} + +const file = (result, p) => result.files.find((f) => f.path === p); + +test('generates a schema-valid, Flavian-compatible theme', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const result = generateTheme(ir); + + assert.equal(result.report.data.valid, true, JSON.stringify(result.tokens.report.validationErrors)); + + // The files a block theme needs to load in the Site Editor. + for (const p of ['style.css', 'functions.php', 'theme.json', 'templates/index.html', 'templates/page.html', 'templates/404.html', 'parts/header.html', 'parts/footer.html', 'bin/import-media.sh', 'indesign-pipeline-report.md']) { + assert.ok(file(result, p), `missing ${p}`); + } + + // theme.json is the merged base + partial. + const themeJson = JSON.parse(file(result, 'theme.json').contents); + assert.equal(themeJson.version, 3); + assert.ok(themeJson.settings.color.palette.length > 0); + + // functions.php registers the import category with the expected label. + const fns = file(result, 'functions.php').contents; + assert.match(fns, /register_block_pattern_category\(\s*'indesign-imports'/); + assert.match(fns, /'label'\s*=>\s*__\(\s*'InDesign Imports'/); +}); + +test('emits one pattern per spread under the InDesign Imports category', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const result = generateTheme(ir); + + const patterns = result.files.filter((f) => /^patterns\/spread-\d+\.php$/.test(f.path)); + assert.equal(patterns.length, ir.spreads.length); + + for (const p of patterns) { + assert.match(p.contents, /^\s*\* Categories: indesign-imports$/m); + assert.match(p.contents, /^\s*\* Slug: catalog\/spread-\d+$/m); + } + + // index.html references every spread pattern so the design renders. + const index = file(result, 'templates/index.html').contents; + assert.match(index, //); + assert.match(index, //); +}); + +test('snapshot: text-heavy spread markup', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const result = generateTheme(ir); + matchSnapshot('pattern-text-spread.php', file(result, 'patterns/spread-1.php').contents); +}); + +test('snapshot: image-heavy spread markup', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const result = generateTheme(ir); + matchSnapshot('pattern-image-spread.php', file(result, 'patterns/spread-2.php').contents); +}); + +test('patterns use design tokens, never inline colors or font sizes', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const result = generateTheme(ir); + const patterns = result.files.filter((f) => f.path.startsWith('patterns/')); + for (const p of patterns) { + // References preset slugs. + assert.match(p.contents, /"fontSize":"/); + // No hardcoded hex colors or inline font-size declarations. + assert.doesNotMatch(p.contents, /#[0-9a-fA-F]{6}\b/, `${p.path} has a hardcoded color`); + assert.doesNotMatch(p.contents, /font-size:\s*\d/, `${p.path} has an inline font-size`); + } +}); + +test('every token slug referenced in patterns exists in the merged theme.json', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const result = generateTheme(ir); + + const fontSizes = new Set((result.themeJson.settings.typography.fontSizes ?? []).map((s) => s.slug)); + const colors = new Set((result.themeJson.settings.color.palette ?? []).map((s) => s.slug)); + + const body = result.files.filter((f) => f.path.startsWith('patterns/')).map((f) => f.contents).join('\n'); + for (const [, slug] of body.matchAll(/"fontSize":"([^"]+)"/g)) { + assert.ok(fontSizes.has(slug), `font-size slug "${slug}" not in theme.json`); + } + for (const [, slug] of body.matchAll(/"textColor":"([^"]+)"/g)) { + assert.ok(colors.has(slug), `color slug "${slug}" not in theme.json`); + } +}); + +test('report enumerates produced files and unmapped IR nodes', () => { + // A spread with a text frame whose story is missing → unmapped. + const idml = buildIdml({ + name: 'Partial', + styles: [{ id: 'p-body', name: 'Body', kind: 'paragraph', pointSize: 12 }], + stories: [{ id: 's-ok', runs: [{ text: 'Mapped text.', paragraphStyle: 'p-body' }] }], + spreads: [ + { + id: 'spread-1', + pages: [{ id: 'p1', bounds: [0, 0, 792, 612] }], + frames: [ + { kind: 'text', id: 'good', bounds: [60, 60, 120, 540], parentStory: 's-ok' }, + { kind: 'text', id: 'orphan', bounds: [200, 60, 260, 540] }, // no parentStory + ], + }, + ], + }); + const ir = parseIdmlBuffer(idml); + const result = generateTheme(ir); + + const md = file(result, 'indesign-pipeline-report.md').contents; + assert.match(md, /## Produced artifacts/); + assert.match(md, /`patterns\/spread-1\.php`/); + assert.match(md, /`theme\.json`/); + + // The orphan text frame is reported as unmapped. + assert.match(md, /## Unmapped IR nodes/); + assert.match(md, /`orphan`/); + assert.ok(result.report.data.unmapped.some((u) => u.id === 'orphan')); +}); + +test('output is deterministic across runs', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const a = generateTheme(ir); + const b = generateTheme(ir); + assert.deepEqual(a.files, b.files); +}); + +test('plans staged assets and emits an import script', () => { + const ir = parseIdmlBuffer(buildCatalog()); + const result = generateTheme(ir); + + assert.equal(result.assets.length, 1); + assert.equal(result.assets[0].relPath, 'assets/spread-2-image-1.png'); + + const script = file(result, 'bin/import-media.sh'); + assert.ok(script); + assert.equal(script.mode, '755'); + assert.match(script.contents, /wp media import/); +}); + +test('--seed-content adds a seeding script; default omits it', () => { + const ir = parseIdmlBuffer(buildCatalog()); + assert.ok(!file(generateTheme(ir), 'bin/seed-content.sh')); + + const seeded = generateTheme(ir, { seedContent: true }); + const script = file(seeded, 'bin/seed-content.sh'); + assert.ok(script); + assert.match(script.contents, /wp post create/); + assert.match(script.contents, /wp:pattern \{"slug":"catalog\/spread-1"\}/); +}); + +test('works on a PDF-derived IR (source-agnostic)', async () => { + const toUnit = ([r, g, b]) => [r / 255, g / 255, b / 255]; + const pdf = buildPdf({ + title: 'Flyer', + pages: [ + { + width: 612, + height: 792, + texts: [ + { text: 'Big Sale', x: 72, y: 96, size: 36, font: 'Helvetica-Bold', color: toUnit([20, 184, 166]) }, + { text: 'Everything must go this weekend only.', x: 72, y: 150, size: 12, font: 'Helvetica', color: toUnit([0, 0, 0]) }, + ], + }, + ], + }); + const ir = await parsePdfBuffer(pdf); + const result = generateTheme(ir); + + assert.equal(result.report.data.valid, true); + const patterns = result.files.filter((f) => f.path.startsWith('patterns/')); + assert.equal(patterns.length, ir.spreads.length); + assert.ok(patterns.length >= 1); +});