Files
open-design-crew[bot] 7a37c1462d feat(export): add reliable standalone HTML export (#6855)
* feat(export): add standalone HTML export

Bundle project-local HTML, CSS, JavaScript, images, fonts, workers,
and iframe dependencies into one offline-safe file.

Expose the same contract through the web download flow and od CLI,
with structured failures and bounded output assembly.

* fix(daemon): honor standalone source URL semantics

Resolve document assets against local base href values, reject browser-invalid TypeScript and JSX sources with a structured 422, and count unquoted first-level resources before graph traversal.

Validated with daemon tests, workspace typecheck, and guard.

* fix(daemon): handle standalone data scripts and CSS URL tokens

Preserve non-JavaScript script payloads as data blocks and scan CSS values without treating strings or comments as project dependencies.

Validated with focused and full daemon tests, workspace typecheck, and guard.

* fix(daemon): classify standalone export resources correctly

Preserve non-HTML iframe MIME types, inline only resource-bearing link relations, and propagate unexpected Vite dist read failures instead of silently falling back.

Validated with focused export tests, the full daemon suite, guard, and workspace typecheck.

* fix(export): preserve version and stylesheet semantics

Hide standalone HTML export for historical versions until dependency snapshots are available, avoiding an action that always fails.

Keep disabled and alternate stylesheets as embedded link elements so standalone export preserves activation semantics. Validated with focused red/green coverage, the full web suite, pnpm guard, and pnpm typecheck.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
2026-08-14 07:21:21 +00:00

118 lines
4.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { EXPORT_FORMATS } from '@open-design/contracts';
import {
buildExportCliResultEnvelope,
buildExportCliRequestBody,
resolveExportCliDeckMode,
} from '../src/export-cli-request.js';
import { exportRoutePath } from '../src/export-cli-routing.js';
describe('exportRoutePath', () => {
it('routes pdf to the raster /export/pdf-image path, not the vector /export route', () => {
// Regression: the CLI used to send --format pdf to the generic `/export`
// route, which renders vector PDF via printToPDF() and drops CJK glyphs in
// the packaged runtime. The UI uses the raster screenshot PDF, so the CLI
// must match it.
expect(exportRoutePath('pdf')).toBe('export/pdf-image');
expect(exportRoutePath('pdf')).not.toBe('export');
});
it('routes pptx and image to their screenshot-renderer routes', () => {
expect(exportRoutePath('pptx')).toBe('export/pptx');
expect(exportRoutePath('image')).toBe('export/image');
});
it('routes html to the headless standalone bundler', () => {
expect(exportRoutePath('html')).toBe('export/html');
});
it('every supported export format maps to a dedicated route', () => {
for (const format of EXPORT_FORMATS) {
const route = exportRoutePath(format);
expect(route.startsWith('export/')).toBe(true);
expect(route).not.toBe('export');
}
});
});
describe('buildExportCliResultEnvelope', () => {
it('emits path plus the deprecated out alias for JSON output', () => {
const result = buildExportCliResultEnvelope({
path: '/tmp/deck.pptx',
bytes: 123,
format: 'pptx',
});
expect(result).toEqual({
ok: true,
path: '/tmp/deck.pptx',
out: '/tmp/deck.pptx',
bytes: 123,
format: 'pptx',
});
});
});
describe('buildExportCliRequestBody', () => {
it('omits deck for pdf/image when --deck is not provided so the daemon can detect decks', () => {
expect(buildExportCliRequestBody({ fileName: 'deck.html', format: 'pdf' })).toEqual({
fileName: 'deck.html',
});
expect(buildExportCliRequestBody({ fileName: 'deck.html', format: 'image', imageFormat: 'jpeg' })).toEqual({
fileName: 'deck.html',
imageFormat: 'jpeg',
});
});
it('serializes explicit deck mode for pdf/image and always for pptx', () => {
expect(buildExportCliRequestBody({ fileName: 'deck.html', format: 'pdf', deck: true })).toEqual({
fileName: 'deck.html',
deck: true,
});
expect(buildExportCliRequestBody({ fileName: 'deck.html', format: 'pptx' })).toEqual({
fileName: 'deck.html',
deck: true,
});
});
it('serializes explicit page mode for non-deck .slide pages', () => {
const pageMode = resolveExportCliDeckMode({ format: 'pdf', page: true });
expect(pageMode).toBe(false);
if (pageMode !== false) throw new Error('expected explicit page mode');
const noDeckMode = resolveExportCliDeckMode({ format: 'image', noDeck: true });
expect(noDeckMode).toBe(false);
if (noDeckMode !== false) throw new Error('expected explicit no-deck mode');
expect(
buildExportCliRequestBody({ fileName: 'landing-with-carousel.html', format: 'pdf', deck: pageMode }),
).toEqual({
fileName: 'landing-with-carousel.html',
deck: false,
});
expect(
buildExportCliRequestBody({
fileName: 'landing-with-testimonials.html',
format: 'image',
deck: noDeckMode,
imageFormat: 'jpeg',
}),
).toEqual({
fileName: 'landing-with-testimonials.html',
deck: false,
imageFormat: 'jpeg',
});
});
it('rejects conflicting or impossible CLI deck/page modes', () => {
expect(() => resolveExportCliDeckMode({ format: 'pdf', deck: true, page: true })).toThrow(
/cannot be combined/,
);
expect(() => resolveExportCliDeckMode({ format: 'pptx', page: true })).toThrow(
/not valid with --format pptx/,
);
expect(() => resolveExportCliDeckMode({ format: 'html', deck: true })).toThrow(
/not valid with --format html/,
);
});
});