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
112 changes: 106 additions & 6 deletions bin/flavian.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Flavian CLI — the first-class entry point for Flavian's conversion pipelines.
//
// flavian pipeline indesign <input> [options]
// flavian pipeline ingest <input> --out-dir <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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -193,6 +265,7 @@ function printRootUsage() {
'',
'Commands:',
' pipeline indesign <input> Convert an InDesign document to a WordPress FSE theme',
' pipeline ingest <input> Stage an InDesign source as a generator-ready bundle',
'',
'Run `flavian pipeline indesign --help` for pipeline options.',
'',
Expand All @@ -207,8 +280,9 @@ function printPipelineUsage() {
'',
'Pipelines:',
' indesign <input> Convert an .idml/.pdf (or IR JSON) to an FSE theme',
' ingest <input> Stage a source as a generator-ready bundle (ir.json + assets/)',
'',
'Run `flavian pipeline indesign --help` for options.',
'Run `flavian pipeline <name> --help` for options.',
'',
].join('\n'),
);
Expand Down Expand Up @@ -254,6 +328,32 @@ function printIndesignUsage() {
);
}

function printIngestUsage() {
process.stderr.write(
[
'Usage: flavian pipeline ingest <input> --out-dir <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 <dir>/ir.json --asset-dir <dir>/assets',
'',
'Arguments:',
' <input> An .idml or .pdf file (or a pre-parsed IR JSON)',
'',
'Options:',
' -o, --out-dir <dir> Bundle output directory (required)',
' --dpi <n> DPI when parsing .idml/.pdf (default 96)',
' --name <str> 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);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/pipeline/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions packages/pipeline/src/indesign/assets/idml-assets.js
Original file line number Diff line number Diff line change
@@ -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<string, { bytes: Uint8Array, ext: string }>} 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;
}
2 changes: 2 additions & 0 deletions packages/pipeline/src/indesign/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
26 changes: 26 additions & 0 deletions packages/pipeline/tests/indesign/assets-idml.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading