diff --git a/bin/flavian.mjs b/bin/flavian.mjs
index 76c5375..ba3cbbb 100755
--- a/bin/flavian.mjs
+++ b/bin/flavian.mjs
@@ -2,7 +2,6 @@
// Flavian CLI — the first-class entry point for Flavian's conversion pipelines.
//
// flavian pipeline indesign [options]
-// flavian pipeline ingest --out-dir
[options]
//
// Today it exposes the InDesign pipeline (#62–#65): parse an .idml/.pdf (or a
// pre-parsed IR JSON), map design tokens, and generate a complete FSE theme.
@@ -37,19 +36,13 @@ async function main() {
process.exit(command ? 0 : 2);
}
- if (command === 'indesign') {
- await runIndesign(rest);
- return;
- }
-
- if (command === 'ingest') {
- await runIngestCommand(rest);
- return;
+ if (command !== 'indesign') {
+ fail(`Unknown pipeline: ${command}`);
+ printPipelineUsage();
+ process.exit(2);
}
- fail(`Unknown pipeline: ${command}`);
- printPipelineUsage();
- process.exit(2);
+ await runIndesign(rest);
}
async function runIndesign(args) {
@@ -132,71 +125,6 @@ async function runIndesign(args) {
process.exit(result.report.data.valid ? 0 : 1);
}
-async function runIngestCommand(args) {
- const opts = { quiet: false };
- let input;
-
- let i = 0;
- const value = (flag) => {
- const next = args[i + 1];
- if (next === undefined || next.startsWith('-')) {
- fail(`${flag} requires a value`);
- process.exit(2);
- }
- i += 1;
- return next;
- };
-
- for (; i < args.length; i += 1) {
- const arg = args[i];
- switch (arg) {
- case '--out-dir': case '-o': opts.outDir = value(arg); break;
- case '--dpi': opts.dpi = Number(value(arg)); break;
- case '--name': opts.name = value(arg); break;
- case '--quiet': case '-q': opts.quiet = true; break;
- case '-h': case '--help': printIngestUsage(); process.exit(0); break;
- default:
- if (!input && !arg.startsWith('-')) input = arg;
- else { fail(`Unknown argument: ${arg}`); printIngestUsage(); process.exit(2); }
- }
- }
-
- if (!input) {
- fail('an input is required (an .idml/.pdf file or an IR JSON)');
- printIngestUsage();
- process.exit(2);
- }
- if (!opts.outDir) {
- fail('--out-dir is required');
- printIngestUsage();
- process.exit(2);
- }
-
- // Lazy import so `--help` never pays for loading the pipeline.
- const { runIngest } = await import(PIPELINE('ingest/index.js'));
-
- const summary = await runIngest({
- input,
- outDir: path.resolve(opts.outDir),
- dpi: opts.dpi,
- name: opts.name,
- });
-
- if (!opts.quiet) {
- const c = summary.counts;
- process.stderr.write(
- [
- `ingest: ${summary.format} → ${path.relative(process.cwd(), summary.dir) || '.'}`,
- `assets: ${c.assetsResolved}/${c.imageFrames} resolved (staged ${summary.assetsWritten})`,
- 'bundle: ir.json, assets/, assets.manifest.json',
- summary.warnings.length ? `warnings: ${summary.warnings.length}` : null,
- ].filter(Boolean).join('\n') + '\n',
- );
- }
-
- process.exit(0);
-}
-
/** Resolve the theme output directory from --output, config.output, or default. */
function resolveOutDir(outputFlag, configOutput, slug) {
if (outputFlag) return path.resolve(outputFlag); // explicit theme dir
@@ -265,7 +193,6 @@ function printRootUsage() {
'',
'Commands:',
' pipeline indesign Convert an InDesign document to a WordPress FSE theme',
- ' pipeline ingest Stage an InDesign source as a generator-ready bundle',
'',
'Run `flavian pipeline indesign --help` for pipeline options.',
'',
@@ -280,9 +207,8 @@ function printPipelineUsage() {
'',
'Pipelines:',
' indesign Convert an .idml/.pdf (or IR JSON) to an FSE theme',
- ' ingest Stage a source as a generator-ready bundle (ir.json + assets/)',
'',
- 'Run `flavian pipeline --help` for options.',
+ 'Run `flavian pipeline indesign --help` for options.',
'',
].join('\n'),
);
@@ -328,32 +254,6 @@ function printIndesignUsage() {
);
}
-function printIngestUsage() {
- process.stderr.write(
- [
- 'Usage: flavian pipeline ingest --out-dir [options]',
- '',
- 'Extract an InDesign source into a generator-ready bundle: ir.json (the',
- 'intermediate IR the generator consumes), assets/ (image bytes pulled from the',
- 'source, named to match the generator), and assets.manifest.json.',
- '',
- 'The bundle feeds straight into generation:',
- ' flavian pipeline indesign /ir.json --asset-dir /assets',
- '',
- 'Arguments:',
- ' An .idml or .pdf file (or a pre-parsed IR JSON)',
- '',
- 'Options:',
- ' -o, --out-dir Bundle output directory (required)',
- ' --dpi DPI when parsing .idml/.pdf (default 96)',
- ' --name Override the document name',
- ' -q, --quiet Suppress the stderr summary',
- ' -h, --help Show this help',
- '',
- ].join('\n'),
- );
-}
-
main().catch((err) => {
fail(err.message);
process.exit(1);
diff --git a/package.json b/package.json
index 1a2180b..340fd90 100644
--- a/package.json
+++ b/package.json
@@ -22,11 +22,11 @@
},
"scripts": {
"flavian": "node bin/flavian.mjs",
- "pipeline:ingest": "node bin/flavian.mjs pipeline ingest",
"init": "node scripts/init.mjs",
"test:init": "node --test \"tests/init/**/*.test.mjs\"",
"test:pipeline": "pnpm --filter @flavian/pipeline test",
"pipeline:ingest": "node packages/pipeline/bin/ingest.mjs",
+ "pipeline:stage-assets": "node packages/pipeline/bin/stage-assets.mjs",
"test:canva-e2e": "node --test tests/canva-e2e/*.test.mjs",
"test:headless-e2e": "node --test tests/headless-e2e/*.test.mjs",
"visual:capture": "node tests/visual/capture.mjs",
diff --git a/packages/pipeline/bin/stage-assets.mjs b/packages/pipeline/bin/stage-assets.mjs
new file mode 100644
index 0000000..bb95529
--- /dev/null
+++ b/packages/pipeline/bin/stage-assets.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+// CLI: stage an InDesign source's image assets into a generator-ready bundle.
+//
+// flavian-stage-assets --out-dir [options]
+//
+// Parses the source to the IR, extracts its image bytes (embedded in the IDML
+// zip, or decoded from the PDF), and writes a bundle the theme generator can
+// consume directly: ir.json, assets/, assets.manifest.json. Then:
+//
+// flavian-generate-theme /ir.json --asset-dir /assets --out-dir theme
+//
+// Complements `flavian-ingest` (which derives the IR + content model): this
+// stage is the one that resolves and stages the document's image bytes.
+//
+// Options:
+// -o, --out-dir Bundle output directory (required). Created if missing.
+// --dpi DPI for unit normalization when parsing (default 96).
+// --name Override the document name.
+// -q, --quiet Suppress the stderr summary.
+// -h, --help Show this help.
+
+import { runAssetStage } from '../src/indesign/assets/stage.js';
+
+const args = process.argv.slice(2);
+const opts = { quiet: false };
+let input;
+
+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': case '-o': opts.outDir = wantsValue(arg); i += 1; break;
+ case '--dpi': opts.dpi = Number(wantsValue(arg)); i += 1; break;
+ case '--name': opts.name = wantsValue(arg); i += 1; break;
+ case '--quiet': case '-q': opts.quiet = true; break;
+ case '-h': case '--help': printUsage(); process.exit(0); break;
+ default:
+ if (!input && !arg.startsWith('-')) input = arg;
+ else { process.stderr.write(`Unknown argument: ${arg}\n`); printUsage(); process.exit(2); }
+ }
+}
+
+if (!input) {
+ process.stderr.write('an input is required (an .idml/.pdf file or an IR JSON)\n');
+ printUsage();
+ process.exit(2);
+}
+if (!opts.outDir) {
+ process.stderr.write('--out-dir is required\n');
+ printUsage();
+ process.exit(2);
+}
+
+try {
+ const summary = await runAssetStage({ input, outDir: opts.outDir, dpi: opts.dpi, name: opts.name });
+ if (!opts.quiet) {
+ const c = summary.counts;
+ process.stderr.write(
+ [
+ `stage-assets: ${summary.format} → ${opts.outDir}`,
+ `assets: ${c.assetsResolved}/${c.imageFrames} resolved (staged ${summary.assetsWritten})`,
+ `bundle: ir.json, assets/, assets.manifest.json`,
+ summary.warnings.length ? `warnings: ${summary.warnings.length}` : null,
+ ]
+ .filter(Boolean)
+ .join('\n') + '\n',
+ );
+ }
+ process.exit(0);
+} catch (err) {
+ process.stderr.write(`error: ${err.message}\n`);
+ process.exit(1);
+}
+
+function printUsage() {
+ process.stderr.write(
+ [
+ 'Usage: flavian-stage-assets --out-dir [options]',
+ '',
+ 'Options:',
+ ' -o, --out-dir Bundle output directory (required)',
+ ' --dpi DPI for unit normalization when parsing (default 96)',
+ ' --name Override the document name',
+ ' -q, --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 51eb64f..8b8a9d6 100644
--- a/packages/pipeline/package.json
+++ b/packages/pipeline/package.json
@@ -12,13 +12,13 @@
"flavian-ingest": "./bin/ingest.mjs",
"flavian-parse-idml": "./bin/parse-idml.mjs",
"flavian-parse-pdf": "./bin/parse-pdf.mjs",
- "flavian-ingest": "./bin/ingest.mjs",
+ "flavian-stage-assets": "./bin/stage-assets.mjs",
"flavian-map-tokens": "./bin/map-tokens.mjs",
"flavian-generate-theme": "./bin/generate-theme.mjs"
},
"scripts": {
"test": "node --test",
- "ingest": "node bin/ingest.mjs"
+ "stage-assets": "node bin/stage-assets.mjs"
},
"dependencies": {
"fast-xml-parser": "^5.7.0",
diff --git a/packages/pipeline/src/indesign/assets/stage.js b/packages/pipeline/src/indesign/assets/stage.js
new file mode 100644
index 0000000..e7db12d
--- /dev/null
+++ b/packages/pipeline/src/indesign/assets/stage.js
@@ -0,0 +1,211 @@
+// Asset-staging stage: a source document (.idml/.pdf) → a generator-ready asset
+// bundle on disk. It complements the `ingest` stage — where `ingest` derives
+// the IR plus a semantic content model, this stage does the one thing neither
+// the parser nor the generator did before: resolve a document's *image bytes*
+// (embedded in the IDML zip, or decoded out of a PDF) and stage them under the
+// exact filenames the theme generator will reference.
+//
+// Extract parse the source to the IR; pull image bytes from the package.
+// Transform join image frames ↔ bytes by href; compute staged paths via the
+// generator's own planAssets() so the names line up byte-for-byte;
+// synthesise a manifest with provenance + unresolved warnings.
+// Load write ir.json, assets/, assets.manifest.json.
+//
+// The bundle is directly consumable downstream:
+// flavian pipeline indesign /ir.json --asset-dir /assets
+//
+// The pure pieces (resolveAssets, buildAssetBundle) take an href→bytes Map so
+// they're source-agnostic and trivially testable; runAssetStage wires in the
+// format-specific extraction.
+
+import { promises as fs } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { parseIdmlBuffer } from '../parse-idml.js';
+import { parsePdfBuffer } from '../parse-pdf.js';
+import { planAssets } from '../generate/media.js';
+import { extractIdmlAssets } from './idml-assets.js';
+
+const BUNDLE_VERSION = 1;
+
+/**
+ * @typedef {Object} ResolvedAsset
+ * @property {string} frameId
+ * @property {string} [href]
+ * @property {boolean} embedded
+ * @property {string} name Staged filename, e.g. "spread-1-image-1.jpg".
+ * @property {string} relPath Theme-relative path, e.g. "assets/spread-1-image-1.jpg".
+ * @property {boolean} resolved
+ * @property {Uint8Array} [bytes] Present only when resolved.
+ */
+
+/**
+ * Join the IR's planned image assets to source bytes from a resolver.
+ *
+ * planAssets() is the generator's own naming pass, so the staged filenames here
+ * are byte-for-byte what generateTheme() will reference — which is what makes
+ * the emitted bundle directly consumable downstream.
+ *
+ * @param {import('../ir.js').DocumentIR} ir
+ * @param {Map} [resolver] href → bytes
+ * @returns {{ assets: ResolvedAsset[], counts: { resolved: number, unresolved: number }, warnings: import('../ir.js').ParseWarningIR[] }}
+ */
+export function resolveAssets(ir, resolver = new Map()) {
+ const { assets } = planAssets(ir);
+ const warnings = [];
+ let resolved = 0;
+
+ const out = assets.map((a) => {
+ const base = { frameId: a.frameId, href: a.href, embedded: a.embedded, name: a.name, relPath: a.relPath };
+ const hit = a.href ? resolver.get(a.href) : undefined;
+ if (hit) {
+ resolved += 1;
+ return { ...base, resolved: true, bytes: hit.bytes };
+ }
+ warnings.push({
+ code: 'asset-unresolved',
+ message: `Image frame ${a.frameId} (${a.href ?? 'no href'}) had no resolvable bytes; staged path ${a.relPath} reserved but no file written`,
+ context: { id: a.frameId },
+ });
+ return { ...base, resolved: false };
+ });
+
+ return { assets: out, counts: { resolved, unresolved: out.length - resolved }, warnings };
+}
+
+/**
+ * Build the in-memory asset bundle: the validated IR, the resolved assets (with
+ * bytes), and a JSON-serialisable manifest (provenance only, no bytes).
+ *
+ * @param {{ ir: import('../ir.js').DocumentIR, resolver?: Map, format?: string }} input
+ * @returns {{ ir: import('../ir.js').DocumentIR, assets: ResolvedAsset[], manifest: object }}
+ */
+export function buildAssetBundle({ ir, resolver = new Map(), format = 'unknown' }) {
+ const { assets, counts, warnings } = resolveAssets(ir, resolver);
+
+ const manifest = {
+ bundleVersion: BUNDLE_VERSION,
+ irVersion: ir.irVersion ?? 1,
+ source: { format, name: ir.meta?.name },
+ irPath: 'ir.json',
+ assetDir: 'assets',
+ counts: {
+ spreads: (ir.spreads ?? []).length,
+ imageFrames: assets.length,
+ assetsResolved: counts.resolved,
+ assetsUnresolved: counts.unresolved,
+ },
+ // Provenance only — raw bytes are staged as files, never inlined here.
+ assets: assets.map(({ frameId, href, embedded, relPath, resolved }) => ({ frameId, href, embedded, relPath, resolved })),
+ // IR parse warnings + asset-resolution warnings together, so the manifest
+ // is the single place a caller has to look.
+ warnings: [...(ir.warnings ?? []), ...warnings],
+ };
+
+ return { ir, assets, manifest };
+}
+
+/**
+ * Persist a bundle to disk: ir.json, assets/, assets.manifest.json.
+ *
+ * @param {{ ir: object, assets: ResolvedAsset[], manifest: object }} bundle
+ * @param {string} outDir
+ * @returns {Promise<{ dir: string, irPath: string, manifestPath: string, assetDir: string, assetsWritten: number }>}
+ */
+export async function writeAssetBundle(bundle, outDir) {
+ await fs.mkdir(outDir, { recursive: true });
+
+ const irPath = path.join(outDir, 'ir.json');
+ await fs.writeFile(irPath, `${JSON.stringify(bundle.ir, null, 2)}\n`);
+
+ let assetsWritten = 0;
+ for (const asset of bundle.assets) {
+ if (!asset.resolved || !asset.bytes) continue;
+ const dest = path.join(outDir, asset.relPath);
+ await fs.mkdir(path.dirname(dest), { recursive: true });
+ await fs.writeFile(dest, asset.bytes);
+ assetsWritten += 1;
+ }
+
+ const manifestPath = path.join(outDir, 'assets.manifest.json');
+ await fs.writeFile(manifestPath, `${JSON.stringify(bundle.manifest, null, 2)}\n`);
+
+ return { dir: outDir, irPath, manifestPath, assetDir: path.join(outDir, 'assets'), assetsWritten };
+}
+
+/**
+ * Orchestrate a full asset-staging run from a source file path to a bundle on
+ * disk. Detects .idml/.pdf by extension; anything else is treated as a
+ * pre-parsed IR JSON (which carries no source bytes, so its frames stage as
+ * 'unresolved').
+ *
+ * @param {{ input: string, outDir: string, dpi?: number, name?: string }} opts
+ * @returns {Promise<{ format: string, dir: string, irPath: string, manifestPath: string, assetDir: string, assetsWritten: number, counts: object, warnings: object[] }>}
+ */
+export async function runAssetStage({ input, outDir, dpi, name } = {}) {
+ if (!input) throw new Error('runAssetStage: an input path is required');
+ if (!outDir) throw new Error('runAssetStage: an outDir is required');
+
+ const parseOpts = {};
+ if (dpi !== undefined) parseOpts.dpi = dpi;
+ if (name !== undefined) parseOpts.name = name;
+
+ let ir;
+ let resolver = new Map();
+ let format;
+
+ if (/\.idml$/i.test(input)) {
+ format = 'idml';
+ const bytes = await fs.readFile(input);
+ ir = parseIdmlBuffer(bytes, parseOpts);
+ resolver = extractIdmlAssets(bytes);
+ } else if (/\.pdf$/i.test(input)) {
+ format = 'pdf';
+ const bytes = await fs.readFile(input);
+ // parse-pdf only decodes image bytes when given a cache dir; round-trip
+ // through a temp dir, then read them back keyed by the same href.
+ const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flavian-stage-pdf-'));
+ try {
+ ir = await parsePdfBuffer(bytes, { ...parseOpts, assetCacheDir: cacheDir });
+ resolver = await resolverFromCacheDir(ir, cacheDir);
+ } finally {
+ await fs.rm(cacheDir, { recursive: true, force: true });
+ }
+ } else {
+ format = 'ir';
+ ir = JSON.parse(await fs.readFile(input, 'utf8'));
+ }
+
+ const bundle = buildAssetBundle({ ir, resolver, format });
+ const written = await writeAssetBundle(bundle, outDir);
+
+ return { format, ...written, counts: bundle.manifest.counts, warnings: bundle.manifest.warnings };
+}
+
+/**
+ * Build an href→bytes resolver from a directory the parser persisted images
+ * into. parse-pdf writes each image to /, so we read them
+ * back keyed by that same href.
+ *
+ * @param {import('../ir.js').DocumentIR} ir
+ * @param {string} cacheDir
+ * @returns {Promise