Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
24a5b4e
feat(html): enable interactive export runtime
project820 Jul 18, 2026
5386a12
fix(html): plumb interactive export mode
project820 Jul 18, 2026
f57e7ed
fix(html): finalize interactive runtime by mode
project820 Jul 18, 2026
e81e5a9
fix(html): scope runtime sha manifest patch
project820 Jul 18, 2026
a8c248e
fix(html): preserve inline head scripts in exports
project820 Jul 18, 2026
a6a81a3
fix(html): preserve relocated script and theme scope order
project820 Jul 18, 2026
fa21348
fix(html): detect layered authored theme palettes
project820 Jul 18, 2026
7979921
fix(html): scope functional theme selectors
project820 Jul 18, 2026
2806292
fix(html): require data-theme for fallback suppression
project820 Jul 18, 2026
571455f
fix(html): honor active media themes and localize runtime
project820 Jul 18, 2026
f13be82
fix(html): preserve custom property case and top-level slides
project820 Jul 18, 2026
764c59e
fix(html): measure injected runtime before quarantine
project820 Jul 18, 2026
6db5e9b
fix(html): preserve runtime locale during generation
project820 Jul 18, 2026
c92d9cc
fix(html): preserve slide display and input selectors
project820 Jul 18, 2026
4f807d9
fix(html): preserve per-theme fallback and input bounds
project820 Jul 18, 2026
d550423
fix(html): preserve functional body theme palettes and step any
project820 Jul 19, 2026
25c06d8
fix(html): restore slides for print output
project820 Jul 19, 2026
07eebb8
fix(html): preserve interactive controls and runtime boundary
project820 Jul 19, 2026
37ba7c6
fix(html): restore active slide and form metadata
project820 Jul 19, 2026
1914de0
fix(html): preserve CSP literals and structured form names
project820 Jul 19, 2026
2e9277f
fix(html): preserve valued boolean attributes
project820 Jul 19, 2026
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
20 changes: 13 additions & 7 deletions scripts/html-export-direct-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,23 +80,28 @@ async function loadModules() {
const distShell = resolve(REPO, 'dist/main/html-export-shell.js');
const distRaster = resolve(REPO, 'dist/main/raster-validate.js');
let bundleSanitizedHtml;
let injectHtmlExportRuntime;
let validateRasterHeader;
if (existsSync(distShell)) {
const distRuntime = resolve(REPO, 'dist/main/html-export-runtime.js');
if (existsSync(distShell) && existsSync(distRuntime)) {
log('loading shell from dist/main');
({ bundleSanitizedHtml } = require(distShell));
({ injectHtmlExportRuntime } = require(distRuntime));
} else {
log('dist/main shell missing — bundling TS via esbuild');
const { mod } = await esbuildToCjs('src/main/html-export-shell.ts');
bundleSanitizedHtml = mod.bundleSanitizedHtml;
const shell = await esbuildToCjs('src/main/html-export-shell.ts');
const runtime = await esbuildToCjs('src/main/html-export-runtime.ts');
bundleSanitizedHtml = shell.mod.bundleSanitizedHtml;
injectHtmlExportRuntime = runtime.mod.injectHtmlExportRuntime;
}
if (existsSync(distRaster)) {
({ validateRasterHeader } = require(distRaster));
} else {
const { mod } = await esbuildToCjs('src/main/raster-validate.ts');
validateRasterHeader = mod.validateRasterHeader;
}
if (typeof bundleSanitizedHtml !== 'function') throw new Error('bundleSanitizedHtml export missing');
return { bundleSanitizedHtml, validateRasterHeader };
if (typeof bundleSanitizedHtml !== 'function' || typeof injectHtmlExportRuntime !== 'function') throw new Error('HTML export shell/runtime export missing');
return { bundleSanitizedHtml, injectHtmlExportRuntime, validateRasterHeader };
}

// ---- Representative finalized payloads (app-authored, shell-shaped) ----------
Expand Down Expand Up @@ -182,7 +187,7 @@ async function measure(BrowserWindow, session, html, viewport) {
}

async function run() {
const { bundleSanitizedHtml, validateRasterHeader } = await loadModules();
const { bundleSanitizedHtml, injectHtmlExportRuntime, validateRasterHeader } = await loadModules();
const { BrowserWindow, session } = await import('electron');

const failures = [];
Expand All @@ -209,7 +214,8 @@ async function run() {

const layouts = { scroll: scrollPayload(), slides: slidesPayload() };
for (const [layout, payload] of Object.entries(layouts)) {
const { html } = bundleSanitizedHtml(payload);
const { html: resolvedHtml } = bundleSanitizedHtml(payload);
const html = injectHtmlExportRuntime(resolvedHtml, layout === 'slides' ? 'slide' : 'scroll');
const saveDigest = digest(Buffer.from(html, 'utf8'));
check(`${layout}: finalized html is a single self-contained document`, html.startsWith('<!doctype html>') && html.includes('</html>'));
check(`${layout}: no remote origins in finalized bytes`, !/https?:\/\//i.test(html) && !/\ssrc=["']\/\//i.test(html), 'remote origin found in bytes');
Expand Down
68 changes: 56 additions & 12 deletions src/__tests__/html-export-css-sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,17 @@ describe('html export CSS sanitizer', () => {
declarationCount: 1,
});
expect(failureCode(sanitizeDeclarationList('background:url(https://example.test/a.png)'))).toBe('css_network_function_not_allowed');
expect(failureCode(sanitizeDeclarationList('color:var(--accent)'))).toBe('css_custom_property_not_allowed');
expect(failureCode(sanitizeDeclarationList('color:red!important'))).toBe('css_important_not_allowed');
expect(sanitizeDeclarationList('color:var(--accent)').ok).toBe(true);
expect(sanitizeDeclarationList('color:red!important').ok).toBe(true);
});

it('enforces the frozen selector, pseudo, and at-rule grammar', () => {
expect(failureCode(sanitizeStylesheet('head{color:red}'))).toBe('css_reserved_selector');
expect(failureCode(sanitizeStylesheet('style{color:red}'))).toBe('css_reserved_selector');
expect(failureCode(sanitizeStylesheet('[data-he-layout]{color:red}'))).toBe('css_reserved_selector');
expect(failureCode(sanitizeStylesheet('.he-scaler{color:red}'))).toBe('css_reserved_selector');
expect(failureCode(sanitizeStylesheet('p:active{color:red}'))).toBe('css_disallowed_selector');
expect(failureCode(sanitizeStylesheet('p::placeholder{color:red}'))).toBe('css_disallowed_selector');
expect(sanitizeStylesheet('p:active{color:red}').ok).toBe(true);
expect(sanitizeStylesheet('p::placeholder{color:red}').ok).toBe(true);
expect(failureCode(sanitizeStylesheet('@layer model{p{color:red}}'))).toBe('css_disallowed_at_rule');
expect(failureCode(sanitizeStylesheet('@-webkit-keyframes fade{from{opacity:0}}'))).toBe('css_disallowed_at_rule');
expect(failureCode(sanitizeStylesheet('@media (color){p{color:red}}'))).toBe('css_disallowed_at_rule');
Expand Down Expand Up @@ -290,14 +290,16 @@ describe('html export CSS sanitizer', () => {
expect(sanitizeDeclarationList('font-size:0').ok).toBe(true);
expect(sanitizeDeclarationList(`font-size:${CSS_MAX_FONT_SIZE_PX}px`).ok).toBe(true);
expect(failureCode(sanitizeDeclarationList(`font-size:${CSS_MAX_FONT_SIZE_PX + 1}px`))).toBe('css_font_size_too_large');
expect(failureCode(sanitizeDeclarationList('font-size:1em'))).toBe('css_font_size_not_allowed');
expect(failureCode(sanitizeDeclarationList('font-size:50%'))).toBe('css_font_size_not_allowed');
expect(sanitizeDeclarationList('font-size:1em')).toMatchObject({ ok: true });
expect(sanitizeDeclarationList('font-size:1rem')).toMatchObject({ ok: true });
expect(sanitizeDeclarationList('font-size:50%')).toMatchObject({ ok: true });
expect(sanitizeDeclarationList(`font:italic ${CSS_MAX_FONT_SIZE_PX}px serif`).ok).toBe(true);
expect(failureCode(sanitizeDeclarationList(`font:${CSS_MAX_FONT_SIZE_PX + 1}px serif`))).toBe('css_font_size_too_large');
expect(failureCode(sanitizeDeclarationList('font:1em serif'))).toBe('css_font_size_not_allowed');
expect(sanitizeDeclarationList('font:1em serif')).toMatchObject({ ok: true });
expect(sanitizeDeclarationList('font:50% serif')).toMatchObject({ ok: true });
expect(failureCode(sanitizeDeclarationList('font:inherit'))).toBe('css_font_size_not_allowed');
expect(failureCode(sanitizeDeclarationList('position:fixed'))).toBe('css_unsafe_position');
expect(failureCode(sanitizeDeclarationList('position:sticky'))).toBe('css_unsafe_position');
expect(sanitizeDeclarationList('position:fixed').ok).toBe(true);
expect(sanitizeDeclarationList('position:sticky').ok).toBe(true);
expect(sanitizeDeclarationList(`font-family:${'a'.repeat(CSS_MAX_VALUE_TOKEN_LENGTH)}`).ok).toBe(true);
expect(failureCode(sanitizeDeclarationList(`font-family:${'a'.repeat(CSS_MAX_VALUE_TOKEN_LENGTH + 1)}`))).toBe('css_value_token_too_long');
});
Expand Down Expand Up @@ -335,10 +337,9 @@ describe('global selector rewrite', () => {
expect(result.css).toBe('[data-he-content]{background:#fff}');
});

it('strips custom properties after :root rewrite', () => {
const result = sanitizeStylesheet(':root{--brand:#4f46e5}');
it('accepts themed custom-property declarations without failing the stylesheet', () => {
const result = sanitizeStylesheet('[data-theme="dark"]{--brand:#4f46e5}');
expect(result.ok).toBe(true);
expect(failureCode(result)).toBe(CSS_VIOLATION_CODES.customProperty);
});

it('rewrites compound global-root selectors without doubled content-root prefixes', () => {
Expand Down Expand Up @@ -415,4 +416,47 @@ describe('global selector rewrite', () => {
CSS_VIOLATION_CODES.disallowedSelector,
);
});
it('compounds all-theme :where and :is selector arguments with the content root', () => {
const result = sanitizeStylesheet(
':where([data-theme="dark"],[data-theme="light"]){--bg:#111}:is([data-theme]){--fg:#eee}:where(:root[data-theme]){--root:#fff}:is(html[data-theme]){--html:#ddd}',
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.css).toBe(
'[data-he-content]:where([data-theme="dark"],[data-theme="light"]){--bg:#111}[data-he-content]:is([data-theme]){--fg:#eee}[data-he-content]:where([data-he-content][data-theme]){--root:#fff}[data-he-content]:is([data-he-content][data-theme]){--html:#ddd}',
);
});
it('keeps mixed :where selector arguments scoped as descendants', () => {
const result = sanitizeStylesheet(':where([data-theme="dark"],.card){--bg:#111}');
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.css).toBe('[data-he-content] :where([data-theme="dark"],.card){--bg:#111}');
});
});
it('compounds leading theme selectors with the content root', () => {
const result = sanitizeStylesheet('[data-theme="dark"]{--bg:#111}:root[data-theme="light"]{--bg:#fff}');
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.css).toContain('[data-he-content][data-theme="dark"]{--bg:#111}');
expect(result.css).toContain('[data-he-content][data-theme="light"]{--bg:#fff}');
});
it('rewrites theme-root sibling selectors as descendants of the content root', () => {
const result = sanitizeStylesheet('[data-theme="dark"]~button{color:red}[data-theme="dark"]+button{color:blue}');
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.css).toBe(
'[data-he-content][data-theme="dark"] button{color:red}[data-he-content][data-theme="dark"] button{color:blue}',
);
});
it('preserves child-combinator semantics for theme-root selectors', () => {
const result = sanitizeStylesheet('[data-theme="dark"]>button{color:red}');
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.css).toBe('[data-he-content][data-theme="dark"]>button{color:red}');
});
it('does not treat data-theme-prefixed attributes as theme atoms', () => {
const result = sanitizeStylesheet('[data-theme-variant="dark"]{--bg:#111}');
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.css).toContain('[data-he-content] [data-theme-variant="dark"]{--bg:#111}');
});
3 changes: 1 addition & 2 deletions src/__tests__/html-export-direct-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,7 @@ describe('buildDirectHtmlPrompt — 1:1 config mapping + full source', () => {
expect(prompt).toMatch(/only.{0,40}asset ID/i);
expect(prompt).toMatch(/never.{0,40}data: URIs|NEVER emit data:/i);
expect(prompt).not.toMatch(/inline data: images/i);
expect(prompt).toMatch(/CSS font-size.*font shorthand size.*px.*0.*absolute keywords/i);
expect(prompt).toMatch(/Never rem, em, or % for font size/i);
expect(prompt).not.toMatch(/Never rem, em, or % for font size/i);
expect(prompt).toMatch(/<a href> may ONLY be a non-empty same-document fragment/i);
expect(prompt).toMatch(/NEVER use https:, http:, mailto:, or any other scheme/i);
expect(prompt).toMatch(/external\/source links from Markdown as plain text/i);
Expand Down
47 changes: 34 additions & 13 deletions src/__tests__/html-export-finalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,19 +208,39 @@ async function driveToResolved(
}

describe('HtmlExportPipelineService.finalize', () => {
it('transitions resolved -> finalized and returns a matching finalized ref', async () => {
it('injects the scroll runtime without slide navigation', async () => {
const { service, registry } = serviceFor();
const { attemptId, resolvedId, resolvedBytes } = await driveToResolved(service, registry);
const { attemptId, resolvedId } = await driveToResolved(service, registry);

const finalized = service.finalize(1, attemptId, resolvedId);
const finalized = service.finalize(1, attemptId, resolvedId, 'scroll');

expect(finalized.ok).toBe(true);
if (!finalized.ok) return;
const bytes = registry.transitions.at(-1)?.bytes;
expect(finalized.value.artifact.stage).toBe('finalized');
expect(finalized.value.artifact.sha256).toBe(digest(resolvedBytes));
expect(finalized.value.artifact.byteLength).toBe(resolvedBytes.byteLength);
expect(registry.transitions.at(-1)).toMatchObject({ priorId: resolvedId, stage: 'finalized' });
expect(registry.transitions.at(-1)?.bytes.equals(resolvedBytes)).toBe(true);
expect(finalized.value.artifact.sha256).toBe(digest(bytes!));
expect(finalized.value.artifact.byteLength).toBe(bytes!.byteLength);
expect(bytes?.toString('utf8')).toContain('id="nai-runtime"');
expect(bytes?.toString('utf8')).toContain('if(false)');
});
it('injects slide navigation for slide-mode requests', async () => {
const { service, registry } = serviceFor();
const { attemptId, resolvedId } = await driveToResolved(service, registry, 1, '<section class="slide">One</section>');

const finalized = service.finalize(1, attemptId, resolvedId, 'slide');

expect(finalized.ok).toBe(true);
expect(registry.transitions.at(-1)?.bytes.toString('utf8')).toContain('nai-slide-nav');
});
it('injects locale-specific labels', async () => {
const { service, registry } = serviceFor();
const { attemptId, resolvedId } = await driveToResolved(service, registry, 1, '<section class="slide">One</section>');

const finalized = service.finalize(1, attemptId, resolvedId, 'slide', 'ko');

expect(finalized.ok).toBe(true);
const html = registry.transitions.at(-1)?.bytes.toString('utf8') ?? '';
expect(html).toContain('어두운 테마로 전환');
});

it('returns typed pipeline errors for unknown, wrong-sender, and stale resolved ids', async () => {
Expand Down Expand Up @@ -285,22 +305,23 @@ describe('HtmlExportPipelineService.finalize', () => {

describe('HtmlExportPipelineService.readFinalizedArtifact', () => {
async function driveToFinalized(service: HtmlExportPipelineService, registry: FakeRegistry) {
const { attemptId, resolvedId, resolvedBytes } = await driveToResolved(service, registry);
const { attemptId, resolvedId } = await driveToResolved(service, registry);
const finalized = valueOf(service.finalize(1, attemptId, resolvedId));
return { attemptId, finalizedId: finalized.artifact.id, resolvedBytes };
return { attemptId, finalizedId: finalized.artifact.id };
}

it('returns the exact main-held finalized bytes with a matching digest', async () => {
const { service, registry } = serviceFor();
const { attemptId, finalizedId, resolvedBytes } = await driveToFinalized(service, registry);
const { attemptId, finalizedId } = await driveToFinalized(service, registry);

const read = service.readFinalizedArtifact(1, attemptId, finalizedId);

expect(read.ok).toBe(true);
if (!read.ok) return;
expect(read.value.bytes.equals(resolvedBytes)).toBe(true);
expect(read.value.sha256).toBe(digest(resolvedBytes));
expect(read.value.byteLength).toBe(resolvedBytes.byteLength);
const finalizedBytes = registry.transitions.at(-1)?.bytes;
expect(read.value.bytes.equals(finalizedBytes!)).toBe(true);
expect(read.value.sha256).toBe(digest(finalizedBytes!));
expect(read.value.byteLength).toBe(finalizedBytes!.byteLength);
});

it('returns typed errors for unknown, wrong-sender, and stale finalized ids', async () => {
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/html-export-pipeline-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type HtmlExportPipelineServiceOptions,
type HtmlExportSanitizedPayload,
} from '../main/html-export-pipeline-service';
import { bundleSanitizedHtml } from '../main/html-export-shell';

type Artifact = {
ref: HtmlExportArtifactRef;
Expand Down Expand Up @@ -779,4 +780,19 @@ describe('HtmlExportPipelineService', () => {
expect(seen[0].contentRootClass).toBe('dark');
expect(seen[0].contentRootId).toBe('app');
});
it('injects the requested runtime mode after the real shell resolver', async () => {
for (const mode of ['slide', 'scroll'] as const) {
const { service, registry } = serviceFor(undefined, undefined, async (payload) => bundleSanitizedHtml(payload).html);
const attemptId = start(service);
const raw = valueOf(service.storeRawModelOutput(1, attemptId, '<section class="slide">one</section><section class="slide">two</section>'));
const sanitized = valueOf(await service.sanitize(1, attemptId, raw.id)).artifact;
const resolved = valueOf(await service.resolve(1, attemptId, sanitized.id)).artifact;
valueOf(service.finalize(1, attemptId, resolved.id, mode));

const finalized = registry.transitions.at(-1)!;
const html = finalized.bytes.toString('utf8');
expect(html).toContain('id="nai-runtime"');
expect(html.includes('if(true){var slides=')).toBe(mode === 'slide');
}
});
});
Loading
Loading