diff --git a/scripts/html-export-direct-harness.mjs b/scripts/html-export-direct-harness.mjs index ae52f22..9a07368 100644 --- a/scripts/html-export-direct-harness.mjs +++ b/scripts/html-export-direct-harness.mjs @@ -80,14 +80,19 @@ 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)); @@ -95,8 +100,8 @@ async function loadModules() { 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) ---------- @@ -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 = []; @@ -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('') && html.includes('')); check(`${layout}: no remote origins in finalized bytes`, !/https?:\/\//i.test(html) && !/\ssrc=["']\/\//i.test(html), 'remote origin found in bytes'); diff --git a/src/__tests__/html-export-css-sanitize.test.ts b/src/__tests__/html-export-css-sanitize.test.ts index b480c3a..37d5373 100644 --- a/src/__tests__/html-export-css-sanitize.test.ts +++ b/src/__tests__/html-export-css-sanitize.test.ts @@ -70,6 +70,19 @@ describe('html export CSS sanitizer', () => { declarationCount: 1, }); }); + it('accepts preserved control type selectors scoped to the content root', () => { + expect(sanitizeStylesheet('textarea{color:red}select{color:blue}option{font-weight:700}optgroup{color:green}')).toMatchObject({ + ok: true, + css: '[data-he-content] textarea{color:red}[data-he-content] select{color:blue}[data-he-content] option{font-weight:700}[data-he-content] optgroup{color:green}', + }); + }); + it('allows quoted input type attribute selectors while rejecting unrelated attributes', () => { + expect(sanitizeStylesheet('input[type="range"]{width:100%}input[type="checkbox"]{height:1em}')).toMatchObject({ + ok: true, + css: '[data-he-content] input[type="range"]{width:100%}[data-he-content] input[type="checkbox"]{height:1em}', + }); + expect(failureCode(sanitizeStylesheet('input[name="volume"]{width:100%}'))).toBe('css_disallowed_selector'); + }); it('parses inline declarations and preserves duplicate shorthand/longhand order', () => { expect(sanitizeDeclarationList('margin:1px;margin-left:4px;color:red;color:blue')).toMatchObject({ @@ -88,8 +101,8 @@ 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', () => { @@ -97,8 +110,8 @@ describe('html export CSS sanitizer', () => { 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'); @@ -290,14 +303,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'); }); @@ -335,10 +350,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', () => { @@ -415,4 +429,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}:where(body[data-theme="dark"]){--body-where:#222}:is(body[data-theme="light"]){--body-is:#ccc}', + ); + 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}[data-he-content]:where([data-he-content][data-theme="dark"]){--body-where:#222}[data-he-content]:is([data-he-content][data-theme="light"]){--body-is:#ccc}', + ); + }); + 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}'); + }); diff --git a/src/__tests__/html-export-direct-prompt.test.ts b/src/__tests__/html-export-direct-prompt.test.ts index de2d009..ce9364f 100644 --- a/src/__tests__/html-export-direct-prompt.test.ts +++ b/src/__tests__/html-export-direct-prompt.test.ts @@ -133,6 +133,7 @@ describe('buildDirectHtmlPrompt — 1:1 config mapping + full source', () => { expect(prompt).toMatch(/\bmain\b/); expect(prompt).toMatch(/\baside\b/); expect(prompt).toMatch(/conversational preamble/i); + expect(prompt).toMatch(/form\/input\/button\/textarea\/select\/option\/optgroup\/label\/fieldset\/legend/); expect(prompt).toMatch(/Sure, here is/i); expect(prompt).toMatch(/I hope this helps/i); expect(prompt).toMatch(/whether bare text or wrapped in an element/i); @@ -144,8 +145,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(/ 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); diff --git a/src/__tests__/html-export-finalize.test.ts b/src/__tests__/html-export-finalize.test.ts index d58484a..e89b862 100644 --- a/src/__tests__/html-export-finalize.test.ts +++ b/src/__tests__/html-export-finalize.test.ts @@ -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, '
One
'); + + 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, '
One
'); + + 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 () => { @@ -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 () => { diff --git a/src/__tests__/html-export-generation-orchestrator.test.ts b/src/__tests__/html-export-generation-orchestrator.test.ts index 749cc05..c2322cb 100644 --- a/src/__tests__/html-export-generation-orchestrator.test.ts +++ b/src/__tests__/html-export-generation-orchestrator.test.ts @@ -181,6 +181,30 @@ describe('HtmlExportGenerationOrchestrator', () => { expect(pipeline.invalidateAttempt).not.toHaveBeenCalled(); expect((generate as ReturnType).calls).toHaveLength(1); }); + it('injects the selected slide runtime before quarantine and finalizes the same mode', async () => { + const quarantine: QuarantineMeasureFn = vi.fn(async () => ({ ok: true as const })); + const pipeline = createFakePipeline(); + const { orchestrator } = createOrchestrator({ pipeline, quarantine }); + + const result = await orchestrator.run(WEB_CONTENTS_ID, PROMPT, { mode: 'slide', locale: 'ko' }); + + expect(result.state).toBe('final'); + expect(pipeline.resolve).toHaveBeenCalledWith( + WEB_CONTENTS_ID, + attempt('attempt-1'), + sanitizedId('sanitized-1'), + 'slide', + 'ko', + ); + expect(quarantine).toHaveBeenCalledAfter(pipeline.resolve as ReturnType); + expect(pipeline.finalize).toHaveBeenCalledWith( + WEB_CONTENTS_ID, + attempt('attempt-1'), + resolvedId('resolved-1'), + 'slide', + 'ko', + ); + }); it('zero-decoded-byte first output retries exactly once on the same route then succeeds', async () => { const generate = createGenerate([ diff --git a/src/__tests__/html-export-pipeline-ipc.test.ts b/src/__tests__/html-export-pipeline-ipc.test.ts index 9534de5..05d5071 100644 --- a/src/__tests__/html-export-pipeline-ipc.test.ts +++ b/src/__tests__/html-export-pipeline-ipc.test.ts @@ -16,6 +16,11 @@ vi.mock('electron', () => ({ dialog: {}, shell: {} })); import { dialog } from 'electron'; import { registerHtmlExportIpc } from '../main/ipc/html-export-ipc'; +import { createHtmlExportGenerator } from '../main/html-export-generate'; +import { htmlExportRuntimeLabels } from '../main/html-export-runtime-labels'; +import { injectHtmlExportRuntime } from '../main/html-export-runtime'; +import type { AiChatEvent, AiChatRequest } from '../main/ai/types'; +import type { HtmlExportRuntimeLocale } from '../main/html-export-runtime-labels'; type Sender = { id: number; @@ -1143,5 +1148,101 @@ describe('HTML export pipeline IPC', () => { model: { provider: 'ollama', id: 'llama3:latest' }, }); }); + it('preserves locale through IPC, generation, and finalization runtime labels', async () => { + const finalizedHtml: string[] = []; + const pipeline = { + beginAttempt: () => ({ ok: true as const, value: { attemptId: 'attempt-1' } }), + storeRawModelOutput: () => ({ + ok: true as const, + value: { id: 'raw-1', attemptId: 'attempt-1', stage: 'raw', sha256: 'a'.repeat(64), byteLength: 32 }, + }), + sanitize: async () => ({ + ok: true as const, + value: { + artifact: { + id: 'sanitized-1', + attemptId: 'attempt-1', + stage: 'sanitized', + sha256: 'a'.repeat(64), + byteLength: 32, + }, + }, + }), + resolve: async () => ({ + ok: true as const, + value: { + artifact: { + id: 'resolved-1', + attemptId: 'attempt-1', + stage: 'resolved', + sha256: 'a'.repeat(64), + byteLength: 32, + }, + }, + }), + finalize: ( + _webContentsId: number, + _attemptId: string, + _resolvedArtifactId: string, + mode: 'slide' | 'scroll' = 'scroll', + locale: HtmlExportRuntimeLocale = 'en', + ) => { + finalizedHtml.push( + injectHtmlExportRuntime( + '
One
', + mode, + htmlExportRuntimeLabels(locale), + ), + ); + return { + ok: true as const, + value: { + artifact: { + id: 'finalized-1', + attemptId: 'attempt-1', + stage: 'finalized', + sha256: 'a'.repeat(64), + byteLength: 32, + }, + }, + }; + }, + invalidateAttempt: () => undefined, + }; + const generator = createHtmlExportGenerator({ + pipeline: pipeline as never, + stream: async (_request: AiChatRequest, onEvent: (event: AiChatEvent) => void) => { + onEvent({ kind: 'delta', text: '
One
' }); + onEvent({ kind: 'done', text: '' }); + }, + quarantine: async () => ({ ok: true as const }), + }); + const sender: Sender = { id: 92, once: vi.fn() }; + registerHtmlExportIpc({ + windowForWebContents: () => null, + pipelineService: createService() as never, + assetLifecycle: createNoopAssetLifecycle(), + generateHtml: (webContentsId, input) => generator.run(webContentsId, input), + }); + + const korean = await ipc.handler('html:generate')!(eventFor(sender), { + prompt: 'make it', + model: { provider: 'ollama', id: 'llama3:latest' }, + mode: 'slide', + locale: 'ko', + }); + const english = await ipc.handler('html:generate')!(eventFor(sender), { + prompt: 'make it', + model: { provider: 'ollama', id: 'llama3:latest' }, + mode: 'slide', + }); + + expect(korean).toMatchObject({ state: 'final', finalizedArtifactId: 'finalized-1' }); + expect(english).toMatchObject({ state: 'final', finalizedArtifactId: 'finalized-1' }); + expect(finalizedHtml[0]).toContain('어두운 테마로 전환'); + expect(finalizedHtml[0]).not.toContain('Switch to dark theme'); + expect(finalizedHtml[1]).toContain('Switch to dark theme'); + expect(finalizedHtml[1]).not.toContain('어두운 테마로 전환'); + }); }); }); diff --git a/src/__tests__/html-export-pipeline-service.test.ts b/src/__tests__/html-export-pipeline-service.test.ts index a66b3ac..ffc2d04 100644 --- a/src/__tests__/html-export-pipeline-service.test.ts +++ b/src/__tests__/html-export-pipeline-service.test.ts @@ -25,6 +25,8 @@ import { type HtmlExportPipelineServiceOptions, type HtmlExportSanitizedPayload, } from '../main/html-export-pipeline-service'; +import { bundleSanitizedHtml } from '../main/html-export-shell'; +import { htmlExportRuntimeSha256 } from '../main/html-export-runtime'; type Artifact = { ref: HtmlExportArtifactRef; @@ -779,4 +781,24 @@ describe('HtmlExportPipelineService', () => { expect(seen[0].contentRootClass).toBe('dark'); expect(seen[0].contentRootId).toBe('app'); }); + it('injects the requested runtime before quarantine resolution and preserves final bytes', 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, '
one
two
')); + const sanitized = valueOf(await service.sanitize(1, attemptId, raw.id)).artifact; + const resolved = valueOf(await service.resolve(1, attemptId, sanitized.id, mode)).artifact; + const measured = registry.transitions.at(-1)!; + const finalized = valueOf(service.finalize(1, attemptId, resolved.id, mode)).artifact; + const finalArtifact = registry.transitions.at(-1)!; + + expect(measured.stage).toBe('resolved'); + const measuredHtml = measured.bytes.toString('utf8'); + expect(measuredHtml).toContain('id="nai-runtime"'); + expect(measuredHtml.includes('if(true){var deck=content||document.body,slides=')).toBe(mode === 'slide'); + expect(measuredHtml).toContain(`"runtimeSha256":"${htmlExportRuntimeSha256(mode)}"`); + expect(finalArtifact.bytes).toEqual(measured.bytes); + expect(finalized.sha256).toBe(digest(finalArtifact.bytes)); + } + }); }); diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts new file mode 100644 index 0000000..c2900a4 --- /dev/null +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -0,0 +1,392 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { bundleSanitizedHtml } from '../main/html-export-shell'; +import { htmlExportRuntimeSha256, injectHtmlExportRuntime } from '../main/html-export-runtime'; +import { sanitizeHtmlExport } from '../main/html-export-sanitize'; +import { htmlExportRuntimeLabels, type HtmlExportRuntimeLocale } from '../main/html-export-runtime-labels'; + +function mount( + html: string, + mode: 'scroll' | 'slide' = 'scroll', + locale: HtmlExportRuntimeLocale = 'en', + styleSheets?: Array<{ cssRules: unknown[] }>, +): void { + document.documentElement.innerHTML = injectHtmlExportRuntime(html, mode, htmlExportRuntimeLabels(locale)); + if (styleSheets) Object.defineProperty(document, 'styleSheets', { configurable: true, value: styleSheets }); + HTMLElement.prototype.scrollIntoView = () => {}; + const source = document.querySelector('#nai-runtime')?.textContent; + if (!source) throw new Error('runtime was not injected'); + window.eval(source); +} + +afterEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-nai-runtime'); + document.documentElement.innerHTML = ''; + vi.unstubAllGlobals(); + delete (document as { styleSheets?: unknown }).styleSheets; +}); + +describe('HTML export runtime DOM', () => { + it('toggles and restores the html theme through localStorage', () => { + localStorage.setItem('nai-theme', 'dark'); + mount('
content
'); + + const toggle = document.querySelector('#nai-runtime-toggle')!; + expect(document.documentElement.dataset.theme).toBe('dark'); + toggle.click(); + expect(document.documentElement.dataset.theme).toBe('light'); + expect(localStorage.getItem('nai-theme')).toBe('light'); + }); + + it('adds the fallback theme stylesheet when authored theme variables are absent inside a layer', () => { + mount('
'); + expect(document.querySelector('#nai-theme-fallback')?.textContent).toContain('[data-theme="dark"]'); + }); + it('keeps the fallback functional when content-root variables are not theme-conditioned', () => { + mount('
'); + const content = document.querySelector('[data-he-content]')!; + + document.querySelector('#nai-runtime-toggle')!.click(); + + expect(content.dataset.theme).toBe('dark'); + expect(document.querySelector('#nai-theme-fallback')).not.toBeNull(); + expect(content.matches('[data-he-content][data-theme="dark"]')).toBe(true); + }); + it('keeps the fallback when a theme-conditioned rule has no custom properties', () => { + mount('
'); + + expect(document.querySelector('#nai-theme-fallback')).not.toBeNull(); + }); + it('applies authored theme variables on the content root and skips fallback only when they match', () => { + mount('
'); + const content = document.querySelector('[data-he-content]')!; + expect(content.dataset.theme).toBe('light'); + document.querySelector('#nai-runtime-toggle')!.click(); + expect(content.dataset.theme).toBe('dark'); + expect(getComputedStyle(content).getPropertyValue('--bg').trim()).toBe('#111'); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + it('keeps the dark fallback for a light-only authored palette', () => { + mount('
'); + const content = document.querySelector('[data-he-content]')!; + + document.querySelector('#nai-runtime-toggle')!.click(); + + expect(content.dataset.theme).toBe('dark'); + expect(document.querySelector('#nai-theme-fallback')?.textContent).toContain('filter:invert(1)'); + }); + it('skips the fallback when both authored theme palettes are present', () => { + mount('
'); + + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + + it('applies a :where theme palette in the finalized artifact without a fallback', () => { + const sanitized = sanitizeHtmlExport({ + html: '
content
', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + expect(sanitized.contentCss).toContain('@layer he-authored{[data-he-content]:where([data-theme="dark"]){--surface:#111'); + + const finalized = bundleSanitizedHtml(sanitized).html; + mount(finalized); + const content = document.querySelector('[data-he-content]')!; + document.querySelector('#nai-runtime-toggle')!.click(); + + expect(content.matches('[data-he-content]:where([data-theme="dark"])')).toBe(true); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + it('applies functional body theme palettes in finalized artifacts without a fallback', () => { + const sanitized = sanitizeHtmlExport({ + html: '
content
', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + expect(sanitized.contentCss).toContain( + '[data-he-content]:where([data-he-content][data-theme="dark"]){--body-where:#111}', + ); + expect(sanitized.contentCss).toContain( + '[data-he-content]:is([data-he-content][data-theme="dark"]){--body-is:#222}', + ); + + mount(bundleSanitizedHtml(sanitized).html); + const content = document.querySelector('[data-he-content]')!; + document.querySelector('#nai-runtime-toggle')!.click(); + + expect(content.matches('[data-he-content]:where([data-he-content][data-theme="dark"])')).toBe(true); + expect(content.matches('[data-he-content]:is([data-he-content][data-theme="dark"])')).toBe(true); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + it('preserves case-sensitive custom properties and resolves their var() references in finalized artifacts', () => { + const sanitized = sanitizeHtmlExport({ + html: '
content
', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + + const finalized = bundleSanitizedHtml(sanitized).html; + expect(finalized).toMatch(/--AccentColor:rgb\(1,\s*2,\s*3\)/); + mount(finalized); + document.querySelector('#nai-runtime-toggle')!.click(); + + const authoredCss = Array.from(document.head.querySelectorAll('style'))[1]?.textContent ?? ''; + expect(authoredCss).toMatch(/--AccentColor:rgb\(1,\s*2,\s*3\)/); + expect(authoredCss).toContain('--Resolved:var(--AccentColor)'); + }); + it('skips the fallback for a :is theme palette', () => { + mount('
'); + + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + it('injects the fallback when a theme palette is only inside a non-matching media rule', () => { + const matchMedia = vi.fn((condition: string) => ({ matches: condition !== 'print' })); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: matchMedia, + }); + mount( + '
', + 'scroll', + 'en', + [{ cssRules: [{ type: 4, conditionText: 'print', cssRules: [{ selectorText: '[data-he-content][data-theme="dark"]', style: ['--surface'] }] }] }], + ); + expect(matchMedia).toHaveBeenCalledWith('print'); + expect(document.querySelector('#nai-theme-fallback')).not.toBeNull(); + }); + + it('skips the fallback when a theme palette is inside a matching media rule', () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (condition: string) => ({ matches: condition === 'screen' }), + }); + mount( + '
', + 'scroll', + 'en', + [{ cssRules: [{ type: 4, conditionText: 'screen', cssRules: [{ selectorText: '[data-he-content][data-theme="dark"]', style: ['--surface'] }] }] }], + ); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + + + it('localizes runtime controls while keeping the visible slide indicator numeric', () => { + mount('
one
two
', 'slide', 'ko'); + + const [previous, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + const indicator = document.querySelector('.nai-slide-nav span')!; + expect(document.querySelector('#nai-runtime-toggle')?.getAttribute('aria-label')).toBe('어두운 테마로 전환'); + expect(previous.getAttribute('aria-label')).toBe('이전 슬라이드'); + expect(next.getAttribute('title')).toBe('다음 슬라이드'); + expect(indicator.textContent).toBe('1/2'); + expect(indicator.getAttribute('aria-label')).toBe('슬라이드 1/2'); + }); + + it('has non-empty runtime labels for all supported locales', () => { + for (const locale of ['en', 'ko', 'zh-Hans', 'zh-Hant', 'ja'] as const) { + const labels = htmlExportRuntimeLabels(locale); + expect(Object.values(labels).every(Boolean), locale).toBe(true); + } + }); + + it('skips the fallback for authored theme variables inside media rules', () => { + mount('
'); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + it('maintains the authored active-slide convention through navigation and printing', () => { + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const [, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + + expect(slides[0].classList.contains('active')).toBe(true); + expect(slides[1].classList.contains('active')).toBe(false); + expect(getComputedStyle(slides[0]).display).toBe('block'); + expect(getComputedStyle(slides[1]).display).toBe('none'); + + next.click(); + expect(slides[0].classList.contains('active')).toBe(false); + expect(slides[1].classList.contains('active')).toBe(true); + expect(getComputedStyle(slides[0]).display).toBe('none'); + expect(getComputedStyle(slides[1]).display).toBe('block'); + + window.dispatchEvent(new Event('beforeprint')); + expect(slides.every((slide) => slide.classList.contains('active'))).toBe(true); + expect(slides.every((slide) => getComputedStyle(slide).display === 'block')).toBe(true); + + window.dispatchEvent(new Event('afterprint')); + expect(slides[0].classList.contains('active')).toBe(false); + expect(slides[1].classList.contains('active')).toBe(true); + expect(getComputedStyle(slides[0]).display).toBe('none'); + expect(getComputedStyle(slides[1]).display).toBe('block'); + }); + it('hides inactive slides over authored important display rules and restores the active display', () => { + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const [, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + + expect(getComputedStyle(slides[0]).display).toBe('flex'); + expect(getComputedStyle(slides[1]).display).toBe('none'); + expect(slides[1].style.getPropertyPriority('display')).toBe('important'); + + next.click(); + + expect(getComputedStyle(slides[0]).display).toBe('none'); + expect(getComputedStyle(slides[1]).display).toBe('flex'); + expect(slides[1].style.display).toBe(''); + }); + it('shows every slide while printing, restores the active slide afterwards, and includes print-only control hiding', () => { + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const [, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + next.click(); + + window.dispatchEvent(new Event('beforeprint')); + expect(slides.every((slide) => slide.style.display === '')).toBe(true); + + window.dispatchEvent(new Event('afterprint')); + expect(slides[0].style.getPropertyPriority('display')).toBe('important'); + expect(slides[1].style.display).toBe(''); + expect(document.querySelector('#nai-print-controls')?.textContent) + .toContain('@media print{#nai-runtime-toggle,#nai-slide-nav{display:none!important}}'); + }); + it('uses the print media change listener when print events are unavailable', () => { + let printListener: ((event: MediaQueryListEvent) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (query: string) => ({ + matches: false, + addEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => { + if (query === 'print' && type === 'change') printListener = listener; + }, + }), + }); + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + expect(printListener).toBeTypeOf('function'); + printListener!({ matches: true } as MediaQueryListEvent); + expect(slides.every((slide) => slide.style.display === '')).toBe(true); + printListener!({ matches: false } as MediaQueryListEvent); + expect(slides[0].style.display).toBe(''); + expect(slides[1].style.getPropertyPriority('display')).toBe('important'); + }); + it('preserves normalized boolean form state in finalized artifacts', () => { + const sanitized = sanitizeHtmlExport({ + html: '', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + + mount(bundleSanitizedHtml(sanitized).html); + const input = document.querySelector('input')!; + const [unselectedOption, selectedOption] = Array.from(document.querySelectorAll('option')); + expect(input.hasAttribute('required')).toBe(true); + expect(input.hasAttribute('checked')).toBe(true); + expect(input.required).toBe(true); + expect(input.checked).toBe(true); + expect(selectedOption.hasAttribute('selected')).toBe(true); + expect(unselectedOption.selected).toBe(false); + expect(selectedOption.selected).toBe(true); + }); + it('pages slide exports by keyboard and controls while ignoring text input focus', () => { + mount('
one
two
three
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const indicator = document.querySelector('.nai-slide-nav span')!; + const [previous, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + expect(indicator.textContent).toBe('1/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + expect(indicator.textContent).toBe('2/3'); + next.click(); + expect(indicator.textContent).toBe('3/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' })); + expect(indicator.textContent).toBe('2/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + expect(indicator.textContent).toBe('1/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown' })); + expect(indicator.textContent).toBe('2/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + expect(indicator.textContent).toBe('3/3'); + previous.click(); + expect(indicator.textContent).toBe('2/3'); + const input = slides[1].querySelector('input')!; + input.focus(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(indicator.textContent).toBe('2/3'); + const textarea = slides[2].querySelector('textarea')!; + textarea.focus(); + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(indicator.textContent).toBe('2/3'); + }); + it('counts only top-level slide sections so nested slide content remains visible', () => { + mount('
parent
nested
second
', 'slide'); + + const parent = document.querySelector('#parent')!; + const nested = document.querySelector('#nested')!; + const second = document.querySelector('#second')!; + const indicator = document.querySelector('.nai-slide-nav span')!; + const [previous, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + + expect(indicator.textContent).toBe('1/2'); + expect(parent.style.display).toBe(''); + expect(nested.style.display).toBe(''); + next.click(); + expect(indicator.textContent).toBe('2/2'); + expect(second.style.display).toBe(''); + previous.click(); + expect(indicator.textContent).toBe('1/2'); + expect(parent.style.display).toBe(''); + expect(nested.style.display).toBe(''); + }); + + it('patches only the manifest runtime SHA, leaving authored CSS decoys untouched', () => { + const decoy = '"runtimeSha256":"AAAA"'; + const output = injectHtmlExportRuntime( + ``, + 'slide', + htmlExportRuntimeLabels('ko'), + ); + + expect(output).toContain(`--x:'${decoy}'`); + const manifest = output.match(/`; + const output = injectHtmlExportRuntime(`${authoredScript}
content
`); + + expect(output).toContain(authoredScript); + expect((output.match(/id="nai-runtime"/g) ?? [])).toHaveLength(1); + expect(output.indexOf('id="nai-runtime"')).toBeGreaterThan(output.indexOf(authoredScript)); + expect(output.indexOf('id="nai-runtime"')).toBeLessThan(output.lastIndexOf('')); + }); + it('replaces only real head CSP meta tags and preserves CSP-like script strings', () => { + const authoredScript = ``; + const output = injectHtmlExportRuntime( + `${authoredScript}`, + ); + + expect(output).toContain(authoredScript); + document.documentElement.innerHTML = output; + expect(document.head.querySelectorAll('meta[http-equiv="Content-Security-Policy"]')).toHaveLength(1); + }); + it('is idempotent across double finalization', () => { + const once = injectHtmlExportRuntime('content'); + const twice = injectHtmlExportRuntime(once); + expect((twice.match(/id="nai-runtime"/g) ?? [])).toHaveLength(1); + expect((twice.match(/http-equiv="Content-Security-Policy"/g) ?? [])).toHaveLength(1); + }); +}); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index 0407c29..73df52d 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -110,13 +110,89 @@ describe('sanitizeHtmlExport', () => { if (!result.ok) return; expect(result.bodyHtml).toBe('

Kept

Text'); }); - it('unwraps content-bearing active containers while recording their removal', () => { + it('preserves interactive form containers', () => { const result = sanitize('

Kept

'); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.bodyHtml).toContain('

Kept

'); - expect(result.bodyHtml).not.toContain('

Kept

'); + }); + it('preserves safe input bounds and strips unsafe bound values', () => { + const result = sanitize(''); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('javascript:1'); + expect(result.bodyHtml).not.toContain('Infinity'); + }); + it('normalizes inert boolean form attributes to bare presence regardless of their authored value', () => { + const result = sanitize( + '' + + '' + + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('checked="true"'); + expect(result.bodyHtml).not.toContain('required="true"'); + expect(result.bodyHtml).not.toContain('selected="true"'); + }); + it('preserves inert label and control metadata while stripping hostile values', () => { + const result = sanitize( + '' + + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('javascript:'); + expect(result.bodyHtml).not.toContain('https://example.test'); + }); + it('preserves structured form names while stripping hostile values', () => { + const result = sanitize( + '' + + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('user[ mail]'); + expect(result.bodyHtml).not.toContain('user["email"]'); + expect(result.bodyHtml).not.toContain('https://example.test'); + }); + it('preserves select options and removes unsupported option attributes', () => { + const result = sanitize( + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('type="button"'); + expect(result.bodyHtml).not.toContain('name="city"'); + }); + it('preserves case-insensitive arbitrary input steps but rejects hostile step values', () => { + const result = sanitize(''); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('step="anywhere"'); + expect(result.bodyHtml).not.toContain('step="Infinity"'); }); it('unwraps template content stored outside its childNodes array', () => { const result = sanitize('
'); @@ -128,11 +204,60 @@ describe('sanitizeHtmlExport', () => { }); it.each([ - 'iframe', 'object', 'embed', 'base', 'frame', 'frameset', 'applet', 'script', 'link', 'template', - 'slot', 'form', 'input', 'button', - ])('rejects active tag <%s>', (tag) => { + 'iframe', 'object', 'embed', 'base', 'frame', 'frameset', 'applet', 'link', 'template', 'slot', + ])('rejects unsupported active tag <%s>', (tag) => { expect(dispositionCodeWithParse(documentWithElement(tag))).toBe('html_active_tag'); }); + it.each(['script', 'form', 'input', 'button'])('preserves interactive tag <%s>', (tag) => { + expect(dispositionCodeWithParse(documentWithElement(tag))).toBe(''); + }); + it('relocates inline head scripts before model body content in source order', () => { + const result = sanitize( + '' + + '' + + '' + + '
body content
', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const bodyContent = result.bodyHtml.indexOf('
body content
'); + const bodyScript = result.bodyHtml.indexOf('window.order.push("body")'); + const firstHeadScript = result.bodyHtml.indexOf('window.order = ["head-1"]'); + const secondHeadScript = result.bodyHtml.indexOf('window.order.push("head-2")'); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(firstHeadScript).toBe(8); + expect(secondHeadScript).toBeGreaterThan(firstHeadScript); + expect(bodyContent).toBeGreaterThan(secondHeadScript); + expect(bodyScript).toBeGreaterThan(bodyContent); + }); + it('relocates head definitions before body calls', () => { + const result = sanitize( + '' + + '', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toBe(''); + }); + it('strips head scripts with src attributes', () => { + const result = sanitize( + '' + + '

Kept

', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain('

Kept

'); + expect(result.bodyHtml).not.toContain(' { + const result = sanitize('

Kept

'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toBe('

Kept

'); + expect(result.contentCss).toContain('p{color:red}'); + }); it('rejects meta http-equiv as an active redirect surface', () => { expect(dispositionCodeWithParse(documentWithElement('meta', [{ name: 'http-equiv', value: 'refresh' }]))).toBe('html_active_tag'); @@ -153,18 +278,22 @@ describe('sanitizeHtmlExport', () => { expect(dispositionCode(html)).toBe(code); }); - it('rejects event handlers and app shell/runtime namespace preseed', () => { - expect(dispositionCode('

x

')).toBe('html_event_handler'); + it('preserves event handlers while rejecting app shell/runtime namespace preseed', () => { + expect(dispositionCode('

x

')).toBe(''); expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); }); - it('removes stripped event and reserved attributes from exported HTML', () => { + it('reserves the nai runtime namespace', () => { + expect(dispositionCode('

x

')).toBe( + 'html_reserved_namespace', + ); + }); + it('keeps interactive event attributes while stripping reserved attributes', () => { const result = sanitize('

Kept

'); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.bodyHtml).toContain('Kept'); - expect(result.bodyHtml).not.toContain('onclick'); + expect(result.bodyHtml).toContain('onclick="x"'); expect(result.bodyHtml).not.toContain('data-he-'); expect(result.bodyHtml).not.toContain('he-shell'); }); @@ -294,41 +423,33 @@ describe('sanitizeHtmlExport', () => { expect(stylesheet).toMatchObject({ ok: true, stripped: ['css_rejected.css_network_function_not_allowed'] }); const inline = sanitize('

safe

'); - expect(inline).toMatchObject({ ok: true, stripped: ['css_rejected.css_important_not_allowed'] }); - if (inline.ok) { - expect(inline.bodyHtml).not.toContain('style='); - expect(inline.stripped.join()).not.toContain('color'); - } + expect(inline).toMatchObject({ ok: true, stripped: [] }); + if (inline.ok) expect(inline.contentCss).toContain('color:red!important'); }); it('strips oversized malformed CSS before stylesheet registration can parse it', () => { const result = sanitize(``); expect(result.ok).toBe(true); if (result.ok) expect(result.stripped).toContain('css_rejected.css_too_large'); }); - it('keeps the document and ordinary rules while stripping :is() sticky declarations', () => { + it('keeps interactive sticky declarations', () => { const result = sanitize('

Kept

Also kept

'); - expect(result).toMatchObject({ ok: true, stripped: ['css_rejected.css_unsafe_position'] }); + expect(result).toMatchObject({ ok: true, stripped: [] }); if (!result.ok) return; expect(result.bodyHtml).toContain('Kept'); - expect(result.contentCss).toContain(':is(.note){color:red}'); + expect(result.contentCss).toContain(':is(.note){position:sticky;color:red}'); expect(result.contentCss).toContain('.plain{color:blue}'); - expect(result.contentCss).not.toContain('sticky'); }); - it('gives active and style-node attribute failures precedence over malformed nested CSS', () => { + it('keeps reserved namespace checks ahead of malformed CSS', () => { for (const [html, code] of [ - ['', 'html_active_tag'], - ['', 'html_active_tag'], - ['', 'html_event_handler'], - ['

x

', 'html_event_handler'], ['', 'html_reserved_namespace'], ]) { expect(failureCode(html)).toBe(code); } }); it.each([ - ['active ancestor', '
', 'html_active_tag'], - ['event ancestor', '
', 'html_event_handler'], + ['active ancestor', '
', 'html_svg_rejected'], + ['event ancestor', '
', 'html_svg_rejected'], ['reserved ancestor', '
', 'html_reserved_namespace'], ['structural ancestor', '

', 'html_attribute'], ])('gives outer HTML boundaries precedence over malformed SVG: %s', (_name, html, code) => { @@ -739,7 +860,6 @@ describe('sanitizeHtmlExport — fail-closed structural gate (issue #27)', () => } const custom = sanitize('

x

'); expect(custom.ok).toBe(true); - if (custom.ok) expect(custom.stripped).toContain('css_rejected.css_custom_property_not_allowed'); }); it('emits no content-root style rule when html/body have no style attribute', () => { diff --git a/src/__tests__/html-export-shell.test.ts b/src/__tests__/html-export-shell.test.ts index 89c3f28..0e552d3 100644 --- a/src/__tests__/html-export-shell.test.ts +++ b/src/__tests__/html-export-shell.test.ts @@ -2,10 +2,7 @@ import { describe, it, expect } from 'vitest'; import { bundleSanitizedHtml } from '../main/html-export-shell'; import type { HtmlExportSanitizedPayload } from '../main/html-export-pipeline-service'; -import { - HTML_EXPORT_RUNTIME_JS, - HTML_EXPORT_RUNTIME_JS_SHA256, -} from '../shared/html-export-runtime'; +import { htmlExportRuntimeSha256 } from '../main/html-export-runtime'; function payload(over: Partial = {}): HtmlExportSanitizedPayload { return { @@ -39,11 +36,10 @@ function scriptBlocks(html: string): Array<{ type: string | null; id: string | n } describe('bundleSanitizedHtml — canonical shell contract', () => { - it('emits exactly one CSP meta whose script-src pins the shared runtime SHA', () => { + it('leaves runtime injection to finalization', () => { const { html } = bundleSanitizedHtml(payload()); - const cspMetas = html.match(/]*>/g) ?? []; - expect(cspMetas).toHaveLength(1); - expect(cspMetas[0]).toContain(`script-src 'sha256-${HTML_EXPORT_RUNTIME_JS_SHA256}'`); + expect(html).not.toContain('Content-Security-Policy'); + expect(scriptBlocks(html).filter((script) => script.id === 'nai-runtime')).toHaveLength(0); }); it('emits exactly two `, ``, @@ -122,8 +117,7 @@ export function bundleSanitizedHtml( // matches and the export renders unstyled. See #29 review (P1). // Safe class/id from source / are transferred so rewritten // selectors like `[data-he-content].dark` still match (Codex P2). - `\n${contentRootOpenTag(payload)}\n${payload.bodyHtml}\n\n\n\n` + + `\n${contentRootOpenTag(payload)}\n${payload.bodyHtml}\n\n\n` + '\n'; - return { html, manifest }; } diff --git a/src/main/ipc/html-export-ipc.ts b/src/main/ipc/html-export-ipc.ts index 731f592..7cf215d 100644 --- a/src/main/ipc/html-export-ipc.ts +++ b/src/main/ipc/html-export-ipc.ts @@ -24,6 +24,7 @@ import { } from '../../shared/html-export-pipeline'; import { atomicWrite, nodeAtomicBackend, type AtomicWriteBackend } from '../atomic-write'; import type { GenerationAttemptResult } from '../html-export-generation-orchestrator'; +import type { HtmlExportRuntimeLocale } from '../html-export-runtime-labels'; import { isAiProviderId, type AiProviderId } from '../ai/types'; import { HTML_EXPORT_CHATGPT_MODEL_IDS, isHtmlExportModelAllowed } from '../ai/html-export-model-allowlist'; import { VIEWPORT_MAX, VIEWPORT_MIN } from '../html-export-quarantine'; @@ -76,6 +77,9 @@ type HtmlExportIpcDeps = { model: { provider: AiProviderId; id: string }; instructions?: string; viewport?: { width: number; height: number }; + reasoningEffort?: 'low'; + mode?: 'slide' | 'scroll'; + locale?: HtmlExportRuntimeLocale; }, ) => Promise; cancelGenerateHtml?: (webContentsId: number) => void; @@ -200,9 +204,15 @@ function isCancelAttemptRequest(input: unknown): input is CancelAttemptRequest { } function isQuarantineMeasureRequest(input: unknown): input is QuarantineMeasureRequest { - return hasExactStringFields(input, ['attemptId', 'resolvedArtifactId']) + if (!isExactPlainObject(input) || Object.getOwnPropertySymbols(input).length !== 0) return false; + const keys = Object.keys(input); + return keys.every((key) => key === 'attemptId' || key === 'resolvedArtifactId' || key === 'mode') + && keys.length >= 2 + && typeof input.attemptId === 'string' + && typeof input.resolvedArtifactId === 'string' && isOpaqueHtmlExportId(input.attemptId) - && isOpaqueHtmlExportId(input.resolvedArtifactId); + && isOpaqueHtmlExportId(input.resolvedArtifactId) + && (input.mode === undefined || input.mode === 'slide' || input.mode === 'scroll'); } function isSaveFinalizedRequest(input: unknown): input is SaveFinalizedRequest { @@ -250,10 +260,12 @@ function isGenerateRequest( instructions?: string; viewport?: { width: number; height: number }; reasoningEffort?: 'low'; + mode?: 'slide' | 'scroll'; + locale?: HtmlExportRuntimeLocale; } { if (!isExactPlainObject(input) || Object.getOwnPropertySymbols(input).length !== 0) return false; const keys = Object.keys(input); - if (!keys.every((key) => key === 'prompt' || key === 'model' || key === 'instructions' || key === 'viewport' || key === 'reasoningEffort')) { + if (!keys.every((key) => key === 'prompt' || key === 'model' || key === 'instructions' || key === 'viewport' || key === 'reasoningEffort' || key === 'mode' || key === 'locale')) { return false; } if (!Object.hasOwn(input, 'prompt') || typeof input.prompt !== 'string') return false; @@ -274,6 +286,8 @@ function isGenerateRequest( return false; } } + if ('mode' in input && input.mode !== undefined && input.mode !== 'slide' && input.mode !== 'scroll') return false; + if ('locale' in input && input.locale !== undefined && input.locale !== 'en' && input.locale !== 'ko' && input.locale !== 'zh-Hans' && input.locale !== 'zh-Hant' && input.locale !== 'ja') return false; return true; } @@ -461,6 +475,8 @@ export function registerHtmlExportIpc({ ...(input.instructions !== undefined ? { instructions: input.instructions } : {}), ...(input.viewport !== undefined ? { viewport: input.viewport } : {}), ...(input.reasoningEffort !== undefined ? { reasoningEffort: input.reasoningEffort } : {}), + ...(input.mode !== undefined ? { mode: input.mode } : {}), + ...(input.locale !== undefined ? { locale: input.locale } : {}), }); } catch { return { state: 'failed', stage: 'generate', kind: 'pipeline-reject' }; @@ -564,11 +580,18 @@ export function registerHtmlExportIpc({ if (!result.ok) return result; // PASS: finalize the exact resolved bytes into a FinalizedArtifactId. - const finalized = await pipelineService.finalize( - binding.webContentsId, - input.attemptId, - input.resolvedArtifactId, - ); + const finalized = input.mode === undefined + ? await pipelineService.finalize( + binding.webContentsId, + input.attemptId, + input.resolvedArtifactId, + ) + : await pipelineService.finalize( + binding.webContentsId, + input.attemptId, + input.resolvedArtifactId, + input.mode, + ); if (!isCurrentSender(binding)) { try { quarantine.cancelWebContents(binding.webContentsId); diff --git a/src/main/preload.ts b/src/main/preload.ts index 60c4d0c..0800c76 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -320,6 +320,7 @@ const api = { instructions?: string; viewport?: { width: number; height: number }; reasoningEffort?: 'low'; + mode?: 'slide' | 'scroll'; }, ): Promise => ipcRenderer.invoke('html:generate', request), cancelHtmlGeneration: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('html:generate:cancel'), diff --git a/src/renderer/__tests__/html-export-wizard.dom.test.ts b/src/renderer/__tests__/html-export-wizard.dom.test.ts index 911d6ea..770fb90 100644 --- a/src/renderer/__tests__/html-export-wizard.dom.test.ts +++ b/src/renderer/__tests__/html-export-wizard.dom.test.ts @@ -110,6 +110,7 @@ function lastRequest(deps: HtmlExportDeps): { model: { provider: string; id: string }; viewport?: { width: number; height: number }; reasoningEffort?: 'low'; + mode?: 'slide' | 'scroll'; } { const calls = (deps.generateHtmlExport as ReturnType).mock.calls; return calls[calls.length - 1][0]; @@ -196,7 +197,7 @@ describe('mountHtmlExportWizard — summary/chart mode + advanced knobs thread i expect(prompt).toContain('summary/chart strength: C'); expect(prompt).toContain('Detailed brief'); expect(prompt).toContain('readable width: WIDE reading measure'); - expect(prompt).toContain('interactivity: allow tasteful CSS-only interactions'); + expect(prompt).toContain('interactivity: inline JavaScript runs in the final document.'); expect(prompt).toContain('board-ready digest'); }); }); @@ -437,6 +438,7 @@ describe('mountHtmlExportWizard — viewport + abandon invalidation', () => { expect(deps.generateHtmlExport).toHaveBeenCalledTimes(1); expect(lastRequest(deps).viewport).toEqual({ width: 720, height: 1280 }); + expect(lastRequest(deps).mode).toBe('scroll'); }); it('sends landscape 1280×720 when orientation is horizontal', async () => { @@ -450,6 +452,16 @@ describe('mountHtmlExportWizard — viewport + abandon invalidation', () => { expect(lastRequest(deps).viewport).toEqual({ width: 1280, height: 720 }); }); + it('sends slide mode when the slides layout is selected', async () => { + const { host, deps } = setup(); + click(host, 'orient-horizontal'); + click(host, 'layout-slides'); + click(host, 'design-default'); + click(host, 'generate-submit'); + await flush(); + + expect(lastRequest(deps).mode).toBe('slide'); + }); it('abandon (destroy) after generated invokes cancelHtmlGeneration so main can invalidate the finalized attempt', async () => { const { host, deps, handle } = setup(); diff --git a/src/renderer/api-types.ts b/src/renderer/api-types.ts index 94d1246..4194c33 100644 --- a/src/renderer/api-types.ts +++ b/src/renderer/api-types.ts @@ -129,6 +129,8 @@ export type Api = HtmlExportPipelineApi & HtmlExportAssetApi & { instructions?: string; viewport?: { width: number; height: number }; reasoningEffort?: 'low'; + mode?: 'slide' | 'scroll'; + locale?: 'en' | 'ko' | 'zh-Hans' | 'zh-Hant' | 'ja'; }, ) => Promise; cancelHtmlGeneration: () => Promise<{ ok: boolean }>; diff --git a/src/renderer/html-export-direct-prompt.ts b/src/renderer/html-export-direct-prompt.ts index fc1a0b7..eb251d9 100644 --- a/src/renderer/html-export-direct-prompt.ts +++ b/src/renderer/html-export-direct-prompt.ts @@ -90,6 +90,7 @@ function configDirectiveLines(config: DirectExportConfig): string[] { `- ${modeLine(config.mode)}`, `- ${densityLine(config.density)}`, ]; + if (config.mode === 'slide') lines.push('- slide markup contract: every slide MUST be exactly
, and each slide MUST occupy one viewport.'); if (config.customPurpose) { lines.push(`- custom purpose brief (weight heavily): ${config.customPurpose}`); @@ -111,7 +112,7 @@ function configDirectiveLines(config: DirectExportConfig): string[] { if (typeof config.interactive === 'boolean') { lines.push( config.interactive - ? '- interactivity: allow tasteful CSS-only interactions (no JavaScript)' + ? '- interactivity: inline JavaScript runs in the final document. Author frontier-quality, self-contained interactions appropriate to the content (tabs, accordions, hover states, animated reveals, inline-SVG chart interactions, and counters). Network APIs are unavailable under CSP; keep everything inline.' : '- interactivity: static document only (no interactive affordances)', ); } @@ -164,9 +165,9 @@ const HTML_EXPORT_DIRECT_DESIGN_KNOWLEDGE = [ '1. Classify the screen by reader task (narrative/marketing, report/dashboard, article/reference, instruction, or command) and turn the source into jobs — introduce, explain, substantiate, compare, decide, orient, retain — sequenced for that job, not for fashion.', '2. Preserve the source reading order and distinguish titles, prose, lists, tables, quotations, code, and data with real semantic HTML; keep evidence adjacent to its claim.', '3. Name the layout problem (flow, repetition, comparison, or primary/supporting context) and choose HTML structure + CSS that solves it: restrained flow for explanation, parallel items for repeated facts, tables for comparison.', - '4. Author complete, self-contained HTML with inline CSS — no scripts, no external fonts/assets. IMAGES: an src may ONLY be an app-issued opaque asset ID (src="asset:…") explicitly listed in this prompt; NEVER emit data: URIs, remote URLs, or invented images. When no asset ID is provided, author without and express any decoration in CSS. Every visual choice — layout, spacing, color, type scale — is yours to encode in CSS, honoring the design authority above.', - '5. Use only LITERAL CSS values. CSS custom properties (`--name`) and `var()` are NOT supported and will be rejected — write concrete values inline. Global element selectors (html/body/:root/*) are allowed (scoped to the export content root) but prefer authoring styles against document content. CSS font-size and the size token of the font shorthand must use 0, Npx, or absolute keywords only (xx-small through xxx-large); never rem, em, or % because relative font sizes are rejected by the sanitizer.', - '6. Use ONLY the supported HTML tag vocabulary (structural: section, article, main, aside, nav, header, footer, div, h1–h6, p, ul/ol/li, dl/dt/dd, figure/figcaption, blockquote, table/thead/tbody/tfoot/tr/th/td/caption, img/picture/source, svg; inline: span, strong/em/b/i/u/s, small, mark, sub/sup, code/pre/kbd/samp, abbr, time, a, br, hr). Attach classes, ids, and inline styles ONLY to these tags — unsupported tags are unwrapped and their attributes dropped, which orphans any CSS that targets them.', + '4. Author complete, self-contained HTML with inline CSS. Inline JavaScript is allowed only when interactivity is enabled; never use remote scripts, network APIs, external fonts, or external assets. IMAGES: an src may ONLY be an app-issued opaque asset ID (src="asset:…") explicitly listed in this prompt; NEVER emit data: URIs, remote URLs, or invented images. When no asset ID is provided, author without and express any decoration in CSS. Every visual choice — layout, spacing, color, type scale — is yours to encode in CSS, honoring the design authority above.', + '5. Define BOTH palettes with CSS custom properties under [data-theme="light"] and [data-theme="dark"]; route every authored color through var(). The app injects a theme toggle that switches data-theme. Global element selectors (html/body/:root/*) are allowed (scoped to the export content root) but prefer authoring styles against document content.', + '6. Use ONLY the supported HTML tag vocabulary (structural: section, article, main, aside, nav, header, footer, div, h1–h6, p, ul/ol/li, dl/dt/dd, figure/figcaption, blockquote, table/thead/tbody/tfoot/tr/th/td/caption, img/picture/source, svg, form/input/button/textarea/select/option/optgroup/label/fieldset/legend; inline: span, strong/em/b/i/u/s, small, mark, sub/sup, code/pre/kbd/samp, abbr, time, a, br, hr, script). Attach classes, ids, data attributes, inline styles, and event attributes only to these tags — unsupported tags are unwrapped and their attributes dropped, which orphans any CSS that targets them.', '7. Links: use
only for non-empty same-document fragments (#id); render external/source URLs as plain text.', ].join('\n'); @@ -232,7 +233,7 @@ export function buildDirectHtmlPrompt( '- Treat design.md as visual authority when present; realize its hierarchy, mood, and signature elements in HTML/CSS.', '- Preserve critical facts, numbers, names, quotes, and code from the source.', '- Self-contained only: inline CSS, no external stylesheets, no remote scripts, no network fetches.', - '- CSS font-size and the font shorthand size must use px, 0, or absolute keywords only (medium, large, …). Never rem, em, or % for font size — relative font sizes are rejected.', + '- Use readable CSS font sizes; px, rem, em, and % are supported.', '- Images: an src may ONLY be an app-issued asset ID (src="asset:…") explicitly provided in this prompt; never data: URIs, never remote or relative URLs. No provided asset IDs means no elements.', '- Anchors: an may ONLY be a non-empty same-document fragment (#id). NEVER use https:, http:, mailto:, or any other scheme.', '- Render external/source links from Markdown as plain text (the URL may appear as visible text); NEVER use .', @@ -337,7 +338,7 @@ export function buildSectionPrompt( '- Preserve facts from the section source; do not silently drop material in this range.', '- Self-contained styling preferences: inline styles or classes that compose with a later shell.', '- Images: an src may ONLY be an app-issued asset ID (src="asset:…") explicitly provided in this prompt; never data: URIs, never remote or relative URLs. No provided asset IDs means no elements.', - '- CSS font-size and the font shorthand size must use px, 0, or absolute keywords only (medium, large, …). Never rem, em, or % for font size — relative font sizes are rejected.', + '- Use readable CSS font sizes; px, rem, em, and % are supported.', '- Anchors: an may ONLY be a non-empty same-document fragment (#id). NEVER use https:, http:, mailto:, or any other scheme.', '- Render external/source links from Markdown as plain text (the URL may appear as visible text); NEVER use .', '', diff --git a/src/renderer/html-export-wizard.ts b/src/renderer/html-export-wizard.ts index 6fb94a8..ecfc246 100644 --- a/src/renderer/html-export-wizard.ts +++ b/src/renderer/html-export-wizard.ts @@ -82,6 +82,8 @@ export type HtmlExportDeps = { instructions?: string; viewport?: { width: number; height: number }; reasoningEffort?: 'low'; + mode?: 'slide' | 'scroll'; + locale?: 'en' | 'ko' | 'zh-Hans' | 'zh-Hant' | 'ja'; }) => Promise; /** Cancel/abandon the in-flight or finalized main-owned generation for this window. */ cancelHtmlGeneration?: () => void; @@ -503,6 +505,7 @@ export function mountHtmlExportWizard(host: HTMLElement, deps: HtmlExportDeps): prompt: pendingPrompt, model: { provider, id: model.id }, viewport, + mode: state.layout === 'slides' ? 'slide' : 'scroll', ...(reasoningEffort ? { reasoningEffort } : {}), }) .then((result) => { diff --git a/src/renderer/unified-chat-wiring.ts b/src/renderer/unified-chat-wiring.ts index 25d3dc5..5d53e15 100644 --- a/src/renderer/unified-chat-wiring.ts +++ b/src/renderer/unified-chat-wiring.ts @@ -3,7 +3,7 @@ import { mountHtmlExportWizard, type HtmlExportWizardHandle } from './html-expor import { clampChatWidth } from './chat-layout'; import { guardVerdict } from './humanize-guards'; import { styleDirective, detectLanguage, type Naturalness } from './humanize-engine'; -import { t } from './i18n'; +import { getLocale, t } from './i18n'; import { aiChatErrorMessage } from './ai-error-message'; import { modelContextWindowTokens } from '../main/ai/output-budget'; import { isAiProviderId, type AiProviderId, type ProviderAuthStatus } from '../main/ai/types'; @@ -406,7 +406,7 @@ export function initUnifiedChatWiring(ctx: AppContext, deps: UnifiedChatWiringDe listDesigns: () => window.api.listDesigns(), saveHtmlFinalized: (args) => window.api.saveHtmlFinalized(args), openSavedHtml: (filePath) => window.api.openSavedHtml(filePath), - generateHtmlExport: (request) => window.api.generateHtmlExport(request), + generateHtmlExport: (request) => window.api.generateHtmlExport({ ...request, locale: getLocale() }), cancelHtmlGeneration: () => void window.api.cancelHtmlGeneration(), openExternal: (url) => void window.api.openExternal(url), onCancel: () => ctx.setStatus(t('status.htmlExportCanceled')), diff --git a/src/shared/html-export-pipeline.ts b/src/shared/html-export-pipeline.ts index 44e26b8..9de8a6f 100644 --- a/src/shared/html-export-pipeline.ts +++ b/src/shared/html-export-pipeline.ts @@ -153,6 +153,7 @@ type HtmlExportQuarantineVerdict = { export type QuarantineMeasureRequest = { attemptId: HtmlExportAttemptId; resolvedArtifactId: ResolvedArtifactId; + mode?: 'slide' | 'scroll'; }; export type QuarantineMeasureResult = diff --git a/src/shared/html-export-runtime.ts b/src/shared/html-export-runtime.ts deleted file mode 100644 index c1c14b7..0000000 --- a/src/shared/html-export-runtime.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Canonical HTML-export runtime + CSP (G007 / PR-S4 §5.10c/§5.11). - * - * Single source of truth for the hash-pinned inline runtime and the matching - * Content-Security-Policy. Extracted VERBATIM from the legacy renderer bundle - * so `bundleHtml` output stays byte-identical. Swipe handlers / CSP re-pin are - * later G007 slices — do not alter RUNTIME_JS here without re-pinning. - */ - -import { sha256Base64 } from './sha256'; - -/** Minimal inline runtime — slide nav + resize-reflow + swipe (G005/G007). - * Contains no remote URL, no fetch/XHR, no `url(` — stays self-contained. */ -export const HTML_EXPORT_RUNTIME_JS = [ - '(function(){', - 'var root=document.querySelector("[data-he-reflow-root]");', - 'if(!root)return;', - 'var slides=Array.prototype.slice.call(root.querySelectorAll(".slide"));', - 'var cur=0;', - 'var curEl=root.querySelector("[data-he-current]");', - 'var totEl=root.querySelector("[data-he-total]");', - 'if(totEl)totEl.textContent=String(slides.length);', - 'function show(i){if(!slides.length)return;cur=Math.max(0,Math.min(slides.length-1,i));for(var k=0;k0)root.style.transform="translate(-50%,-50%) scale("+f+")";}', - 'function reflow(){sizeActive();fitDeck();}', - 'var t;window.addEventListener("resize",function(){clearTimeout(t);t=setTimeout(reflow,120);});', - 'fitDeck();', - 'window.__heReflow=reflow;', - '})();', -].join(''); - -export const HTML_EXPORT_RUNTIME_JS_SHA256 = sha256Base64(HTML_EXPORT_RUNTIME_JS); - -// Content-Security-Policy for the exported file (G006 defense-in-depth atop the -// structural allowlist validator). Only the inline runtime — pinned by its -// SHA-256 — may execute; default-src 'none' blocks every network fetch, and -// img/font are limited to inline data: URIs. `style-src 'unsafe-inline'` is kept -// because the document legitimately carries inline style attributes (and the -// allowlist validator already forbids remote url() in styles). -const HTML_EXPORT_CSP = - [ - "default-src 'none'", - 'img-src data:', - "style-src 'unsafe-inline'", - `script-src 'sha256-${HTML_EXPORT_RUNTIME_JS_SHA256}'`, - 'font-src data:', - "base-uri 'none'", - "form-action 'none'", - "frame-ancestors 'none'", - ].join('; ') + ';'; - -export const HTML_EXPORT_CSP_META = ``;