// Phase 6.5 T5 — HTML adapter (standalone bundler).
//
// Renders the target through Playwright, emits a self-contained `index.html`
// with all stylesheets inlined and every asset (images, fonts, CSS url()s,
// SVG defs) embedded as data: URIs — see bin/_html-playwright.mjs. Output is
// always a ZIP per
// plan T5 ("Always zipped (multi-file)"), even for a single artboard,
// because the consuming workflow typically wants one bundle per export.
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import JSZip from 'jszip';
import { exportShimPath, runShim } from './_runtime.ts';
import {
canvasShellUrl,
type ExportContext,
type ExportHooks,
type ExportOptions,
type ExportResult,
} from './index.ts';
import type { Target } from './scope.ts';
// DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
const HTML_PLAYWRIGHT = exportShimPath('_html-playwright.mjs');
async function captureHtml(
target: Extract,
ctx: ExportContext,
outDir: string,
timeoutSec: number,
hooks?: ExportHooks
): Promise {
const args = [
HTML_PLAYWRIGHT,
'--url',
canvasShellUrl(ctx, target.file),
'--selector',
target.cssPath,
'--timeout',
String(timeoutSec),
];
if (target.multi) {
args.push('--multi', '1', '--out-dir', outDir);
} else {
// Widen to the enclosing artboard only when scope.ts requested it; a
// `selection` target serializes the element exactly.
if (target.widen) args.push('--widen-to-artboard', '1');
args.push('--out', path.join(outDir, `${target.canvasSlug}.html`));
}
return runShim(args, {
cwd: path.dirname(HTML_PLAYWRIGHT),
signal: hooks?.signal,
onProgress: hooks?.onProgress,
});
}
export async function run(
targets: Target[],
options: ExportOptions,
ctx: ExportContext,
hooks?: ExportHooks
): Promise {
if (!targets.length) {
return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
}
const elementTargets = targets.filter(
(t): t is Extract => t.kind === 'element'
);
if (!elementTargets.length) {
throw new Error('html adapter requires element targets (got file-tree)');
}
const timeoutSec = (options.timeoutSec as number | undefined) ?? 8;
const tmp = mkdtempSync(path.join(tmpdir(), 'maude-html-'));
try {
const written: string[] = [];
for (let i = 0; i < elementTargets.length; i += 1) {
const paths = await captureHtml(elementTargets[i], ctx, tmp, timeoutSec, hooks);
written.push(...paths);
hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
}
if (!written.length) {
return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
}
const zip = new JSZip();
for (const p of written) {
zip.file(path.basename(p), new Uint8Array(readFileSync(p)));
}
const zipBytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
return {
filename: `${baseSlug}.html.zip`,
contentType: 'application/zip',
body: zipBytes,
};
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}