From 3d84d5f80a4ace6d7195de2c09acf82d1ec8265b Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 24 Jun 2026 17:00:38 -0400 Subject: [PATCH] feat(pipeline): add asset-staging stage and repair #152 merge fallout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land a working `stage-assets` ETL stage that resolves an InDesign source's image bytes and stages them into a generator-ready bundle (ir.json + assets/ + assets.manifest.json), and repair the breakage the #152 conflict-resolution merge left on main. The stage (Extract -> Transform -> Load): - resolveAssets/buildAssetBundle join IR image frames to an href->bytes resolver (pure, source-agnostic); runAssetStage wires in IDML zip extraction and the PDF image cache. Staged filenames come from the generator's own planAssets(), so the bundle feeds straight into generation: flavian-stage-assets brochure.idml --out-dir build/brochure flavian pipeline indesign build/brochure/ir.json --asset-dir build/brochure/assets - Runnable via `pnpm pipeline:stage-assets` and the `flavian-stage-assets` bin. Repairs from the #152 merge: - src/indesign/index.js re-exported four names from ./ingest/index.js that it never exported, so importing the package barrel threw at load. Repointed to ./assets/stage.js, which actually provides them; the orphaned idml-assets.js is now wired into a real stage. - Removed the crashing `flavian pipeline ingest` subcommand (it imported a non-existent runIngest); main exposes ingest via the flavian-ingest bin. - De-duplicated pipeline:ingest (root) and flavian-ingest (package) keys. Adds a barrel-import test that fails on any dangling re-export — the guard that was missing when the bad merge shipped green. Redence proof token (Pipelines / Data-ETL): RDNC-NWYQDP Closes #154 Co-Authored-By: Claude Opus 4.8 --- bin/flavian.mjs | 112 +--------- package.json | 2 +- packages/pipeline/bin/stage-assets.mjs | 98 ++++++++ packages/pipeline/package.json | 4 +- .../pipeline/src/indesign/assets/stage.js | 211 ++++++++++++++++++ packages/pipeline/src/indesign/index.js | 2 +- .../pipeline/tests/indesign/barrel.test.mjs | 31 +++ .../tests/indesign/stage-assets.test.mjs | 149 +++++++++++++ 8 files changed, 499 insertions(+), 110 deletions(-) create mode 100644 packages/pipeline/bin/stage-assets.mjs create mode 100644 packages/pipeline/src/indesign/assets/stage.js create mode 100644 packages/pipeline/tests/indesign/barrel.test.mjs create mode 100644 packages/pipeline/tests/indesign/stage-assets.test.mjs 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>} + */ +async function resolverFromCacheDir(ir, cacheDir) { + const map = new Map(); + for (const spread of ir.spreads ?? []) { + for (const frame of spread.frames ?? []) { + if (frame.kind !== 'image' || !frame.href) continue; + try { + const bytes = await fs.readFile(path.join(cacheDir, frame.href)); + const ext = (frame.href.split('.').pop() ?? 'png').toLowerCase(); + map.set(frame.href, { bytes: new Uint8Array(bytes), ext }); + } catch { + // Not every image frame is persisted (e.g. decode failures); skip it + // and let the frame fall through to 'unresolved'. + } + } + } + return map; +} diff --git a/packages/pipeline/src/indesign/index.js b/packages/pipeline/src/indesign/index.js index 4eeab9d..5e24373 100644 --- a/packages/pipeline/src/indesign/index.js +++ b/packages/pipeline/src/indesign/index.js @@ -7,4 +7,4 @@ 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'; +export { resolveAssets, buildAssetBundle, writeAssetBundle, runAssetStage } from './assets/stage.js'; diff --git a/packages/pipeline/tests/indesign/barrel.test.mjs b/packages/pipeline/tests/indesign/barrel.test.mjs new file mode 100644 index 0000000..92abb18 --- /dev/null +++ b/packages/pipeline/tests/indesign/barrel.test.mjs @@ -0,0 +1,31 @@ +// Guards the package barrel (src/indesign/index.js) against dangling re-exports. +// +// A static `export { x } from './m.js'` where m.js doesn't export x is a link +// error that throws the moment anything imports the barrel — but nothing in the +// suite imported it, so a bad merge once shipped a dangling re-export to main +// with CI still green. This test is the "anything": importing the namespace +// below fails to load if any advertised name is missing, and the assertions +// document the contract the barrel promises. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import * as api from '../../src/indesign/index.js'; + +test('the indesign barrel resolves every advertised function export', () => { + const expected = [ + 'parseIdml', 'parseIdmlBuffer', + 'parsePdf', 'parsePdfBuffer', + 'ingestSource', 'ingestBuffer', 'toArtifact', 'detectFormat', 'extractContent', + 'mapTokens', 'generateTheme', + 'extractIdmlAssets', 'resolveAssets', 'buildAssetBundle', 'writeAssetBundle', 'runAssetStage', + ]; + for (const name of expected) { + assert.equal(typeof api[name], 'function', `barrel must export a live '${name}' binding`); + } +}); + +test('the indesign barrel exposes the ir schema namespace', () => { + assert.equal(typeof api.ir, 'object'); + assert.equal(typeof api.ir.Document?.parse, 'function', 'ir.Document must be the zod schema'); +}); diff --git a/packages/pipeline/tests/indesign/stage-assets.test.mjs b/packages/pipeline/tests/indesign/stage-assets.test.mjs new file mode 100644 index 0000000..88078dc --- /dev/null +++ b/packages/pipeline/tests/indesign/stage-assets.test.mjs @@ -0,0 +1,149 @@ +// The asset-staging stage: source (.idml/.pdf) → a generator-ready bundle on +// disk (ir.json + assets/ + assets.manifest.json). These tests pin three things: +// 1. the transform that joins IR image frames to extracted bytes by href, +// 2. the manifest shape (provenance, no bytes), and +// 3. an end-to-end IDML run whose staged filenames line up byte-for-byte with +// what the generator (planAssets) will reference — i.e. the bundle is +// directly consumable by `generate-theme --asset-dir `. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Document } from '../../src/indesign/ir.js'; +import { resolveAssets, buildAssetBundle, runAssetStage } from '../../src/indesign/assets/stage.js'; +import { generateTheme } from '../../src/indesign/generate/index.js'; +import { buildIdml } from './helpers/build-idml.js'; + +/** A one-spread IR with one resolvable image frame and one dangling one. */ +function twoImageIr() { + return Document.parse({ + dpi: 96, + swatches: [], + fonts: [], + styles: [], + stories: [], + masterSpreads: [], + spreads: [ + { + id: 'sp', + source: 'Spreads/Spread_sp.xml', + pages: [{ id: 'p', bounds: { x: 0, y: 0, width: 600, height: 400 } }], + frames: [ + { kind: 'image', id: 'img', bounds: { x: 0, y: 0, width: 600, height: 400 }, href: 'file:Resources/hero.jpg', embedded: true }, + { kind: 'image', id: 'img2', bounds: { x: 0, y: 0, width: 100, height: 100 }, href: 'file:Links/missing.png', embedded: true }, + ], + }, + ], + }); +} + +function resolverWithHero() { + return new Map([ + ['file:Resources/hero.jpg', { bytes: new TextEncoder().encode('JPEGDATA-hero'), ext: 'jpg' }], + ]); +} + +test('resolveAssets joins image frames to extracted bytes by href', () => { + const { assets, counts, warnings } = resolveAssets(twoImageIr(), resolverWithHero()); + + assert.equal(assets.length, 2); + + const hero = assets.find((a) => a.frameId === 'img'); + assert.equal(hero.resolved, true); + assert.equal(hero.relPath, 'assets/spread-1-image-1.jpg'); + assert.ok(hero.bytes instanceof Uint8Array); + assert.deepEqual(hero.bytes, new TextEncoder().encode('JPEGDATA-hero')); + + const missing = assets.find((a) => a.frameId === 'img2'); + assert.equal(missing.resolved, false); + assert.equal(missing.relPath, 'assets/spread-1-image-2.png'); + assert.equal(missing.bytes, undefined); + + assert.deepEqual(counts, { resolved: 1, unresolved: 1 }); + assert.equal(warnings.filter((w) => w.code === 'asset-unresolved').length, 1); +}); + +test('buildAssetBundle records provenance in the manifest without embedding bytes', () => { + const bundle = buildAssetBundle({ ir: twoImageIr(), resolver: resolverWithHero(), format: 'idml' }); + + assert.equal(bundle.manifest.bundleVersion, 1); + assert.equal(bundle.manifest.source.format, 'idml'); + assert.deepEqual(bundle.manifest.counts, { + spreads: 1, + imageFrames: 2, + assetsResolved: 1, + assetsUnresolved: 1, + }); + + // Manifest entries are provenance only — bytes are written separately. + const entry = bundle.manifest.assets.find((a) => a.frameId === 'img'); + assert.deepEqual(entry, { + frameId: 'img', + href: 'file:Resources/hero.jpg', + embedded: true, + relPath: 'assets/spread-1-image-1.jpg', + resolved: true, + }); + assert.ok(!('bytes' in entry), 'manifest must not carry raw bytes'); + + // The in-memory bundle keeps bytes so the writer can stage them. + const live = bundle.assets.find((a) => a.frameId === 'img'); + assert.ok(live.bytes instanceof Uint8Array); + + // The unresolved frame is surfaced as a warning in the manifest. + assert.ok(bundle.manifest.warnings.some((w) => w.code === 'asset-unresolved')); +}); + +test('runAssetStage writes a generator-ready bundle from an .idml (assets aligned with the generator)', async () => { + const heroBytes = 'JPEGDATA-hero-frame'; + const idml = buildIdml({ + name: 'Stage Fixture', + spreads: [ + { + id: 'sp1', + pages: [{ id: 'pg1', bounds: [0, 0, 600, 400] }], + frames: [{ kind: 'image', id: 'hero', bounds: [0, 0, 600, 400], href: 'file:Resources/hero.jpg' }], + }, + ], + extraFiles: { 'Resources/hero.jpg': heroBytes }, + }); + + const work = await fs.mkdtemp(path.join(os.tmpdir(), 'flavian-stage-')); + const idmlPath = path.join(work, 'fixture.idml'); + const outDir = path.join(work, 'bundle'); + + try { + await fs.writeFile(idmlPath, idml); + + const summary = await runAssetStage({ input: idmlPath, outDir }); + assert.equal(summary.format, 'idml'); + assert.equal(summary.counts.assetsResolved, 1); + + // ir.json round-trips through the schema — it is the intermediate JSON + // the theme generator consumes. + const irJson = JSON.parse(await fs.readFile(path.join(outDir, 'ir.json'), 'utf8')); + const reparsed = Document.parse(irJson); + assert.equal(reparsed.spreads.length, 1); + + // The extracted image landed at the generator's staged filename. + const stagedRel = 'assets/spread-1-image-1.jpg'; + const staged = await fs.readFile(path.join(outDir, stagedRel)); + assert.deepEqual(new Uint8Array(staged), new TextEncoder().encode(heroBytes)); + + // Manifest written and consistent. + const manifest = JSON.parse(await fs.readFile(path.join(outDir, 'assets.manifest.json'), 'utf8')); + assert.equal(manifest.counts.assetsResolved, 1); + assert.equal(manifest.assets[0].relPath, stagedRel); + + // The payoff: feeding ir.json to the generator references the exact path + // the stage staged — so `--asset-dir /assets` resolves it. + const generated = generateTheme(irJson); + const planned = generated.assets.find((a) => a.frameId === 'hero'); + assert.equal(planned.relPath, stagedRel); + } finally { + await fs.rm(work, { recursive: true, force: true }); + } +});