Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions .github/workflows/pipeline-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
should-test: ${{ steps.filter.outputs.scripts || steps.filter.outputs.tests }}
should-test-pipeline-pkg: ${{ steps.filter.outputs.pipeline-pkg }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
Expand All @@ -21,6 +22,9 @@ jobs:
- 'scripts/canva-fse/**'
tests:
- 'tests/**'
pipeline-pkg:
- 'packages/pipeline/**'
- 'pnpm-workspace.yaml'

test:
needs: check-paths
Expand Down Expand Up @@ -49,21 +53,48 @@ jobs:
- name: Run Canva-to-FSE pipeline tests
run: ./tests/libs/bats-core/bin/bats tests/canva-fse/

test-indesign-pkg:
needs: check-paths
if: needs.check-paths.outputs.should-test-pipeline-pkg == 'true'
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9.15.0

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install workspace dependencies
run: pnpm install --frozen-lockfile

- name: Run @flavian/pipeline tests
run: pnpm --filter @flavian/pipeline test

test-status:
runs-on: ubuntu-latest
needs: [check-paths, test]
needs: [check-paths, test, test-indesign-pkg]
if: always()
name: Pipeline Tests Status
steps:
- name: Report status
run: |
if [ "${{ needs.check-paths.outputs.should-test }}" != "true" ]; then
if [ "${{ needs.check-paths.outputs.should-test }}" != "true" ] && [ "${{ needs.check-paths.outputs.should-test-pipeline-pkg }}" != "true" ]; then
echo "No pipeline/test changes detected — skipping tests"
exit 0
fi
if [ "${{ needs.test.result }}" = "success" ]; then
echo "Pipeline tests passed"
exit 0
fail=0
if [ "${{ needs.check-paths.outputs.should-test }}" = "true" ] && [ "${{ needs.test.result }}" != "success" ]; then
echo "bats pipeline tests failed"
fail=1
fi
if [ "${{ needs.check-paths.outputs.should-test-pipeline-pkg }}" = "true" ] && [ "${{ needs.test-indesign-pkg.result }}" != "success" ]; then
echo "@flavian/pipeline tests failed"
fail=1
fi
echo "Pipeline tests failed"
exit 1
exit $fail
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"scripts": {
"init": "node scripts/init.mjs",
"test:init": "node --test \"tests/init/**/*.test.mjs\"",
"test:pipeline": "pnpm --filter @flavian/pipeline test",
"visual:capture": "node tests/visual/capture.mjs",
"visual:diff": "node scripts/visual-diff.js --batch tests/visual/actual tests/visual/baselines --output-dir tests/visual/diffs --threshold 0.005 --json > tests/visual/report.json && node tests/visual/print-report.mjs tests/visual/report.json",
"visual:update": "bash scripts/visual-update-baselines.sh",
Expand Down
88 changes: 88 additions & 0 deletions packages/pipeline/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# @flavian/pipeline

Conversion pipeline for InDesign (and future) sources into WordPress FSE themes.

## Status

This package currently ships the **IDML parser and intermediate representation** (sub-issue #62 of the InDesign-to-WordPress epic). Downstream stages — PDF fallback (#63), style + token mapper (#64), output generator (#65) — will land as separate PRs. The IR shape produced here is the contract those stages consume.

## Layout

```
packages/pipeline/
├── bin/parse-idml.mjs CLI entry; prints validated IR JSON on stdout
└── src/
├── index.js Re-exports the InDesign surface
└── indesign/
├── ir.js zod schemas + JSDoc typedefs for the IR
├── parse-idml.js Main entry: unzips + orchestrates + cross-refs + validates
├── units.js pt/pc/mm/cm/in → px at configurable DPI
├── warnings.js Non-fatal warning collector
└── parsers/
├── xml.js fast-xml-parser wrapper
├── designmap.js designmap.xml → manifest with paths
├── resources.js Graphic.xml + Fonts.xml + Styles.xml
├── stories.js Stories/Story_*.xml → text runs
└── spreads.js Spreads/*.xml + MasterSpreads/*.xml
```

## Quick start

```js
import { parseIdml } from '@flavian/pipeline';

const ir = await parseIdml('./brochure.idml', { dpi: 96 });

for (const warning of ir.warnings) {
console.warn(`[${warning.code}] ${warning.message}`);
}
for (const swatch of ir.swatches) {
console.log(swatch.name, swatch.color.hex);
}
```

Or from the command line:

```bash
node packages/pipeline/bin/parse-idml.mjs my-document.idml > ir.json
```

## IR shape

The intermediate representation is described in [`src/indesign/ir.js`](src/indesign/ir.js). At the top level:

```js
{
irVersion: 1,
meta: { idmlVersion: '16.0', name: 'Brochure' },
dpi: 96,
swatches: [{ id, name, color: { hex, space } }],
fonts: [{ id, family, style, postScriptName }],
styles: [{ id, name, kind, fontSize, leading, tracking, fontRef, fillColorRef, properties }],
stories: [{ id, source, runs: [{ text, paragraphStyleRef, characterStyleRef }] }],
spreads: [{ id, source, pages, frames, appliedMasterRef }],
masterSpreads: [{ id, source, name, pages, frames }],
warnings: [{ code, message, context }],
}
```

Geometry (`Page.bounds`, `Frame.bounds`) is normalized to pixels at `dpi` (default 96). Frames are a discriminated union (`kind: 'text'` or `kind: 'image'`).

## Failure mode

- **Throws** on structural problems that make the IR meaningless: missing `designmap.xml`, malformed zip, a `<Spread>` element that lacks `Self`.
- **Warns and continues** on everything else: missing optional resource files, dangling style references, unknown color spaces, empty stories, unrecognized unit suffixes.

The CLI surfaces warnings on stderr and exits 0 unless the IR itself failed to build.

## Testing

```bash
pnpm --filter @flavian/pipeline test
```

Tests build minimal IDML zips programmatically (see `tests/indesign/helpers/build-idml.js`) — no binary fixtures in git. The fixture builder mirrors the IDML XML grammar the parser reads, so adding a new test case is usually one option flag.

## 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'`.
69 changes: 69 additions & 0 deletions packages/pipeline/bin/parse-idml.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env node
// CLI: print the parsed IR as JSON on stdout, warnings on stderr.
//
// flavian-parse-idml <path-to.idml> [--dpi <n>] [--quiet]

import { parseIdml } from '../src/indesign/parse-idml.js';

const args = process.argv.slice(2);
let inputPath;
let dpi;
let quiet = false;

for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--dpi') {
const next = args[i + 1];
if (!next || Number.isNaN(Number(next))) {
console.error('--dpi requires a positive number');
process.exit(2);
}
dpi = Number(next);
i += 1;
} else if (arg === '--quiet') {
quiet = true;
} else if (arg === '-h' || arg === '--help') {
printUsage();
process.exit(0);
} else if (!inputPath && !arg.startsWith('-')) {
inputPath = arg;
} else {
console.error(`Unknown argument: ${arg}`);
printUsage();
process.exit(2);
}
}

if (!inputPath) {
printUsage();
process.exit(2);
}

try {
const ir = await parseIdml(inputPath, dpi !== undefined ? { dpi } : undefined);
if (!quiet && ir.warnings.length > 0) {
for (const w of ir.warnings) {
const where = w.context?.file ? ` (${w.context.file}${w.context.id ? `#${w.context.id}` : ''})` : '';
process.stderr.write(`[${w.code}] ${w.message}${where}\n`);
}
process.stderr.write(`\n${ir.warnings.length} warning(s).\n`);
}
process.stdout.write(JSON.stringify(ir, null, 2) + '\n');
} catch (err) {
process.stderr.write(`error: ${err.message}\n`);
process.exit(1);
}

function printUsage() {
process.stderr.write(
[
'Usage: flavian-parse-idml <path.idml> [options]',
'',
'Options:',
' --dpi <n> Pixels per inch for unit normalization (default 96)',
' --quiet Suppress warnings on stderr',
' -h, --help Show this help',
'',
].join('\n'),
);
}
25 changes: 25 additions & 0 deletions packages/pipeline/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@flavian/pipeline",
"version": "0.1.0",
"private": true,
"description": "Conversion pipeline for InDesign (and future) sources into WordPress FSE themes.",
"type": "module",
"exports": {
".": "./src/index.js",
"./indesign": "./src/indesign/index.js"
},
"bin": {
"flavian-parse-idml": "./bin/parse-idml.mjs"
},
"scripts": {
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"fast-xml-parser": "^4.5.0",
"fflate": "^0.8.2",
"zod": "^3.23.8"
},
"engines": {
"node": ">=20.0.0"
}
}
4 changes: 4 additions & 0 deletions packages/pipeline/src/indesign/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { parseIdml, parseIdmlBuffer } from './parse-idml.js';
export * as ir from './ir.js';
export { WarningCollector } from './warnings.js';
export { lengthToPx, ptToPx, roundPx } from './units.js';
Loading
Loading