diff --git a/bin/flavian.mjs b/bin/flavian.mjs
index ba3cbbb..76c5375 100755
--- a/bin/flavian.mjs
+++ b/bin/flavian.mjs
@@ -2,6 +2,7 @@
// 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.
@@ -36,13 +37,19 @@ async function main() {
process.exit(command ? 0 : 2);
}
- if (command !== 'indesign') {
- fail(`Unknown pipeline: ${command}`);
- printPipelineUsage();
- process.exit(2);
+ if (command === 'indesign') {
+ await runIndesign(rest);
+ return;
+ }
+
+ if (command === 'ingest') {
+ await runIngestCommand(rest);
+ return;
}
- await runIndesign(rest);
+ fail(`Unknown pipeline: ${command}`);
+ printPipelineUsage();
+ process.exit(2);
}
async function runIndesign(args) {
@@ -125,6 +132,71 @@ 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
@@ -193,6 +265,7 @@ 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.',
'',
@@ -207,8 +280,9 @@ 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 indesign --help` for options.',
+ 'Run `flavian pipeline --help` for options.',
'',
].join('\n'),
);
@@ -254,6 +328,32 @@ 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 3fbf818..1a2180b 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
},
"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",
diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json
index f37333f..51eb64f 100644
--- a/packages/pipeline/package.json
+++ b/packages/pipeline/package.json
@@ -12,11 +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-map-tokens": "./bin/map-tokens.mjs",
"flavian-generate-theme": "./bin/generate-theme.mjs"
},
"scripts": {
- "test": "node --test"
+ "test": "node --test",
+ "ingest": "node bin/ingest.mjs"
},
"dependencies": {
"fast-xml-parser": "^5.7.0",
diff --git a/packages/pipeline/src/indesign/assets/idml-assets.js b/packages/pipeline/src/indesign/assets/idml-assets.js
new file mode 100644
index 0000000..84823a9
--- /dev/null
+++ b/packages/pipeline/src/indesign/assets/idml-assets.js
@@ -0,0 +1,25 @@
+// Resolve image-frame assets stored inside an IDML package. Image frames whose
+// href uses the `file:` scheme point at a member of the IDML zip; this helper
+// unzips once and returns a Map from that href to its bytes + extension, ready
+// for the output generator to stage into the theme's assets/images/.
+//
+// Real-world IDML can also embed images as base64 inside the spread XML; that
+// path is out of scope here (v1 resolves package-linked assets only).
+
+import { unzipSync } from 'fflate';
+
+/**
+ * @param {Uint8Array|ArrayBuffer|Buffer} buffer Raw .idml bytes.
+ * @returns {Map} href → asset.
+ */
+export function extractIdmlAssets(buffer) {
+ const files = unzipSync(buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer));
+ const map = new Map();
+ for (const [name, bytes] of Object.entries(files)) {
+ const ext = (name.split('.').pop() ?? '').toLowerCase();
+ if (/^(jpe?g|png|gif|webp|svg|tiff?)$/.test(ext)) {
+ map.set(`file:${name}`, { bytes, ext });
+ }
+ }
+ return map;
+}
diff --git a/packages/pipeline/src/indesign/index.js b/packages/pipeline/src/indesign/index.js
index ba0e963..4eeab9d 100644
--- a/packages/pipeline/src/indesign/index.js
+++ b/packages/pipeline/src/indesign/index.js
@@ -6,3 +6,5 @@ export { WarningCollector } from './warnings.js';
export { lengthToPx, ptToPx, roundPx } from './units.js';
export { mapTokens } from './map/index.js';
export { generateTheme } from './generate/index.js';
+export { extractIdmlAssets } from './assets/idml-assets.js';
+export { resolveAssets, buildIngestBundle, writeIngestBundle, runIngest } from './ingest/index.js';
diff --git a/packages/pipeline/tests/indesign/assets-idml.test.mjs b/packages/pipeline/tests/indesign/assets-idml.test.mjs
new file mode 100644
index 0000000..bd4ea35
--- /dev/null
+++ b/packages/pipeline/tests/indesign/assets-idml.test.mjs
@@ -0,0 +1,26 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { extractIdmlAssets } from '../../src/indesign/assets/idml-assets.js';
+import { buildIdml } from './helpers/build-idml.js';
+
+test('extracts embedded asset bytes by href', () => {
+ const idml = buildIdml({
+ spreads: [{ id: 'sp', pages: [{ id: 'p', bounds: [0, 0, 600, 400] }],
+ frames: [{ kind: 'image', id: 'img', bounds: [0, 0, 600, 400], href: 'file:Resources/hero.jpg' }] }],
+ extraFiles: { 'Resources/hero.jpg': '\x89PNG\x01\x02\x03' },
+ });
+ const map = extractIdmlAssets(idml);
+ assert.ok(map.has('file:Resources/hero.jpg'));
+ assert.equal(map.get('file:Resources/hero.jpg').ext, 'jpg');
+ assert.ok(map.get('file:Resources/hero.jpg').bytes instanceof Uint8Array);
+});
+
+test('only image-typed entries are included (no XML/manifest noise)', () => {
+ const idml = buildIdml({
+ spreads: [{ id: 'sp', pages: [{ id: 'p', bounds: [0, 0, 600, 400] }], frames: [] }],
+ });
+ const map = extractIdmlAssets(idml);
+ for (const href of map.keys()) {
+ assert.match(href, /\.(jpe?g|png|gif|webp|svg|tiff?)$/i);
+ }
+});