diff --git a/.env.example b/.env.example index e4acd3ec2..2ddbb3d9c 100644 --- a/.env.example +++ b/.env.example @@ -805,3 +805,11 @@ DEFAULT_MODEL= # successful verification clears its own counter. Without a trusted proxy the # app cannot attribute a request to a client, so there is no throttle; rely on # the length and randomness of the code instead. + +# Interactive scene libraries (public, build-time URL setting). +# Defaults to /vendor/interactive on the deployment's own origin. +# Override with an absolute HTTPS base URL if you mirror public/vendor/interactive +# to object storage or your own CDN. Preserve package/version paths and enable +# CORS (Access-Control-Allow-Origin: *) for sandboxed iframe modules and fonts. +# Set before pnpm build; Docker builds also accept this as a build argument. +# INTERACTIVE_ASSET_BASE_URL=https://assets.example.com/interactive diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f91334e68..05a2be600 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,7 +330,10 @@ jobs: COVER_LAYOUT_BROWSER: '1' - name: Interactive static HTML Chromium smoke - run: pnpm exec vitest run tests/video-export/interactive-static-html.browser.test.ts + run: >- + pnpm exec vitest run + tests/video-export/interactive-static-html.browser.test.ts + tests/video-export/interactive-self-hosted.browser.test.ts env: INTERACTIVE_STATIC_BROWSER: '1' diff --git a/.gitignore b/.gitignore index da8bfed46..b0714fa4c 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ e2e/__screenshots-619/ # Temporary render-service sources extracted by the mixed-pool regression test render-service/.owner-bound-parent-*/ + +# Generated interactive libraries +/public/vendor/interactive diff --git a/CHANGELOG.md b/CHANGELOG.md index 832846cb4..a1c5fc8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Interactive scenes load KaTeX, Three.js, Chart.js and the Tailwind v4 browser runtime from the deployment (`/vendor/interactive`, configurable with the build-time `INTERACTIVE_ASSET_BASE_URL`) instead of a public CDN. KaTeX is injected only into pages that contain math, and loads ahead of the page's scripts as before. The interactive prompts list the hosted libraries, and public CDN URLs for them in generated or stored pages are rewritten to the hosted copies when a scene opens and before export; other URLs are left as written. `@openmaic/generation` 0.3.24. [#1971](https://github.com/THU-MAIC/OpenMAIC/issues/1971) + - Course generation passes each catalogue voice's `gender` into the voice list and asks the model to pick a voice whose gender matches the agent, keeping the agent's name and persona consistent with that gender. `voiceDesign.gender` (`male` or `female`) is part of the agent DSL, the export sanitizer, and `set_roster`, so a course-language identity can still drive the match. When the model's voice is missing or invalid, playback falls back by index within that gender. A catalogue with no male/female data, or an agent whose gender is unknown, keeps the previous full-list index pick. [#1084](https://github.com/THU-MAIC/OpenMAIC/issues/1084) ### Bug Fixes diff --git a/Dockerfile b/Dockerfile index f88e77ef8..e4b0f1618 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ FROM base AS builder ARG ALLOWED_FRAME_ANCESTORS +ARG INTERACTIVE_ASSET_BASE_URL ARG NEXT_PUBLIC_MAIC_EDITOR_ENABLED ARG NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED ARG NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED @@ -60,6 +61,7 @@ ARG NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION ARG NEXT_PUBLIC_ENABLE_PPTX_IMPORT ARG NEXT_PUBLIC_PRO_WORKBENCH_ENABLED ENV ALLOWED_FRAME_ANCESTORS=$ALLOWED_FRAME_ANCESTORS +ENV INTERACTIVE_ASSET_BASE_URL=$INTERACTIVE_ASSET_BASE_URL ENV NEXT_PUBLIC_MAIC_EDITOR_ENABLED=$NEXT_PUBLIC_MAIC_EDITOR_ENABLED ENV NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED=$NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED ENV NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED=$NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED diff --git a/eslint.config.mjs b/eslint.config.mjs index a202bedc3..7832d3166 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -161,19 +161,19 @@ const eslintConfig = defineConfig([ }, { selector: - 'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json)(\\/|$)|node:|\\.\\.?\\/).+/]', + 'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json|es-module-lexer|postcss|postcss-value-parser)(\\/|$)|node:|\\.\\.?\\/).+/]', message: '@openmaic/generation may import only from @openmaic/dsl, @openmaic/geometry, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).', }, { selector: - 'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json)(\\/|$)|node:|\\.\\.?\\/).+/]', + 'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json|es-module-lexer|postcss|postcss-value-parser)(\\/|$)|node:|\\.\\.?\\/).+/]', message: '@openmaic/generation may re-export only from @openmaic/dsl, @openmaic/geometry, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).', }, { selector: - 'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json)(\\/|$)|node:|\\.\\.?\\/).+/]', + 'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/(dsl|geometry)(\\/|$)|(jsonrepair|katex|nanoid|parse5|partial-json|es-module-lexer|postcss|postcss-value-parser)(\\/|$)|node:|\\.\\.?\\/).+/]', message: '@openmaic/generation may re-export only from @openmaic/dsl, @openmaic/geometry, approved leaf runtime dependencies, Node built-ins, or relative modules (./… or ../…).', }, diff --git a/lib/export/inline-assets-shared.ts b/lib/export/inline-assets-shared.ts index dbf54d5bc..9d1b2f5bb 100644 --- a/lib/export/inline-assets-shared.ts +++ b/lib/export/inline-assets-shared.ts @@ -4,6 +4,8 @@ export interface InlineReport { } export interface InlineOptions { + /** Deployment origin for resolving self-hosted interactive libraries outside a browser. */ + baseUrl?: string; fetchImpl?: typeof fetch; maxAssetBytes?: number; fetcher?: FetchAsset; diff --git a/lib/export/inline-assets.ts b/lib/export/inline-assets.ts index c76d6f78b..ba542630b 100644 --- a/lib/export/inline-assets.ts +++ b/lib/export/inline-assets.ts @@ -1,3 +1,8 @@ +import { + postProcessInteractiveHtml, + rewriteInteractiveLibraries, + interactiveAssetBaseUrl, +} from '@openmaic/generation/browser'; import { toDataUri, type InlineReport, @@ -604,6 +609,12 @@ export async function inlineHtmlAssets( html: string, options?: InlineOptions, ): Promise<{ html: string; report: InlineReport; unresolved: string[] }> { + html = postProcessInteractiveHtml(html); + const assetBase = interactiveAssetBaseUrl(); + const origin = + options?.baseUrl ?? (typeof window !== 'undefined' ? window.location.origin : undefined); + if (origin && assetBase.startsWith('/')) + html = rewriteInteractiveLibraries(html, new URL(assetBase, origin).href).html; const fetchAsset = options?.fetcher ?? createAssetFetcher(options); const report: InlineReport = { inlined: [], failed: [] }; const unsupported = unsupportedImportmapFeatures(html); diff --git a/lib/export/proxied-fetch.ts b/lib/export/proxied-fetch.ts index dc7660896..a5a5777d1 100644 --- a/lib/export/proxied-fetch.ts +++ b/lib/export/proxied-fetch.ts @@ -4,9 +4,23 @@ * into cross-origin-only mode so local, blob, and data URLs stay direct, and can * try a CORS-enabled CDN directly before falling back to the bounded proxy. The * proxy validates the URL server-side (SSRF guard) and returns the bytes. + * + * Hosted interactive-scene libraries are always fetched directly: they are + * static files served with CORS, and on a localhost or private-address + * deployment the proxy's SSRF guard would reject them. */ +import { interactiveAssetBaseUrl } from '@openmaic/generation/browser'; import { fetchProxiedMediaUrl } from '@/lib/media/proxy-media-cache'; +function isInteractiveAsset(url: URL, pageHref: string | undefined): boolean { + try { + const base = new URL(interactiveAssetBaseUrl().replace(/\/?$/, '/'), pageHref); + return url.origin === base.origin && url.pathname.startsWith(base.pathname); + } catch { + return false; + } +} + export function createProxiedFetch( options: { crossOriginOnly?: boolean; directFirst?: boolean } = {}, ): typeof fetch { @@ -16,6 +30,7 @@ export function createProxiedFetch( let shouldProxy = false; try { const resolved = pageHref ? new URL(url, pageHref) : new URL(url); + if (isInteractiveAsset(resolved, pageHref)) return fetch(input, init); shouldProxy = (resolved.protocol === 'http:' || resolved.protocol === 'https:') && (!pageHref || resolved.origin !== new URL(pageHref).origin); diff --git a/lib/export/standalone-html/prepare-manifest.ts b/lib/export/standalone-html/prepare-manifest.ts index b7ca6836b..575f669f0 100644 --- a/lib/export/standalone-html/prepare-manifest.ts +++ b/lib/export/standalone-html/prepare-manifest.ts @@ -482,7 +482,7 @@ function prepareScene( return { ...rest, content: html - ? { ...interactive, html: patchHtmlForIframe(html) } + ? { ...interactive, html: patchHtmlForIframe(html, undefined, { processed: true }) } : { ...interactive, html: undefined }, }; } diff --git a/lib/utils/iframe.ts b/lib/utils/iframe.ts index 093416081..1166cce00 100644 --- a/lib/utils/iframe.ts +++ b/lib/utils/iframe.ts @@ -1,3 +1,4 @@ +import { postProcessInteractiveHtml } from '@openmaic/generation/browser'; import { injectIntoDocumentHead } from './html-document'; import { withObservationResponder, @@ -387,7 +388,12 @@ const ELEMENT_PICKER_SHIM = ` - - + + @@ -663,6 +663,8 @@ Return ONLY the HTML document, no markdown fences or explanations. - The output must end with exactly one `` tag +{{snippet:interactive-libraries}} + {{snippet:interactive-math}} {{snippet:interactive-observation}} diff --git a/packages/@openmaic/generation/test/__snapshots__/scene-prompt-golden.test.ts.snap b/packages/@openmaic/generation/test/__snapshots__/scene-prompt-golden.test.ts.snap index d1990acb4..67930e93d 100644 --- a/packages/@openmaic/generation/test/__snapshots__/scene-prompt-golden.test.ts.snap +++ b/packages/@openmaic/generation/test/__snapshots__/scene-prompt-golden.test.ts.snap @@ -322,9 +322,18 @@ const objectY = baseY - BOTTOM_MARGIN - (value / maxValue) * playableHeight; - [ ] **Animation is OBVIOUS, not subtle - user can tell simulation is running** +## Available libraries +The deployment hosts the libraries below. When a page needs one of them, load it from the URL listed here, exactly as written, instead of a public CDN. Load only libraries the page needs. Initialize classic-script libraries after their load event, or after DOMContentLoaded when using defer. +- katex 0.16.38: /vendor/interactive/katex@0.16.38/dist/katex.min.js +- three 0.160.0: /vendor/interactive/three@0.160.0/build/three.module.js +- chart.js 4.4.8: /vendor/interactive/chart.js@4.4.8/dist/chart.umd.js +- @tailwindcss/browser 4.1.18: /vendor/interactive/@tailwindcss/browser@4.1.18/dist/index.global.js +- Three.js addons import-map prefix: /vendor/interactive/three@0.160.0/examples/jsm/ +For Three.js use an import map with "three" and "three/addons/" set to the URLs above. Tailwind uses the v4 browser runtime, not the legacy v3 Play CDN API. The platform handles KaTeX automatically when math delimiters occur in page text; do not load or call KaTeX yourself. + ## Math typesetting -The platform loads KaTeX into every interactive page and typesets math wherever it appears, including text your scripts add or change after the page loads. Write math as LaTeX between \`\\(\` and \`\\)\` for inline math or \`\\[\` and \`\\]\` for display math, and leave typesetting to the platform. Do not load KaTeX, MathJax or any other math library yourself, and do not call their functions: the page can run where such a copy never loads, and a call into a missing library stops the page's script. +The platform loads KaTeX into any interactive page that contains math, whether in the page text or in strings your scripts write into the page, and typesets math wherever it appears, including text your scripts add or change after the page loads. Write math as LaTeX between \`\\(\` and \`\\)\` for inline math or \`\\[\` and \`\\]\` for display math, also inside script strings, and leave typesetting to the platform. Do not load KaTeX, MathJax or any other math library yourself, and do not call their functions: the page can run where such a copy never loads, and a call into a missing library stops the page's script. ## REQUIRED: declared current state for newly generated interactive content (v1) diff --git a/packages/@openmaic/generation/test/assets.test.ts b/packages/@openmaic/generation/test/assets.test.ts index a31b216d9..8960a0678 100644 --- a/packages/@openmaic/generation/test/assets.test.ts +++ b/packages/@openmaic/generation/test/assets.test.ts @@ -34,6 +34,7 @@ const SNIPPET_IDS = [ 'video-instructions', 'media-safety-guidelines', 'interactive-math', + 'interactive-libraries', 'interactive-observation', 'slide-image-instructions', 'slide-generated-image-instructions', diff --git a/packages/@openmaic/generation/test/fixtures/interactive-libraries/README.md b/packages/@openmaic/generation/test/fixtures/interactive-libraries/README.md new file mode 100644 index 000000000..40f8fde4c --- /dev/null +++ b/packages/@openmaic/generation/test/fixtures/interactive-libraries/README.md @@ -0,0 +1,5 @@ +# Interactive library regression corpus + +`math-cards.html` preserves the structure and initialization calls of the page regression in #1966, with version and CSS import variations. `three-orbit.html` preserves the import-map and OrbitControls pattern in the shipped 3D generation template. `chart-tailwind.html` is a representative generated-page fixture covering runtime styling, charts, unknown resources, and a script containing HTML as data. + +The fixtures are small hand-written pages that exercise the URL forms the mapper handles: pinned, ranged, unversioned and `latest` versions, CSS imports, ESM imports and import maps. diff --git a/packages/@openmaic/generation/test/fixtures/interactive-libraries/chart-tailwind.html b/packages/@openmaic/generation/test/fixtures/interactive-libraries/chart-tailwind.html new file mode 100644 index 000000000..fe09fc554 --- /dev/null +++ b/packages/@openmaic/generation/test/fixtures/interactive-libraries/chart-tailwind.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/packages/@openmaic/generation/test/fixtures/interactive-libraries/math-cards.html b/packages/@openmaic/generation/test/fixtures/interactive-libraries/math-cards.html new file mode 100644 index 000000000..674868213 --- /dev/null +++ b/packages/@openmaic/generation/test/fixtures/interactive-libraries/math-cards.html @@ -0,0 +1,26 @@ + + + + + + + + + + +
$E=mc^2$
+ + + diff --git a/packages/@openmaic/generation/test/fixtures/interactive-libraries/three-orbit.html b/packages/@openmaic/generation/test/fixtures/interactive-libraries/three-orbit.html new file mode 100644 index 000000000..356979b70 --- /dev/null +++ b/packages/@openmaic/generation/test/fixtures/interactive-libraries/three-orbit.html @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/packages/@openmaic/generation/test/interactive-library-mapper.test.ts b/packages/@openmaic/generation/test/interactive-library-mapper.test.ts new file mode 100644 index 000000000..e3a11112d --- /dev/null +++ b/packages/@openmaic/generation/test/interactive-library-mapper.test.ts @@ -0,0 +1,203 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + mapInteractiveLibraryUrl, + rewriteInteractiveLibraries, +} from '../src/interactive-library-mapper.js'; +import { interactiveLibraryUrl, interactiveLibraryPrompt } from '../src/interactive-libraries.js'; +import { postProcessInteractiveHtml } from '../src/interactive-post-processor.js'; + +const katex = interactiveLibraryUrl('katex'); +describe('library URL compatibility', () => { + it.each(['0.16.9', '^0.16.9', '~0.16.9', '0.16', '0.16.x', 'latest', ''])( + 'maps KaTeX version %s', + (version) => { + const suffix = version ? `@${version}` : ''; + expect( + mapInteractiveLibraryUrl(`https://cdn.jsdelivr.net/npm/katex${suffix}/dist/katex.min.js`), + ).toBe(katex); + expect(mapInteractiveLibraryUrl(`https://unpkg.com/katex${suffix}/dist/katex.min.js`)).toBe( + katex, + ); + }, + ); + it.each(['0.15.6', '0.17.0', '^0.17', '0.16.99', 'next', '>=0.16', '0'])( + 'leaves incompatible or ambiguous version %s', + (version) => { + expect( + mapInteractiveLibraryUrl(`https://unpkg.com/katex@${version}/dist/katex.min.js`), + ).toBeUndefined(); + }, + ); + it.each([ + 'https://unpkg.com/three@0.159.0/build/three.module.js', + 'https://cdn.jsdelivr.net/npm/chart.js@3/dist/chart.umd.js', + 'https://unpkg.com/katex@0.16.9/not-real.js', + 'https://unpkg.com/katex-extra@0.16.9/dist/katex.min.js', + 'https://unpkg.com.evil.test/katex@0.16.9/dist/katex.min.js', + 'https://user@unpkg.com/katex@0.16.9/dist/katex.min.js', + 'https://unpkg.com:8080/katex@0.16.9/dist/katex.min.js', + 'https://cdn.jsdelivr.net/npm/chart.js@4?module', + 'https://cdn.tailwindcss.com', + ])('preserves unknown URL %s', (url) => expect(mapInteractiveLibraryUrl(url)).toBeUndefined()); + it('maps package defaults, cdnjs aliases, scoped packages and protocol-relative URLs', () => { + expect(mapInteractiveLibraryUrl('https://unpkg.com/chart.js@^4.0')).toBe( + interactiveLibraryUrl('chart.js'), + ); + expect( + mapInteractiveLibraryUrl( + 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js', + ), + ).toBe(interactiveLibraryUrl('chart.js')); + expect(mapInteractiveLibraryUrl('https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4')).toBe( + interactiveLibraryUrl('@tailwindcss/browser'), + ); + expect(mapInteractiveLibraryUrl('//unpkg.com/katex@0.16.9/dist/katex.min.js')).toBe(katex); + }); +}); + +describe('HTML source mapping', () => { + it('drops integrity, normalizes credentials, and leaves comments and script strings alone', () => { + const input = ``; + const { html } = rewriteInteractiveLibraries(input); + expect(html).toContain(`src="${interactiveLibraryUrl('chart.js')}"`); + expect(html).not.toContain('sha384-old'); + expect(html).toContain('crossorigin="anonymous"'); + expect(html).toContain(""); + expect(html).toContain('const html=\'`; + const result = rewriteInteractiveLibraries(input).html; + expect(result.match(/vendor\/interactive/g)).toHaveLength(5); + expect(result).toContain(`const literal='${url}'`); + expect(rewriteInteractiveLibraries(result).html).toBe(result); + }); + it('preserves import-map layout and handles case-insensitive script types', () => { + const three = 'https://unpkg.com/three@0.160.0/build/three.module.js'; + const input = ``; + const html = rewriteInteractiveLibraries(input).html; + expect(html).toBe(input.replaceAll(three, interactiveLibraryUrl('three'))); + }); + it('counts unmatched external resource hosts', () => { + const input = + ''; + expect(rewriteInteractiveLibraries(input).unmatchedExternalHosts).toEqual({ + 'cdn.tailwindcss.com': 2, + 'fonts.googleapis.com': 1, + }); + }); + it('uses a configurable base in mapping and prompts', () => { + const base = 'https://assets.example.test/interactive'; + expect(mapInteractiveLibraryUrl('https://unpkg.com/chart.js@4', base)).toBe( + `${base}/chart.js@4.4.8/dist/chart.umd.js`, + ); + expect(interactiveLibraryPrompt()).toContain(interactiveLibraryUrl('three')); + }); + const fixtures = new URL('./fixtures/interactive-libraries/', import.meta.url); + for (const name of readdirSync(fixtures).filter((name) => name.endsWith('.html'))) { + it(`maps corpus fixture ${name} without a second-pass change`, () => { + const input = readFileSync(new URL(name, fixtures), 'utf8'); + const { html } = rewriteInteractiveLibraries(input); + expect(html).toContain('/vendor/interactive/'); + expect(rewriteInteractiveLibraries(html).html).toBe(html); + const processed = postProcessInteractiveHtml(input); + expect(postProcessInteractiveHtml(processed)).toBe(processed); + }); + } +}); + +describe('on-demand math', () => { + const SYNC_KATEX = + '`, + `
`, + `

@@x@@

`, + `
`, + ``, + '

', + '

', + '

$x$

', + ])('loads hosted KaTeX synchronously for math in %s', (input) => { + const html = postProcessInteractiveHtml(input); + expect(html).toContain(SYNC_KATEX); + expect(html).toContain(SYNC_AUTO_RENDER); + expect(html).toContain( + '', + ); + expect(html).not.toContain('defer'); + expect(html).not.toContain('cdn.jsdelivr.net'); + expect(postProcessInteractiveHtml(html)).toBe(html); + }); + describe('asset base changes', () => { + const original = process.env.INTERACTIVE_ASSET_BASE_URL; + afterEach(() => { + if (original === undefined) delete process.env.INTERACTIVE_ASSET_BASE_URL; + else process.env.INTERACTIVE_ASSET_BASE_URL = original; + }); + const page = + '

$x$

'; + + it('re-injects platform KaTeX for the current base and is idempotent for the same base', () => { + process.env.INTERACTIVE_ASSET_BASE_URL = 'https://old.example/interactive'; + const first = postProcessInteractiveHtml(page); + expect(first).toContain('https://old.example/interactive/katex@0.16.38/dist/katex.min.js'); + expect(postProcessInteractiveHtml(first)).toBe(first); + + process.env.INTERACTIVE_ASSET_BASE_URL = 'https://new.example/lib'; + const refreshed = postProcessInteractiveHtml(first); + expect(refreshed).toBe( + postProcessInteractiveHtml(page).replace( + 'https://new.example/lib/chart.js@4.4.8/dist/chart.umd.js', + 'https://old.example/interactive/chart.js@4.4.8/dist/chart.umd.js', + ), + ); + expect(refreshed.match(/data-openmaic-katex/g)).toHaveLength(4); + expect(postProcessInteractiveHtml(refreshed)).toBe(refreshed); + }); + + it('re-points library URLs under the default hosted path', () => { + delete process.env.INTERACTIVE_ASSET_BASE_URL; + const first = postProcessInteractiveHtml(page); + process.env.INTERACTIVE_ASSET_BASE_URL = 'https://new.example/lib'; + const refreshed = postProcessInteractiveHtml(first); + expect(refreshed).toBe(postProcessInteractiveHtml(page)); + expect(refreshed).not.toContain('"/vendor/interactive/'); + }); + }); + it('injects nothing for a page without math that has a blocking script', () => { + const input = '

No math

'; + expect(postProcessInteractiveHtml(input)).toBe(input); + }); + it.each([ + 'Save $5 or $10 today

No math

', + '$x$

No math

', + '

Prices: $5 and $10

', + '', + '', + '', + '', + '

No math

', + '
$x$
$x$', + '

Escaped \\$x$

', + ])('makes no KaTeX request for %s', (input) => + expect(postProcessInteractiveHtml(input)).toBe(input), + ); + it('upgrades stored #1966 markup and removes unnecessary resources', () => { + const legacy = + 'No math'; + const output = postProcessInteractiveHtml(legacy); + expect(output).not.toContain('katex'); + expect(output).not.toContain('oldRuntime'); + expect(postProcessInteractiveHtml(output)).toBe(output); + }); +}); diff --git a/packages/@openmaic/generation/test/interactive-post-processor.test.ts b/packages/@openmaic/generation/test/interactive-post-processor.test.ts index 5da9959c7..f6d765991 100644 --- a/packages/@openmaic/generation/test/interactive-post-processor.test.ts +++ b/packages/@openmaic/generation/test/interactive-post-processor.test.ts @@ -15,7 +15,7 @@ describe('interactive HTML post-processing', () => { const processed = postProcessInteractiveHtml(source); expect(processed).toContain('translate(${pos.x}, ${pos.y})'); expect(processed).toContain('cost $5 ${pos.x}'); - expect(processed).toContain('data-after="${pos.x}\\(x\\)"'); + expect(processed).toContain('data-after="${pos.x}$x$"'); expect(processed).toContain('\\(\\frac{1}{2}\\)'); }); @@ -43,7 +43,7 @@ const PAGE_WITH_OWN_KATEX = [ '', ].join('\n'); -const INJECTED_SCRIPT = /') + ''.length; - expect(processed.slice(0, ''.length)).toBe(''); - expect(processed.slice(injectionEnd).trim()).toBe(source.slice(''.length)); + expect(postProcessInteractiveHtml(source)).toBe(source); }); it('places the injection in head for documents without an explicit head', () => { - const withHtml = postProcessInteractiveHtml('x'); + const withHtml = postProcessInteractiveHtml('$x$'); expect(withHtml.startsWith('\n

x

'); + const withDoctype = postProcessInteractiveHtml('

$x$

'); expect(withDoctype.startsWith('\n { const processed = postProcessInteractiveHtml( - 't', + 't$x$', ); expect(processed.startsWith('\n=18'} @@ -5468,6 +5489,9 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tailwindcss/browser@4.1.18': + resolution: {integrity: sha512-cnAUsj2TXGslPmDEnM469b4MAFT/ifxjt+Bla/2vBTBzp/PIPo6pjKxRrd8x+S84/S1bAtEIlzbJaaJxgZUV8Q==} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -6748,6 +6772,10 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chart.js@4.4.8: + resolution: {integrity: sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==} + engines: {pnpm: '>=8'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -12465,6 +12493,9 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + three@0.160.0: + resolution: {integrity: sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==} + throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} @@ -16769,6 +16800,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@langchain/community@0.0.57(@aws-crypto/sha256-js@5.2.0)(@aws-sdk/client-bedrock-runtime@3.1048.0)(@aws-sdk/credential-provider-node@3.972.78)(@mozilla/readability@0.6.0)(@smithy/util-utf8@2.3.0)(jsdom@29.1.1(@noble/hashes@1.8.0)(canvas@3.2.3))(lodash@4.18.1)(openai@4.104.0(ws@8.21.3)(zod@4.3.6))(pg@8.22.0)(ws@8.21.3)': dependencies: '@langchain/core': 0.1.63(openai@4.104.0(ws@8.21.3)(zod@4.3.6)) @@ -18552,6 +18585,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@tailwindcss/browser@4.1.18': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -19969,6 +20004,10 @@ snapshots: charenc@0.0.2: {} + chart.js@4.4.8: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -27129,6 +27168,8 @@ snapshots: dependencies: real-require: 0.2.0 + three@0.160.0: {} + throttleit@2.1.0: {} through2@2.0.5: diff --git a/scripts/sync-interactive-assets.mjs b/scripts/sync-interactive-assets.mjs new file mode 100644 index 000000000..de75de0f7 --- /dev/null +++ b/scripts/sync-interactive-assets.mjs @@ -0,0 +1,39 @@ +/** Copy only allowlisted installed assets. No build or runtime CDN downloads. */ +import { cp, mkdir, readFile, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { INTERACTIVE_LIBRARIES } from '../packages/@openmaic/generation/dist/interactive-libraries.js'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(join(root, 'package.json')); +const output = join(root, 'public/vendor/interactive'); +await mkdir(output, { recursive: true }); +for (const library of INTERACTIVE_LIBRARIES) { + // Resolve the entry, then find its package root (some packages hide package.json). + let directory = dirname(require.resolve(library.package)); + while (true) { + try { + const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); + if (manifest.name === library.package) { + if (manifest.version !== library.version) + throw new Error( + `${library.package}: expected ${library.version}, installed ${manifest.version}`, + ); + break; + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const parent = dirname(directory); + if (parent === directory) throw new Error(`Package root not found: ${library.package}`); + directory = parent; + } + const target = join(output, `${library.package}@${library.version}`); + await rm(target, { recursive: true, force: true }); + for (const file of [...library.files, library.license]) { + await mkdir(dirname(join(target, file)), { recursive: true }); + await cp(join(directory, file), join(target, file), { recursive: true }); + } +} +console.log('Synced deployment-hosted interactive libraries.'); diff --git a/tests/config/pi-chat-build-env.test.ts b/tests/config/pi-chat-build-env.test.ts index 30345ac57..565ff2b19 100644 --- a/tests/config/pi-chat-build-env.test.ts +++ b/tests/config/pi-chat-build-env.test.ts @@ -35,7 +35,7 @@ describe('Legacy Pi chat flag build check', () => { const config = await loadConfig(PHASE_PRODUCTION_BUILD, flagValue); expect(config.output).toBe('standalone'); - expect(config.env).toBeUndefined(); + expect(config.env).not.toHaveProperty(FLAG); }, ); diff --git a/tests/export/inline-assets.test.ts b/tests/export/inline-assets.test.ts index 719110938..5fa31449e 100644 --- a/tests/export/inline-assets.test.ts +++ b/tests/export/inline-assets.test.ts @@ -629,10 +629,11 @@ describe('inlineHtmlAssets — importmap integration', () => { }); it('inlines three + three/addons via importmap and retains the prefix as fallback', async () => { - const base = 'https://unpkg.com/three@0.160.0/examples/jsm/'; + const base = 'https://classroom.test/vendor/interactive/three@0.160.0/examples/jsm/'; const fetchImpl = (async (url: string) => { const map: Record = { - 'https://unpkg.com/three@0.160.0/build/three.module.js': 'export const THREE=1', + 'https://classroom.test/vendor/interactive/three@0.160.0/build/three.module.js': + 'export const THREE=1', [base + 'controls/OrbitControls.js']: "import * as THREE from 'three'; export class OrbitControls{}", }; @@ -649,7 +650,10 @@ describe('inlineHtmlAssets — importmap integration', () => { "", ].join(''); - const { html: out } = await inlineHtmlAssets(html, { fetchImpl }); + const { html: out } = await inlineHtmlAssets(html, { + fetchImpl, + baseUrl: 'https://classroom.test', + }); expect(out).toContain('"three":"data:text/javascript;base64,'); expect(out).toContain('"three/addons/controls/OrbitControls.js":"data:text/javascript;base64,'); // prefix is retained as online fallback for sub-paths not seen during static analysis @@ -657,11 +661,12 @@ describe('inlineHtmlAssets — importmap integration', () => { }); it('keeps the three/addons/ prefix as fallback when an addon fails to fetch', async () => { - const base = 'https://unpkg.com/three@0.160.0/examples/jsm/'; + const base = 'https://classroom.test/vendor/interactive/three@0.160.0/examples/jsm/'; const fetchImpl = (async (url: string) => { // three OK, OrbitControls OK, but a second addon 404s const map: Record = { - 'https://unpkg.com/three@0.160.0/build/three.module.js': 'export const THREE=1', + 'https://classroom.test/vendor/interactive/three@0.160.0/build/three.module.js': + 'export const THREE=1', [base + 'controls/OrbitControls.js']: "import 'three'; export class OrbitControls{}", }; const body = map[String(url)]; @@ -675,11 +680,14 @@ describe('inlineHtmlAssets — importmap integration', () => { '}}', "", ].join(''); - const { html: out } = await inlineHtmlAssets(html, { fetchImpl }); + const { html: out } = await inlineHtmlAssets(html, { + fetchImpl, + baseUrl: 'https://classroom.test', + }); // OrbitControls inlined as explicit data: entry expect(out).toContain('"three/addons/controls/OrbitControls.js":"data:'); // GLTFLoader failed → prefix retained as online fallback - expect(out).toContain('"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"'); + expect(out).toContain(`"three/addons/":"${base}"`); }); }); @@ -893,7 +901,7 @@ describe('inlineHtmlAssets', () => { it('ships the real KaTeX stylesheet with woff2-only font sources and no placeholder URLs', async () => { const dist = path.join(process.cwd(), 'node_modules/katex/dist'); - const base = 'https://cdn.jsdelivr.net/npm/katex/dist/'; + const base = 'https://classroom.test/vendor/interactive/katex@0.16.38/dist/'; const fetched: string[] = []; const fetcher = async (url: string) => { fetched.push(url); diff --git a/tests/export/proxied-fetch.test.ts b/tests/export/proxied-fetch.test.ts index 21bcb2558..eb120fb3e 100644 --- a/tests/export/proxied-fetch.test.ts +++ b/tests/export/proxied-fetch.test.ts @@ -30,6 +30,28 @@ describe('createProxiedFetch', () => { expect(await res.text()).toBe('BYTES'); }); + it('fetches hosted interactive libraries directly, even from a private-address origin', async () => { + vi.stubGlobal('location', new URL('http://127.0.0.1:3000/classroom/1')); + const spy = vi.fn(async () => new Response('BYTES')); + vi.stubGlobal('fetch', spy); + const url = 'http://127.0.0.1:3000/vendor/interactive/katex@0.16.38/dist/katex.min.js'; + + await createProxiedFetch()(url); + + expect(spy).toHaveBeenCalledWith(url, undefined); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('still proxies a cross-origin URL with the same path', async () => { + vi.stubGlobal('location', new URL('http://127.0.0.1:3000/classroom/1')); + const spy = vi.fn(async () => new Response('BYTES')); + vi.stubGlobal('fetch', spy); + + await createProxiedFetch()('https://cdn.example/vendor/interactive/x.js'); + + expect(spy).toHaveBeenCalledWith('/api/proxy-media', expect.anything()); + }); + it('handles URL objects', async () => { const spy = vi.fn(async () => new Response('', { status: 200 })); vi.stubGlobal('fetch', spy); diff --git a/tests/generation/browser-entry.test.ts b/tests/generation/browser-entry.test.ts index b65c1ef69..428c75089 100644 --- a/tests/generation/browser-entry.test.ts +++ b/tests/generation/browser-entry.test.ts @@ -32,7 +32,14 @@ describe('generation browser entry', () => { /^(node:|fs$|path$|url$|crypto$|child_process$)/, ); if (name.startsWith('.')) visit(resolve(dirname(file), name)); - else expect(['jsonrepair']).toContain(name); + else + expect([ + 'jsonrepair', + 'parse5', + 'es-module-lexer/js', + 'postcss', + 'postcss-value-parser', + ]).toContain(name); } } diff --git a/tests/math/math-engine-guard.test.ts b/tests/math/math-engine-guard.test.ts index 9667c982c..5bd3d7f99 100644 --- a/tests/math/math-engine-guard.test.ts +++ b/tests/math/math-engine-guard.test.ts @@ -60,6 +60,12 @@ const ALLOWLIST: Record

$2+2=4$

'; +const fetchAsset: FetchAsset = async (url) => { + const parsed = new URL(url); + if (parsed.origin !== ORIGIN || !parsed.pathname.startsWith('/vendor/interactive/')) + throw new Error(`Unexpected external request: ${url}`); + const bytes = new Uint8Array(await readFile(join(process.cwd(), 'public', parsed.pathname))); + return { + bytes, + contentType: url.endsWith('.css') + ? 'text/css' + : url.endsWith('.js') + ? 'text/javascript' + : 'font/woff2', + }; +}; + +describe.skipIf(!REQUIRED)('self-hosted interactive libraries in Chromium', () => { + let browser: Browser; + beforeAll(async () => { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: ['--disable-crash-reporter', '--disable-crashpad'], + }); + }); + afterAll(async () => { + await browser?.close(); + }); + + /** Serves `html` at ORIGIN, extra routes by URL, and hosted libraries from public/. */ + async function openPage( + html: string, + extra: Record = {}, + options: { failLibraries?: boolean } = {}, + ) { + const page = await browser.newPage(); + const requests: string[] = []; + await page.route('**/*', async (route) => { + const url = route.request().url(); + requests.push(url); + if (url === `${ORIGIN}/`) return route.fulfill({ contentType: 'text/html', body: html }); + if (url in extra) return route.fulfill({ contentType: 'text/javascript', body: extra[url]! }); + if (options.failLibraries) return route.abort(); + const asset = await fetchAsset(url); + await route.fulfill({ + body: Buffer.from(asset!.bytes), + contentType: asset!.contentType, + headers: { 'Access-Control-Allow-Origin': '*' }, + }); + }); + await page.goto(ORIGIN); + return { page, requests }; + } + + const parseTimeRender = + "document.getElementById('formula').innerHTML = katex.renderToString('x^2');"; + + it('typesets in a sandboxed classroom iframe using local requests only', async () => { + const { page, requests } = await openPage( + ``, + ); + try { + const frame = page.frames().find((frame) => frame !== page.mainFrame())!; + await expect.poll(() => frame.locator('.katex').count()).toBe(1); + expect(requests.every((url) => url.startsWith(ORIGIN))).toBe(true); + expect(requests.some((url) => url.endsWith('.woff2'))).toBe(true); + } finally { + await page.close(); + } + }); + + it('keeps parsing and page initialization working when local libraries fail', async () => { + const { page } = await openPage( + postProcessInteractiveHtml( + math.replace( + 'window.parserReachedBody = true;', + "katex.render('x', document.getElementById('formula')); window.parserReachedBody = true;", + ), + ), + {}, + { failLibraries: true }, + ); + try { + expect( + await page.evaluate( + () => (window as unknown as { parserReachedBody: boolean }).parserReachedBody, + ), + ).toBe(true); + expect(await page.locator('#formula').textContent()).toBe('x'); + } finally { + await page.close(); + } + }); + + it.each([ + [ + 'an inline katex.render call', + ``, + {}, + ], + ['an inline katex.renderToString call', ``, {}], + [ + 'a blocking external script', + '', + { [`${ORIGIN}/app.js`]: parseTimeRender }, + ], + [ + 'an async external script', + '', + { [`${ORIGIN}/app.js`]: parseTimeRender }, + ], + [ + 'a String.raw template', + '', + {}, + ], + ])('typesets math rendered during parsing by %s', async (_name, script, extra) => { + const { page } = await openPage( + postProcessInteractiveHtml( + `

$y$

${script}`, + ), + extra, + ); + try { + await expect.poll(() => page.locator('#formula .katex').count()).toBe(1); + } finally { + await page.close(); + } + }); + + it('applies authored auto-render options from a parse-time renderMathInElement call', async () => { + const { page } = await openPage( + postProcessInteractiveHtml( + `

@@x^2@@

`, + ), + ); + try { + expect(await page.locator('#formula .katex').count()).toBe(1); + } finally { + await page.close(); + } + }); + + it('makes no library request for a page without math', async () => { + const page = await browser.newPage(); + const requests: string[] = []; + page.on('request', (request) => requests.push(request.url())); + try { + await page.setContent(postProcessInteractiveHtml('

Price: $5 and $10

')); + expect(requests).toEqual([]); + } finally { + await page.close(); + } + }); + + it('loads Three.js and OrbitControls modules from the deployment', async () => { + const page = await browser.newPage(); + const html = ``; + const requests: string[] = []; + await page.route('**/*', async (route) => { + const url = route.request().url(); + requests.push(url); + if (url === `${ORIGIN}/`) + return route.fulfill({ contentType: 'text/html', body: postProcessInteractiveHtml(html) }); + const asset = await fetchAsset(url); + await route.fulfill({ + body: Buffer.from(asset!.bytes), + contentType: asset!.contentType, + headers: { 'Access-Control-Allow-Origin': '*' }, + }); + }); + try { + await page.goto(ORIGIN); + await expect.poll(() => page.locator('body').textContent()).toBe('function14'); + expect(requests.every((url) => url.startsWith(ORIGIN))).toBe(true); + expect(requests.some((url) => url.endsWith('/controls/OrbitControls.js'))).toBe(true); + } finally { + await page.close(); + } + }); + + it.each(['standalone', 'video'])( + 'typesets offline in %s HTML with scripts, CSS, and fonts embedded', + async (kind) => { + let html: string; + if (kind === 'standalone') { + const result = await inlineSceneContent( + { type: 'interactive', url: '', html: math }, + { fetcher: fetchAsset, baseUrl: ORIGIN }, + ); + expect(result.report.failed).toEqual([]); + html = (result.content as { html: string }).html; + } else { + const scene = { + id: 'math', + type: 'interactive', + content: { type: 'interactive', url: '', html: math }, + actions: [], + } as unknown as Scene; + const prepared = await prepareInteractiveHtmlScenes([scene], { + fetcher: fetchAsset, + baseUrl: ORIGIN, + }); + expect(prepared.html(scene)?.present).toBe(true); + html = prepared.content('interactive:math')!; + } + expect(html).not.toContain(`src="${ORIGIN}`); + expect(html).not.toContain(`href="${ORIGIN}`); + expect(html).toContain('data:font/woff2;base64,'); + const page = await browser.newPage(); + const requests: string[] = []; + await page.route('**/*', (route) => { + requests.push(route.request().url()); + return route.abort(); + }); + try { + await page.setContent(html); + await expect.poll(() => page.locator('.katex').count()).toBe(1); + expect( + await page.locator('.katex').evaluate((element) => getComputedStyle(element).fontFamily), + ).toContain('KaTeX'); + expect(requests.filter((url) => /^https?:/.test(url))).toEqual([]); + if (kind === 'video') + await expect + .poll(() => page.locator('html').getAttribute('data-openmaic-static-state')) + .toBe('frozen'); + } finally { + await page.close(); + } + }, + ); + + it('executes the hosted Tailwind and Chart browser runtimes', async () => { + const page = await browser.newPage(); + await page.route('**/*', async (route) => { + const url = route.request().url(); + if (url === `${ORIGIN}/`) + return route.fulfill({ + contentType: 'text/html', + body: `

Chart

`, + }); + const asset = await fetchAsset(url); + await route.fulfill({ body: Buffer.from(asset!.bytes), contentType: asset!.contentType }); + }); + try { + await page.goto(ORIGIN); + expect( + await page.evaluate( + () => (window as unknown as { Chart: { version: string } }).Chart.version, + ), + ).toBe('4.4.8'); + await expect + .poll(() => page.locator('h1').evaluate((element) => getComputedStyle(element).fontSize)) + .toBe('36px'); + } finally { + await page.close(); + } + }); +}); diff --git a/tests/workflows/ci-video-export-contract.test.ts b/tests/workflows/ci-video-export-contract.test.ts index 423cbb783..bb8876c68 100644 --- a/tests/workflows/ci-video-export-contract.test.ts +++ b/tests/workflows/ci-video-export-contract.test.ts @@ -181,7 +181,7 @@ describe('CI video-export workflow contract', () => { const guard = step(parseWorkflow(), 'e2e', 'Interactive static HTML Chromium smoke'); expect(guard.run).toBe( - 'pnpm exec vitest run tests/video-export/interactive-static-html.browser.test.ts', + 'pnpm exec vitest run tests/video-export/interactive-static-html.browser.test.ts tests/video-export/interactive-self-hosted.browser.test.ts', ); expect(guard.env).toEqual({ INTERACTIVE_STATIC_BROWSER: '1' }); expect(guard.if).toBeUndefined();