feat(generation): self-host interactive scene libraries with robust CDN URL mapping (#1972)

Interactive scenes load KaTeX, Three.js (with addons), Chart.js and the Tailwind v4 browser runtime from the deployment (/vendor/interactive, configurable via the build-time INTERACTIVE_ASSET_BASE_URL) instead of public CDNs. KaTeX is injected only into pages with detectable math, synchronously at the start of head (same-origin and immutable-cached), with the #1966 fallback when the asset fails; reprocessing refreshes platform-injected KaTeX tags against the current asset base. A parser-based mapper rewrites compatible public CDN URLs for these libraries to the hosted copies, drops integrity, leaves unknown URLs untouched and is idempotent; stored scenes are reprocessed on open and before export, and exports fetch hosted assets directly. Legacy Tailwind v3 Play CDN is not mapped. @openmaic/generation 0.3.24. Closes #1971.
This commit is contained in:
wyuc authored and GitHub committed 2026-10-21 22:46:51 +08:00
1 parent b87e014423
commit a8c745a791
54 files changed
+1336 -103

No files matched your search

+8
View File
@@ -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
+4 -1
View File
@@ -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'
+3
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+3 -3
View File
@@ -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 ../…).',
},
+2
View File
@@ -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;
+11
View File
@@ -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);
+15
View File
@@ -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);
@@ -482,7 +482,7 @@ function prepareScene(
return {
...rest,
content: html
? { ...interactive, html: patchHtmlForIframe(html) }
? { ...interactive, html: patchHtmlForIframe(html, undefined, { processed: true }) }
: { ...interactive, html: undefined },
};
}
+7 -1
View File
@@ -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 = `<script data-iframe-element-picker-shim>
* Omitted by consumers that produce standalone documents (video export), where
* no parent ever asks and the bytes count against an export size cap.
*/
export function patchHtmlForIframe(html: string, observation?: ObservationIdentity): string {
export function patchHtmlForIframe(
html: string,
observation?: ObservationIdentity,
options?: { processed?: boolean },
): string {
if (!options?.processed) html = postProcessInteractiveHtml(html);
const iframeCss = `<style data-iframe-patch>
html, body {
width: 100%;
@@ -23,6 +23,8 @@ export interface PreparedInteractiveHtmlSet extends InteractiveHtmlSource {
}
export interface PrepareInteractiveHtmlOptions {
/** Deployment origin when packaging outside a browser. */
baseUrl?: string;
fetcher?: FetchAsset;
maxHtmlBytes?: number;
}
@@ -236,6 +238,7 @@ export async function prepareInteractiveHtmlScenes(
unresolved,
} = await inlineHtmlAssets(authored, {
fetcher: options.fetcher,
baseUrl: options.baseUrl,
keepImportmapFallbacks: false,
});
const sanitized = stripInvalidFontFallbacks(inlined);
@@ -258,7 +261,7 @@ export async function prepareInteractiveHtmlScenes(
}
const packaged = injectIntoDocumentHead(
patchHtmlForIframe(sanitized),
patchHtmlForIframe(sanitized, undefined, { processed: true }),
staticCaptureInjection(),
);
const size = new TextEncoder().encode(packaged).byteLength;
+11
View File
@@ -5,6 +5,10 @@ const nextConfig: NextConfig = {
// Keep Next dev from creating or rewriting repository agent instructions.
agentRules: false,
output: 'standalone',
env: {
INTERACTIVE_ASSET_BASE_URL:
process.env.INTERACTIVE_ASSET_BASE_URL?.trim() || '/vendor/interactive',
},
outputFileTracingIncludes: {
'/*': [
'lib/server/agent-runtime/import-pptx-worker.mjs',
@@ -56,6 +60,13 @@ const nextConfig: NextConfig = {
const frameAncestors = extraAncestors ? `'self' ${extraAncestors}` : "'self'";
return [
{
source: '/vendor/interactive/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
{
source: '/(.*)',
headers: [
+10 -6
View File
@@ -8,14 +8,14 @@
},
"scripts": {
"postinstall": "pnpm run build:packages",
"build:packages": "cd packages/mathml2omml && npm run build && cd ../pptxgenjs && npm run build && cd ../@openmaic/dsl && pnpm run build && cd ../geometry && pnpm run build && cd ../generation && pnpm run build && cd ../storage && pnpm run build && cd ../importer && pnpm run build && cd ../renderer && pnpm run build && cd ../editor && pnpm run build && cd ../../.. && node scripts/sync-maic-importer.mjs",
"build:packages": "cd packages/mathml2omml && npm run build && cd ../pptxgenjs && npm run build && cd ../@openmaic/dsl && pnpm run build && cd ../geometry && pnpm run build && cd ../generation && pnpm run build && cd ../storage && pnpm run build && cd ../importer && pnpm run build && cd ../renderer && pnpm run build && cd ../editor && pnpm run build && cd ../../.. && node scripts/sync-maic-importer.mjs && node scripts/sync-interactive-assets.mjs",
"sync:maic-importer": "node scripts/sync-maic-importer.mjs",
"build:standalone-player": "node scripts/build-standalone-player.mjs",
"gen:video-export-katex": "node scripts/generate-video-export-katex.mjs",
"gen:video-export-noto-cjk": "node scripts/generate-video-export-noto-cjk.mjs",
"gen:video-export-noto-script-fonts": "node scripts/generate-video-export-noto-script-fonts.mjs",
"dev": "node scripts/build-standalone-player.mjs && next dev",
"build": "node scripts/assert-vendor-maic-importer.mjs && node scripts/build-standalone-player.mjs && next build",
"dev": "node scripts/sync-interactive-assets.mjs && node scripts/build-standalone-player.mjs && next dev",
"build": "node scripts/sync-interactive-assets.mjs && node scripts/assert-vendor-maic-importer.mjs && node scripts/build-standalone-player.mjs && next build",
"start": "next start",
"db:up": "docker compose -p openmaic-dev-db -f docker-compose.db.yml up -d --wait postgres",
"db:down": "docker compose -p openmaic-dev-db -f docker-compose.db.yml stop postgres",
@@ -33,7 +33,8 @@
"eval:whiteboard": "tsx --conditions=import eval/whiteboard-layout/runner.ts",
"eval:outline-language": "tsx eval/outline-language/runner.ts",
"eval:orchestration:answering": "tsx --conditions=import eval/orchestration/answering-runner.ts",
"eval:orchestration:answer-content": "tsx --conditions=import eval/orchestration/answer-content-runner.ts"
"eval:orchestration:answer-content": "tsx --conditions=import eval/orchestration/answer-content-runner.ts",
"sync:interactive-assets": "node scripts/sync-interactive-assets.mjs"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.103",
@@ -110,7 +111,7 @@
"js-yaml": "4.3.0",
"jsonrepair": "^3.13.2",
"jszip": "^3.10.1",
"katex": "^0.16.33",
"katex": "0.16.38",
"linkedom": "^0.18.13",
"lodash": "4.18.1",
"lucide-react": "^0.562.0",
@@ -170,7 +171,10 @@
"zod": "^4.3.5",
"zustand": "^5.0.10",
"remark-cjk-friendly": "^2.3.1",
"@openmaic/geometry": "workspace:*"
"@openmaic/geometry": "workspace:*",
"three": "0.160.0",
"chart.js": "4.4.8",
"@tailwindcss/browser": "4.1.18"
},
"devDependencies": {
"@electric-sql/pglite": "^0.3.16",
+6
View File
@@ -26,3 +26,9 @@ PBL scenes use the same `AICallFn` seam for single-call planning. Hosts that
need an agentic fallback may inject `pblLoopFallback`; provider selection and
fallback execution remain caller-owned. The two planner templates ship under
`prompts-pbl/` and resolve relative to the installed package.
## Interactive libraries
`INTERACTIVE_LIBRARIES` is the source of truth for pinned versions, compatible CDN URL forms, prompt guidance, and build-time copied files. `rewriteInteractiveLibraries(html, baseUrl?)` preserves authored bytes outside recognized resource references and returns unmatched-host counts. `postProcessInteractiveHtml` applies this mapping and upgrades legacy platform KaTeX markup. It injects the hosted KaTeX (stylesheet, library and auto-render, loaded synchronously ahead of the page's own scripts) only when the page contains math: in page text, as TeX in inline script strings (JS-escaped or `String.raw`), or as calls to the KaTeX API. Pages without math get no KaTeX. Re-processing replaces the platform's KaTeX markup with markup for the current asset base, and re-points library URLs under the default `/vendor/interactive` path; library URLs that an earlier pass pointed at a former absolute `INTERACTIVE_ASSET_BASE_URL` are not recognized and stay as written. Browser consumers can import these APIs from `@openmaic/generation/browser`.
Hosts must copy the manifest's files from the exact installed package versions and serve them below `/vendor/interactive` (or the public `INTERACTIVE_ASSET_BASE_URL` setting), including fonts and Three.js addons, with CORS enabled for sandboxed frames. The OpenMAIC app runs `scripts/sync-interactive-assets.mjs` at install/build/dev time. Compatible version rules accept exact/partial/wildcard/caret/tilde versions with the same major for stable libraries and same minor for pre-1.0 libraries, without downgrading concrete requested minimums; unversioned and `latest` URLs select the hosted pin. Other ranges/tags, query parameters, unsupported paths, legacy Tailwind v3 Play CDN, and Google Fonts stay authored. Literal ESM imports and import-map values are handled; computed imports, ordinary JS strings, external CSS contents, and import-map scope keys are untouched.
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@openmaic/generation",
"version": "0.3.23",
"version": "0.3.24",
"description": "Pure generation pipeline contracts and packaged prompt assets for MAIC consumers.",
"type": "module",
"main": "./dist/index.js",
@@ -49,11 +49,14 @@
"dependencies": {
"@openmaic/dsl": "workspace:^",
"jsonrepair": "^3.13.2",
"katex": "^0.16.22",
"katex": "0.16.38",
"nanoid": "^5.1.6",
"parse5": "^8.0.1",
"partial-json": "^0.1.7",
"@openmaic/geometry": "workspace:^"
"@openmaic/geometry": "workspace:^",
"es-module-lexer": "^2.0.0",
"postcss": "^8.5.15",
"postcss-value-parser": "^4.2.0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org",
@@ -0,0 +1 @@
{{interactiveLibraryAllowlist}}
@@ -1,3 +1,3 @@
## 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.
@@ -15,3 +15,15 @@ export * from './pbl/operations/kernel/proficiency.js';
export * from './pbl/operations/kernel/progress.js';
export * from './pbl/operations/kernel/runtime-events.js';
export * from './pbl/operations/kernel/task-completion.js';
export { postProcessInteractiveHtml } from './interactive-post-processor.js';
export {
rewriteInteractiveLibraries,
mapInteractiveLibraryUrl,
} from './interactive-library-mapper.js';
export {
INTERACTIVE_LIBRARIES,
INTERACTIVE_ASSET_PATH,
interactiveAssetBaseUrl,
interactiveLibraryUrl,
} from './interactive-libraries.js';
@@ -113,3 +113,13 @@ export type {
} from './outline-types.js';
export * from './prompts/index.js';
export {
rewriteInteractiveLibraries,
mapInteractiveLibraryUrl,
} from './interactive-library-mapper.js';
export {
INTERACTIVE_LIBRARIES,
interactiveLibraryPrompt,
interactiveLibraryUrl,
} from './interactive-libraries.js';
@@ -0,0 +1,87 @@
/** Single source of truth for prompt URLs, CDN compatibility, and copied assets. */
export const INTERACTIVE_ASSET_PATH = '/vendor/interactive';
export const INTERACTIVE_LIBRARIES = [
{
package: 'katex',
license: 'LICENSE',
version: '0.16.38',
// Pre-1.0 releases are compatible only within this minor version.
compatibility: 'minor',
entry: 'dist/katex.min.js',
files: [
'dist/katex.min.js',
'dist/katex.min.css',
'dist/contrib/auto-render.min.js',
'dist/fonts',
],
aliases: {},
cdnjs: 'KaTeX',
},
{
package: 'three',
license: 'LICENSE',
version: '0.160.0',
compatibility: 'minor',
entry: 'build/three.module.js',
files: ['build', 'examples/jsm'],
aliases: {},
cdnjs: 'three.js',
},
{
package: 'chart.js',
license: 'LICENSE.md',
version: '4.4.8',
compatibility: 'major',
entry: 'dist/chart.umd.js',
files: ['dist/chart.umd.js'],
aliases: {
'dist/chart.umd.min.js': 'dist/chart.umd.js',
'chart.umd.js': 'dist/chart.umd.js',
'chart.umd.min.js': 'dist/chart.umd.js',
},
cdnjs: 'Chart.js',
},
{
package: '@tailwindcss/browser',
license: 'LICENSE',
version: '4.1.18',
compatibility: 'major',
entry: 'dist/index.global.js',
files: ['dist/index.global.js'],
aliases: {},
cdnjs: undefined,
},
] as const;
export function interactiveAssetBaseUrl(): string {
// Next.js exposes this public setting to browser consumers at build time.
try {
return process.env.INTERACTIVE_ASSET_BASE_URL?.trim() || INTERACTIVE_ASSET_PATH;
} catch {
return INTERACTIVE_ASSET_PATH;
}
}
export function interactiveLibraryUrl(
name: string,
file?: string,
baseUrl = interactiveAssetBaseUrl(),
): string {
const library = INTERACTIVE_LIBRARIES.find((entry) => entry.package === name);
if (!library) throw new Error(`Unknown interactive library: ${name}`);
return `${baseUrl.replace(/\/$/, '')}/${name}@${library.version}/${file ?? library.entry}`;
}
export function interactiveLibraryPrompt(): string {
return [
'## 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.',
...INTERACTIVE_LIBRARIES.map(
(library) =>
`- ${library.package} ${library.version}: ${interactiveLibraryUrl(library.package)}`,
),
`- Three.js addons import-map prefix: ${interactiveLibraryUrl('three', '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.',
].join('\n');
}
@@ -0,0 +1,250 @@
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
import { parse as parseModules } from 'es-module-lexer/js';
import postcss from 'postcss';
import valueParser from 'postcss-value-parser';
import {
INTERACTIVE_ASSET_PATH,
INTERACTIVE_LIBRARIES,
interactiveAssetBaseUrl,
interactiveLibraryUrl,
} from './interactive-libraries.js';
/** Versions without a lower bound (unversioned/latest) intentionally select our pin. */
function compatible(requested: string, version: string, policy: string): boolean {
if (!requested || requested === 'latest') return true;
// Accept exact, partial, wildcard, caret and tilde forms only; reject arbitrary ranges/tags.
const match = /^(?:\^|~)?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/.exec(requested);
if (!match) return false;
const hosted = version.split('.').map(Number);
if (Number(match[1]) !== hosted[0]) return false;
if (policy === 'minor' && match[2] !== String(hosted[1])) return false;
// Never downgrade a concrete requested minimum to an older hosted version.
const minimum = [Number(match[1]), Number(match[2]) || 0, Number(match[3]) || 0];
for (let i = 0; i < 3; i++) {
if (minimum[i] !== hosted[i]) return minimum[i]! < hosted[i]!;
}
return true;
}
export function mapInteractiveLibraryUrl(
input: string,
baseUrl = interactiveAssetBaseUrl(),
): string | undefined {
const localBase = interactiveAssetBaseUrl().replace(/\/$/, '');
if (input.startsWith(`${INTERACTIVE_ASSET_PATH}/`))
return baseUrl.replace(/\/$/, '') + input.slice(INTERACTIVE_ASSET_PATH.length);
if (localBase.startsWith('/') && input.startsWith(`${localBase}/`))
return baseUrl.replace(/\/$/, '') + input.slice(localBase.length);
let url: URL;
try {
url = new URL(input.startsWith('//') ? `https:${input}` : input);
} catch {
return undefined;
}
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.port)
return undefined;
// Query parameters can change CDN output (e.g. ?module, ?plugins=). Never guess.
if (url.search || url.hash) return undefined;
let name: string;
let version: string;
let file: string;
if (url.hostname === 'cdn.jsdelivr.net' || url.hostname === 'unpkg.com') {
const path =
url.hostname === 'cdn.jsdelivr.net'
? /^\/npm\/(.+)$/.exec(url.pathname)?.[1]
: url.pathname.slice(1);
if (!path) return undefined;
if (path.includes('%')) return undefined;
const match = /^(@[^/]+\/[^/@]+|[^/@]+)(?:@([^/]+))?(?:\/(.*))?$/.exec(path);
if (!match) return undefined;
name = match[1]!;
version = match[2] ?? '';
file = match[3] ?? '';
} else if (url.hostname === 'cdnjs.cloudflare.com') {
const match = /^\/ajax\/libs\/([^/]+)\/([^/]+)\/(.+)$/.exec(url.pathname);
if (!match) return undefined;
const library = INTERACTIVE_LIBRARIES.find((entry) => entry.cdnjs === match[1]);
if (!library) return undefined;
name = library.package;
version = match[2]!;
file = match[3]!;
if (name === 'katex' && !file.startsWith('dist/')) file = `dist/${file}`;
if (name === 'three' && /^three(?:\.module)?(?:\.min)?\.js$/.test(file)) file = `build/${file}`;
} else return undefined;
const library = INTERACTIVE_LIBRARIES.find((entry) => entry.package === name);
if (!library || !compatible(version, library.version, library.compatibility)) return undefined;
if (!file) file = library.entry;
const aliases: Readonly<Record<string, string>> = library.aliases;
file = aliases[file] ?? file;
if (file.includes('..') || file.includes('%') || file.includes('\\')) return undefined;
const allowed = library.files.some(
(entry) =>
file === entry ||
((entry === 'build' || entry === 'examples/jsm' || entry === 'dist/fonts') &&
file.startsWith(`${entry}/`) &&
(file.endsWith('.js') || /\.(woff2?|ttf)$/.test(file) || file.endsWith('/'))),
);
if (!allowed) return undefined;
return interactiveLibraryUrl(name, file, baseUrl);
}
type Node = DefaultTreeAdapterTypes.Node;
export function htmlNodes(html: string): Node[] {
const nodes: Node[] = [];
const pending: Node[] = [parse(html, { sourceCodeLocationInfo: true })];
while (pending.length) {
const node = pending.pop()!;
nodes.push(node);
if ('childNodes' in node) {
for (let index = node.childNodes.length - 1; index >= 0; index--)
pending.push(node.childNodes[index]!);
}
if ('content' in node) pending.push(node.content);
}
return nodes;
}
export interface HtmlPatch {
start: number;
end: number;
text: string;
}
export function patchHtml(html: string, patches: HtmlPatch[]): string {
const chunks: string[] = [];
let cursor = 0;
for (const patch of patches.sort((a, b) => a.start - b.start)) {
chunks.push(html.slice(cursor, patch.start), patch.text);
cursor = patch.end;
}
chunks.push(html.slice(cursor));
return chunks.join('');
}
export interface InteractiveLibraryMapping {
html: string;
/** Counts of unmatched resource references by host, for caller-side aggregation. */
unmatchedExternalHosts: Record<string, number>;
}
/** Parser-based, source-preserving mapping. Unknown or invalid markup stays authored. */
export function rewriteInteractiveLibraries(
html: string,
baseUrl = interactiveAssetBaseUrl(),
): InteractiveLibraryMapping {
const unmatchedExternalHosts: Record<string, number> = Object.create(null);
const patches: HtmlPatch[] = [];
const map = (value: string): string => {
const mapped = mapInteractiveLibraryUrl(value, baseUrl);
if (mapped) return mapped;
try {
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
const base = new URL(baseUrl, 'https://openmaic.invalid');
if (
['https:', 'http:'].includes(url.protocol) &&
!(
url.origin === base.origin &&
url.pathname.startsWith(`${base.pathname.replace(/\/$/, '')}/`)
)
) {
unmatchedExternalHosts[url.hostname] = (unmatchedExternalHosts[url.hostname] ?? 0) + 1;
}
} catch {
/* Relative and bare specifiers have no external host. */
}
return value;
};
for (const node of htmlNodes(html)) {
if (!('tagName' in node)) continue;
const attrName =
node.tagName === 'script' ? 'src' : node.tagName === 'link' ? 'href' : undefined;
const attr = node.attrs.find((entry) => entry.name === attrName);
if (attr) {
const mapped = map(attr.value);
const location = node.sourceCodeLocation?.attrs?.[attr.name];
if (mapped !== attr.value && location) {
patches.push({
start: location.startOffset,
end: location.endOffset,
text: `${attr.name}="${mapped.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"`,
});
for (const name of ['integrity', 'crossorigin']) {
const range = node.sourceCodeLocation?.attrs?.[name];
if (range)
patches.push({
start: range.startOffset,
end: range.endOffset,
text: name === 'crossorigin' ? 'crossorigin="anonymous"' : '',
});
}
}
}
const textNode = node.childNodes.find((child) => child.nodeName === '#text') as
| DefaultTreeAdapterTypes.TextNode
| undefined;
const range = textNode?.sourceCodeLocation;
if (!textNode || !range) continue;
let rewritten = textNode.value;
const type = node.attrs
.find((entry) => entry.name === 'type')
?.value.trim()
.toLowerCase();
if (node.tagName === 'style') {
try {
const css = postcss.parse(rewritten);
css.walkAtRules(/^import$/i, (rule) => {
const params = valueParser(rule.params);
const first = params.nodes.find(
(entry) => entry.type !== 'space' && entry.type !== 'comment',
);
const value =
first?.type === 'string'
? first
: first?.type === 'function' && first.value.toLowerCase() === 'url'
? first.nodes.find((entry) => entry.type === 'string' || entry.type === 'word')
: undefined;
if (value && 'value' in value) value.value = map(value.value);
rule.params = params.toString();
});
rewritten = css.toString();
} catch {
/* Leave malformed CSS untouched. */
}
} else if (node.tagName === 'script' && type === 'importmap') {
try {
JSON.parse(rewritten);
// Rewrite string values in place so the authored JSON layout is preserved.
// Keys (specifiers and scope prefixes) are left alone; non-URL values map to themselves.
rewritten = rewritten.replace(/"(?:[^"\\]|\\.)*"(\s*:)?/g, (token, key?: string) => {
if (key) return token;
const value = JSON.parse(token) as string;
const mapped = map(value);
return mapped === value ? token : JSON.stringify(mapped).replace(/</g, '\\u003c');
});
} catch {
/* Leave malformed import maps untouched. */
}
} else if (node.tagName === 'script' && type === 'module') {
try {
const [imports] = parseModules(rewritten);
const edits: HtmlPatch[] = [];
for (const entry of imports) {
if (!entry.n || entry.d === -2) continue;
const mapped = map(entry.n);
if (mapped === entry.n) continue;
const quoted = entry.d >= 0;
edits.push({
start: entry.s,
end: entry.e,
text: quoted ? JSON.stringify(mapped) : mapped,
});
}
rewritten = patchHtml(rewritten, edits);
} catch {
/* Leave invalid JavaScript untouched. */
}
}
if (rewritten !== textNode.value)
patches.push({ start: range.startOffset, end: range.endOffset, text: rewritten });
}
return { html: patchHtml(html, patches), unmatchedExternalHosts };
}
@@ -10,67 +10,107 @@
*/
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
import {
htmlNodes,
patchHtml,
rewriteInteractiveLibraries,
type HtmlPatch,
} from './interactive-library-mapper.js';
import { interactiveLibraryUrl } from './interactive-libraries.js';
/** Marks the KaTeX markup this module injects, so a second pass adds nothing. */
/** Marks the KaTeX markup this module owns; a later pass replaces it instead of adding more. */
const KATEX_INJECTION_MARKER = 'data-openmaic-katex';
/**
* Main entry point: post-process generated interactive HTML
* Converts LaTeX delimiters and injects KaTeX rendering resources.
*
* The platform's KaTeX is injected even when the page references KaTeX itself.
* The page's own copy may not be there when the page needs it (an unreachable
* CDN, a wrong `integrity` hash, an `async` tag), and a page that then calls
* `renderMathInElement` or `katex.render` aborts with a ReferenceError.
* The page's markup is left as authored.
* Re-process stored HTML as well as newly generated pages. Platform-owned
* KaTeX markup (marked with {@link KATEX_INJECTION_MARKER}, including the
* #1966 CDN injection) is removed and injected again for the current asset
* base, so a page re-processed with the same base comes out byte-identical.
*/
export function postProcessInteractiveHtml(html: string): string {
// Convert LaTeX delimiters while protecting script tags
let processed = convertLatexDelimiters(html);
if (!processed.includes(KATEX_INJECTION_MARKER)) {
processed = injectKatex(processed);
let processed = rewriteInteractiveLibraries(stripKatexInjection(html)).html;
const patches: HtmlPatch[] = [];
let hasMath = false;
for (const node of htmlNodes(processed)) {
if (node.nodeName !== '#text' || !('value' in node)) continue;
// Script/style/code examples and attributes are never LaTeX input.
let parent = node.parentNode;
let excluded = false;
while (parent) {
if (
'tagName' in parent &&
['script', 'style', 'pre', 'code', 'textarea', 'noscript', 'title'].includes(parent.tagName)
)
excluded = true;
parent = 'parentNode' in parent ? parent.parentNode : null;
}
if (excluded) {
// Scripts that call KaTeX, or hold TeX strings written into the page later, need it too.
if (
node.parentNode &&
'tagName' in node.parentNode &&
node.parentNode.tagName === 'script' &&
(scriptHasTex(node.value) || KATEX_CALL.test(node.value))
)
hasMath = true;
continue;
}
const range = node.sourceCodeLocation;
if (!range) continue;
const source = processed.slice(range.startOffset, range.endOffset);
if (/\\\([\s\S]+?\\\)|\\\[[\s\S]+?\\\]/.test(source)) hasMath = true;
const converted = source
.replace(/(?<!\\)\$\$([^$]+)\$\$/g, (_, tex: string) => {
hasMath = true;
return `\\[${tex}\\]`;
})
// Avoid common currency ($5, $10), interpolation, and escaped dollar signs.
.replace(/(?<![\\\w$])\$(?![\s{])([^$\n]+?)(?<!\s)\$(?![\w${])/g, (_, tex: string) => {
hasMath = true;
return `\\(${tex}\\)`;
});
if (converted !== source)
patches.push({ start: range.startOffset, end: range.endOffset, text: converted });
}
return processed;
processed = patchHtml(processed, patches);
return hasMath ? injectKatex(processed) : processed;
}
/**
* Convert LaTeX delimiters while protecting <script> tags.
*
* - Protects script blocks from modification
* - Converts $$...$$ to \[...\] (display math)
* - Converts $...$ to \(...\) (inline math)
* - Restores script blocks after conversion
*/
function convertLatexDelimiters(html: string): string {
const scriptBlocks: string[] = [];
// Protect script tags by replacing them with placeholders
let processed = html.replace(/<script\b[^>]*>[\s\S]*?(?:<\/script>|$)/gi, (match) => {
scriptBlocks.push(match);
return `__SCRIPT_BLOCK_${scriptBlocks.length - 1}__`;
});
// Convert display math: $$...$$ -> \[...\]
processed = processed.replace(/\$\$([^$]+)\$\$/g, '\\[$1\\]');
// Convert inline math: $...$ -> \(...\)
// Use non-greedy match and exclude newlines to avoid false positives
processed = processed.replace(/\$(?!\{)([^$\n]+?)\$(?!\{)/g, '\\($1\\)');
// Restore script blocks in a single pass. A replacer FUNCTION (not a string)
// is safe even when script content contains `$` — a function's return value
// is inserted literally, with no `$&`/`$1` substitution. The previous
// indexOf+substring loop rebuilt the entire string once per block, i.e.
// O(blocks × length), which balloons memory and blocks the event loop when
// the generated widget HTML contains many <script> tags.
processed = processed.replace(
/__SCRIPT_BLOCK_(\d+)__/g,
(whole, index) => scriptBlocks[Number(index)] ?? whole,
/** Remove marked nodes together with the newline the injection puts before each, and after the last. */
function stripKatexInjection(html: string): string {
const ranges = htmlNodes(html).flatMap((node) =>
'attrs' in node &&
node.attrs.some((attr) => attr.name === KATEX_INJECTION_MARKER) &&
node.sourceCodeLocation
? [node.sourceCodeLocation]
: [],
);
if (!ranges.length) return html;
const patches = ranges.map(({ startOffset, endOffset }) => ({
start: html[startOffset - 1] === '\n' ? startOffset - 1 : startOffset,
end: endOffset,
text: '',
}));
const last = patches.reduce((a, b) => (b.end > a.end ? b : a));
if (html[last.end] === '\n') last.end += 1;
return patchHtml(html, patches);
}
return processed;
/** A page script that calls KaTeX needs the #1966 guards and the library. */
const KATEX_CALL = /\b(?:katex\s*\.\s*render(?:ToString)?|renderMathInElement)\s*\(/;
/** TeX delimiters and common commands after a single backslash. */
const TEX =
/\\(?:[([]|begin\{|(?:frac|sqrt|sum|int|lim|cdot|times|div|pm|infty|alpha|beta|gamma|delta|theta|lambda|sigma|omega|Delta|vec|mathrm|mathbf)(?![A-Za-z]))/;
/** The same, JS-escaped (two backslashes) inside an ordinary string literal. */
const SCRIPT_TEX = new RegExp(`\\\\${TEX.source}`);
/** String.raw template literals keep TeX backslashes single. */
const RAW_TEMPLATE = /\bString\s*\.\s*raw\s*`((?:[^`\\]|\\[\s\S])*)`/g;
function scriptHasTex(source: string): boolean {
if (SCRIPT_TEX.test(source)) return true;
for (const match of source.matchAll(RAW_TEMPLATE)) if (TEX.test(match[1]!)) return true;
return false;
}
/**
@@ -81,17 +121,17 @@ function convertLatexDelimiters(html: string): string {
* `katex` and `renderMathInElement` exist when those scripts and their
* DOMContentLoaded handlers run. A page that loads its own KaTeX later
* replaces these globals with the same library.
* - When the CDN is unreachable, no-op stand-ins keep the page's own math
* - When the hosted asset fails to load, no-op stand-ins keep the page's own math
* calls from throwing; the math then stays as written.
* - The render handler is registered before the page's handlers, so the
* observer is already watching when the page fills its labels.
*/
function injectKatex(html: string): string {
const katexInjection = `
<link ${KATEX_INJECTION_MARKER} rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script ${KATEX_INJECTION_MARKER} src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script ${KATEX_INJECTION_MARKER} src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script ${KATEX_INJECTION_MARKER}>
<link ${KATEX_INJECTION_MARKER}="2" rel="stylesheet" href="${interactiveLibraryUrl('katex', 'dist/katex.min.css')}">
<script ${KATEX_INJECTION_MARKER}="2" src="${interactiveLibraryUrl('katex')}"></script>
<script ${KATEX_INJECTION_MARKER}="2" src="${interactiveLibraryUrl('katex', 'dist/contrib/auto-render.min.js')}"></script>
<script ${KATEX_INJECTION_MARKER}="2">
(function () {
if (!window.katex) {
window.katex = {
@@ -2,6 +2,7 @@
* Prompt loader for packaged Markdown templates and snippets.
*/
import { interactiveLibraryPrompt, interactiveLibraryUrl } from '../interactive-libraries.js';
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -31,7 +32,9 @@ export function loadSnippet(
const snippetPath = join(promptsDir, 'snippets', `${snippetId}.md`);
try {
return readFileSync(snippetPath, 'utf-8').trim();
return readFileSync(snippetPath, 'utf-8')
.trim()
.replace('{{interactiveLibraryAllowlist}}', interactiveLibraryPrompt());
} catch {
// Fail loud rather than silently shipping `{{snippet:foo}}` to the model.
throw new Error(`Snippet not found: ${snippetId}`);
@@ -88,6 +91,10 @@ export function loadPrompt(
}
userPromptTemplate = processSnippets(userPromptTemplate, promptsDir);
systemPrompt = systemPrompt
.replaceAll('{{threeModuleUrl}}', interactiveLibraryUrl('three'))
.replaceAll('{{threeAddonsUrl}}', interactiveLibraryUrl('three', 'examples/jsm/'));
return {
id: promptId,
systemPrompt,
@@ -20,6 +20,7 @@ export type PromptId =
/** Snippets referenced by generation-owned prompt templates. */
export type SnippetId =
| 'interactive-libraries'
| 'interactive-math'
| 'interactive-observation'
| 'json-output-rules'
@@ -231,6 +231,8 @@ Return ONLY the HTML document, no markdown fences or explanations.
- [ ] **Pyodide uses async execution** - `runPythonAsync()` not `runPython()`
{{snippet:interactive-libraries}}
{{snippet:interactive-math}}
{{snippet:interactive-observation}}
@@ -158,6 +158,8 @@ So highlight/annotate/reveal can target nodes, give each node a stable id:
Return exactly ONE complete HTML document. No markdown fences, no duplication.
{{snippet:interactive-libraries}}
{{snippet:interactive-math}}
{{snippet:interactive-observation}}
@@ -426,6 +426,8 @@ render(); // Render paths publish the current state, including before Start.
**Avoid**: Complex dependencies like reading localStorage before events are bound, multiple async operations during init, or chained promises for game start.
{{snippet:interactive-libraries}}
{{snippet:interactive-math}}
{{snippet:interactive-observation}}
@@ -354,6 +354,8 @@ Before finalizing the HTML, verify that:
Follow the requested language directive exactly when provided.
{{snippet:interactive-libraries}}
{{snippet:interactive-math}}
{{snippet:interactive-observation}}
@@ -317,6 +317,8 @@ const objectY = baseY - BOTTOM_MARGIN - (value / maxValue) * playableHeight;
- [ ] **Animation is OBVIOUS, not subtle - user can tell simulation is running**
{{snippet:interactive-libraries}}
{{snippet:interactive-math}}
{{snippet:interactive-observation}}
@@ -7,7 +7,7 @@ Generate a self-contained HTML 3D visualization with embedded widget configurati
Your output must be a complete HTML document with:
1. **Standard HTML5 structure**
2. **Three.js loaded from CDN** (use unpkg or cdnjs)
2. **Three.js loaded from the deployment** (use the available library URLs below)
3. **Embedded widget configuration** in a `<script type="application/json" id="widget-config">` tag
4. **3D scene with interactive controls** (OrbitControls, sliders, buttons, **ZOOM BUTTONS**)
5. **Mobile-responsive design**
@@ -355,8 +355,8 @@ const earth = new THREE.Mesh(earthGeometry, earthMaterial);
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
"three": "{{threeModuleUrl}}",
"three/addons/": "{{threeAddonsUrl}}"
}
}
</script>
@@ -663,6 +663,8 @@ Return ONLY the HTML document, no markdown fences or explanations.
- The output must end with exactly one `</html>` tag
{{snippet:interactive-libraries}}
{{snippet:interactive-math}}
{{snippet:interactive-observation}}
@@ -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)
@@ -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',
@@ -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.
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link href="https://fonts.googleapis.com/css2?family=Roboto" rel="stylesheet" />
<style>
/* @import 'https://unpkg.com/not-a-library'; */
@import 'https://unpkg.com/katex@0.16/dist/katex.min.css';
</style>
</head>
<body>
<canvas id="chart" class="w-full"></canvas>
<script>
new Chart(document.getElementById('chart'), {
type: 'bar',
data: { labels: ['A'], datasets: [{ data: [3] }] },
});
const example = '<script src="https://cdn.jsdelivr.net/npm/chart.js"><\/script>';
</script>
</body>
</html>
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"
integrity="old-hash"
crossorigin="use-credentials"
/>
<script src="https://cdn.jsdelivr.net/npm/katex@^0.16.9/dist/katex.min.js"></script>
<script src="https://unpkg.com/katex@latest/dist/contrib/auto-render.min.js"></script>
<style>
@import url('https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css') screen;
</style>
</head>
<body>
<div id="cards">$E=mc^2$</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
renderMathInElement(document.body, { throwOnError: false });
katex.render('2^{-2}', document.getElementById('cards'));
});
</script>
</body>
</html>
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<canvas id="scene"></canvas>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Vector3 } from 'https://cdn.jsdelivr.net/npm/three@~0.160.0/build/three.module.js';
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('scene') });
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 100);
const controls = new OrbitControls(camera, renderer.domElement);
const helpers = import('https://unpkg.com/three@0.160/build/three.module.js');
</script>
</body>
</html>
@@ -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 = `<script SRC='https://unpkg.com/chart.js@4' integrity='sha384-old' crossorigin='use-credentials'></script><!-- <script src='https://unpkg.com/chart.js'> --><script>const html='<script src="https://unpkg.com/chart.js">';</script>`;
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("<!-- <script src='https://unpkg.com/chart.js'> -->");
expect(html).toContain('const html=\'<script src="https://unpkg.com/chart.js">\'');
expect(rewriteInteractiveLibraries(html).html).toBe(html);
});
it('supports CSS imports, ESM static/dynamic imports, and scoped import-map values', () => {
const url = 'https://unpkg.com/chart.js@4';
const input = `<style>@import url('${url}');</style><script type="module">import x from '${url}'; export * from '${url}'; import('${url}'); const literal='${url}';</script><script type="importmap">{"scopes":{"/scope/":{"chart":"${url}"}}}</script>`;
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 = `<script type="importmap">\n {\n "imports": { "three": "${three}", "x": "https://esm.sh/x" }\n }\n</script><script type="Module">import "${three}";</script>`;
const html = rewriteInteractiveLibraries(input).html;
expect(html).toBe(input.replaceAll(three, interactiveLibraryUrl('three')));
});
it('counts unmatched external resource hosts', () => {
const input =
'<script src="https://cdn.tailwindcss.com"></script><link href="https://fonts.googleapis.com/css2"><script src="https://cdn.tailwindcss.com"></script>';
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 =
'<script data-openmaic-katex="2" src="/vendor/interactive/katex@0.16.38/dist/katex.min.js">';
const SYNC_AUTO_RENDER =
'<script data-openmaic-katex="2" src="/vendor/interactive/katex@0.16.38/dist/contrib/auto-render.min.js">';
it.each([
'<p>$x+1$</p>',
'<p>$$2+2$$</p>',
'<p>\\(x\\)</p>',
'<p>\\[x\\]</p>',
`<div id="a"></div><script>katex.render('x^2', document.getElementById('a'));</script>`,
`<div id="a"></div><script type="module">a.innerHTML = katex.renderToString('x^2');</script>`,
`<p>@@x@@</p><script>renderMathInElement(document.body, { delimiters: [{ left: '@@', right: '@@', display: false }] });</script>`,
`<div id="a"></div><script>a.textContent = 'Area \\\\(\\\\pi r^2\\\\)';</script>`,
`<script>const f = '\\\\frac{1}{2}';</script>`,
'<p id="a"></p><script>a.textContent = String.raw`\\(\\frac{1}{2}\\)`;</script>',
'<p id="a"></p><script>a.textContent = String.raw`Area: \\sqrt{${n}}`;</script>',
'<p>$x$</p><script async src="/app.js"></script>',
])('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(
'<link data-openmaic-katex="2" rel="stylesheet" href="/vendor/interactive/katex@0.16.38/dist/katex.min.css">',
);
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 =
'<!doctype html><html><head></head><body><p>$x$</p><script src="https://unpkg.com/chart.js@4"></script></body></html>';
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 = '<p>No math</p><script src="/app.js"></script>';
expect(postProcessInteractiveHtml(input)).toBe(input);
});
it.each([
'<title>Save $5 or $10 today</title><p>No math</p>',
'<title>$x$</title><p>No math</p>',
'<p>Prices: $5 and $10</p>',
'<script>const x="$x$"; const y=`${value}`;</script>',
'<script>const re = /\\(\\d+\\)/; const t = `a\\(b`;</script>',
'<script>const p = String.raw`C:\\temp\\new`;</script>',
'<style>.x::before{content:"$x$"}</style>',
'<p data-value="$x$">No math</p>',
'<pre>$x$</pre><code>$x$</code>',
'<p>Escaped \\$x$</p>',
])('makes no KaTeX request for %s', (input) =>
expect(postProcessInteractiveHtml(input)).toBe(input),
);
it('upgrades stored #1966 markup and removes unnecessary resources', () => {
const legacy =
'<head><link data-openmaic-katex rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"><script data-openmaic-katex>oldRuntime()</script></head><body>No math</body>';
const output = postProcessInteractiveHtml(legacy);
expect(output).not.toContain('katex');
expect(output).not.toContain('oldRuntime');
expect(postProcessInteractiveHtml(output)).toBe(output);
});
});
@@ -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 = [
'</script></body></html>',
].join('\n');
const INJECTED_SCRIPT = /<script data-openmaic-katex>([\s\S]*?)<\/script>/;
const INJECTED_SCRIPT = /<script data-openmaic-katex="2">([\s\S]*?)<\/script>/;
const PAGE_INLINE_SCRIPT = /<script>([\s\S]*?)<\/script>/;
/**
@@ -87,9 +87,9 @@ describe('KaTeX injection', () => {
const injected = processed.indexOf('data-openmaic-katex');
expect(injected).toBeGreaterThan(processed.indexOf('<head>'));
expect(injected).toBeLessThan(processed.indexOf('<meta charset'));
expect(processed.match(/<script data-openmaic-katex>/g) ?? []).toHaveLength(1);
// The page's own tags stay exactly as authored.
expect(processed).toContain(PAGE_WITH_OWN_KATEX.slice(PAGE_WITH_OWN_KATEX.indexOf('<meta')));
expect(processed.match(/<script data-openmaic-katex="2">/g) ?? []).toHaveLength(1);
expect(processed).not.toContain('cdn.jsdelivr.net');
expect(processed).toContain('/vendor/interactive/katex@0.16.38/dist/katex.min.js');
expect(postProcessInteractiveHtml(processed)).toBe(processed);
});
@@ -107,24 +107,21 @@ describe('KaTeX injection', () => {
expect((context.pageInit as { observing: boolean }).observing).toBe(true);
});
it('leaves a page without math unchanged apart from the injection', () => {
it('leaves a page without math unchanged', () => {
const source = '<html><head><title>Plain</title></head><body><p>No math</p></body></html>';
const processed = postProcessInteractiveHtml(source);
const injectionEnd = processed.lastIndexOf('</script>') + '</script>'.length;
expect(processed.slice(0, '<html><head>'.length)).toBe('<html><head>');
expect(processed.slice(injectionEnd).trim()).toBe(source.slice('<html><head>'.length));
expect(postProcessInteractiveHtml(source)).toBe(source);
});
it('places the injection in head for documents without an explicit head', () => {
const withHtml = postProcessInteractiveHtml('<html><body>x</body></html>');
const withHtml = postProcessInteractiveHtml('<html><body>$x$</body></html>');
expect(withHtml.startsWith('<html><head>\n<link data-openmaic-katex')).toBe(true);
const withDoctype = postProcessInteractiveHtml('<!DOCTYPE html><p>x</p>');
const withDoctype = postProcessInteractiveHtml('<!DOCTYPE html><p>$x$</p>');
expect(withDoctype.startsWith('<!DOCTYPE html><head>\n<link data-openmaic-katex')).toBe(true);
});
it('ignores a head tag that only appears inside a comment', () => {
const processed = postProcessInteractiveHtml(
'<!-- <head> --><html><head><title>t</title></head><body></body></html>',
'<!-- <head> --><html><head><title>t</title></head><body>$x$</body></html>',
);
expect(processed.startsWith('<!-- <head> --><html><head>\n<link data-openmaic-katex')).toBe(
true,
@@ -863,3 +863,11 @@ slots:
يخدم مزوّد `firecrawl` نفسه البحث على الويب أيضًا (`POST /v2/search`): مفتاح واحد وعنوان أساسي واحد يكفيان للأمرين، لذا يمكن تعيين مزوّد واحد في `slots.webSearch` و`slots.webFetch` معًا. تضبط المتغيرات القديمة `FIRECRAWL_API_KEY` و`FIRECRAWL_BASE_URL` هذا المزوّد نفسه، ويبقى جلب صفحات الويب على `builtin` إلى أن يُعيَّن `webFetch` له.
قبل إرسال رابط إلى خدمة مستضافة، تُطبق بوابة روابط الجلسة والتطبيع الصارم للرابط وفحص جميع عناوين DNS المحلية. تُطبق الفحوص نفسها على الرابط النهائي الذي تعيده الخدمة قبل قبول المحتوى. تتحكم الخدمات المستضافة في DNS والتحويلات الوسيطة الخاصة بها، لذلك لا تستطيع الفحوص المحلية تثبيت اتصالاتها الشبكية. يظل النص بيانات غير موثوقة، ويخضع لاكتشاف صفحات التحقق وحدود الحجم بالبايت وعلامة الاقتطاع، مع حفظ مواد الويب وصيغة النتيجة `{trusted, untrusted}` كما في السابق.
## مكتبات المشاهد التفاعلية
تحمّل المشاهد التفاعلية KaTeX وThree.js (مع addons) وChart.js وبيئة تشغيل Tailwind v4 للمتصفح من النشر نفسه بدلًا من شبكة CDN عامة. تنسخ الأوامر `pnpm install` و`pnpm dev` و`pnpm build` الإصدارات المثبّتة من الحزم المثبَّتة إلى `public/vendor/interactive`، وتُقدَّم من المسار `/vendor/interactive`. لا يُحمَّل KaTeX إلا في الصفحات التي تحتوي على صيغ رياضية.
`INTERACTIVE_ASSET_BASE_URL` إعداد عام يُحدَّد وقت البناء. لتقديم هذه الملفات من شبكة CDN خاصة بك أو من تخزين كائنات، انسخ `public/vendor/interactive` مع الحفاظ على مسارات الحزمة/الإصدار، واضبط المتغير على عنوان URL الأساسي الجديد (مثل `https://assets.example.com/interactive`) ثم أعد البناء؛ وفي Docker مرّره عبر `--build-arg`. ولأن المشاهد التفاعلية تعمل داخل إطارات iframe معزولة، يجب أن يقدّم المضيف ملفات JavaScript وCSS والخطوط بأنواع MIME الصحيحة وأن يرسل `Access-Control-Allow-Origin: *`.
يُعاد معالجة HTML التفاعلي المحفوظ عند فتحه في الصف وقبل التصدير إلى HTML مستقل أو فيديو، لذا تُعاد كتابة مراجع CDN العامة المتوافقة إلى هذه المكتبات دون إعادة توليد المقرر. أما المراجع إلى مكتبات أخرى أو إصدارات غير متوافقة أو Tailwind v3 Play CDN القديم (`cdn.tailwindcss.com`) أو Google Fonts فتبقى كما هي، وما زالت تتطلب الوصول إلى تلك المضيفات.
@@ -863,3 +863,11 @@ DeepSeek は `deepseek` プリセットでホスト型 Markdown 抽出も提供
同じ `firecrawl` プロバイダーは Web 検索(`POST /v2/search`)にも使えます。1 つのキーとベース URL で両方をまかなうため、1 つのプロバイダーを `slots.webSearch` と `slots.webFetch` の両方に割り当てられます。従来の `FIRECRAWL_API_KEY` と `FIRECRAWL_BASE_URL` 変数はこのプロバイダーを設定します。`webFetch` をこのプロバイダーに割り当てるまで、Webページ取得は `builtin` のままです。
ホスト型サービスに URL を送る前に、セッションの URL 許可、厳密な URL 正規化、ローカル DNS の全アドレス検査を行います。返された最終 URL も、内容を受け入れる前に同じ検査を受けます。ホスト型サービス自身の DNS と中間リダイレクトはサービス側が制御するため、ローカルの検査ではそのネットワーク接続を固定できません。返されたテキストは信頼できないデータのままであり、認証チャレンジページの検出、バイト数制限、切り詰め表示、既存の Web 素材保存と `{trusted, untrusted}` の結果形式を適用します。
## インタラクティブシーンのライブラリ
インタラクティブシーンは、KaTeX、Three.js(addons を含む)、Chart.js、Tailwind v4 ブラウザランタイムを公開 CDN ではなくデプロイ自身から読み込みます。`pnpm install`、`pnpm dev`、`pnpm build` は、インストール済みパッケージから固定バージョンを `public/vendor/interactive` にコピーし、`/vendor/interactive` で配信します。KaTeX は数式を含むページでのみ読み込まれます。
`INTERACTIVE_ASSET_BASE_URL` は公開されるビルド時の設定です。独自の CDN やオブジェクトストレージからこれらのファイルを配信する場合は、パッケージ/バージョンのパスを保ったまま `public/vendor/interactive` をコピーし、この変数を新しいベース URL(例:`https://assets.example.com/interactive`)に設定して再ビルドしてください。Docker では `--build-arg` で渡します。インタラクティブシーンはサンドボックス化された iframe で動作するため、配信先は JavaScript、CSS、フォントを正しい MIME タイプで返し、`Access-Control-Allow-Origin: *` を送信する必要があります。
保存済みのインタラクティブ HTML は、教室で開くときと、スタンドアロン HTML または動画をエクスポートする前に再処理されます。そのため、これらのライブラリを指す互換性のある公開 CDN 参照は、コースを再生成しなくても書き換えられます。その他のライブラリ、互換性のないバージョン、旧 Tailwind v3 Play CDN(`cdn.tailwindcss.com`)、Google Fonts への参照は変更されず、引き続きそれらのホストへのネットワークアクセスが必要です。
@@ -863,3 +863,11 @@ Unlike `kimi`, `firecrawl` has its own key: declare a provider with `preset: fir
The same `firecrawl` provider also serves web search (`POST /v2/search`): one key and base URL cover both, so a single provider can be assigned to `slots.webSearch` and `slots.webFetch`. The legacy `FIRECRAWL_API_KEY` and `FIRECRAWL_BASE_URL` variables configure that provider; web fetch stays on `builtin` until `webFetch` is assigned to it.
The session URL gate, strict URL normalization and all local DNS address checks run before forwarding a URL to a hosted reader. The same checks run against a reported final URL before accepting its content. Hosted readers control their own DNS and intermediate redirects; local checks cannot pin their networks. Their text remains untrusted, with challenge-page detection, byte limits, a truncation notice and the existing web material persistence and `{trusted, untrusted}` result.
## Interactive scene libraries
Interactive scenes load KaTeX, Three.js (with its addons), Chart.js and the Tailwind v4 browser runtime from the deployment instead of a public CDN. `pnpm install`, `pnpm dev` and `pnpm build` copy the pinned versions from the installed packages into `public/vendor/interactive`, which is served at `/vendor/interactive`. KaTeX loads only on pages that contain math.
`INTERACTIVE_ASSET_BASE_URL` is a public, build-time setting. To serve these files from your own CDN or object storage, copy `public/vendor/interactive` with its package/version paths intact, set the variable to the new base URL (for example `https://assets.example.com/interactive`) and rebuild; for Docker, pass it with `--build-arg`. Because interactive scenes run in sandboxed iframes, the host must serve JavaScript, CSS and fonts with their correct MIME types and send `Access-Control-Allow-Origin: *`.
Stored interactive HTML is reprocessed when it opens in the classroom and before standalone HTML or video export, so compatible public CDN references to these libraries are rewritten without regenerating the course. References to other libraries, incompatible versions, the legacy Tailwind v3 Play CDN (`cdn.tailwindcss.com`) and Google Fonts are left unchanged and still need network access to those hosts.
@@ -863,3 +863,11 @@ DeepSeek также предоставляет размещённое извле
Тот же провайдер `firecrawl` обслуживает и веб-поиск (`POST /v2/search`): один ключ и один базовый URL подходят для обоих, поэтому одного провайдера можно назначить и в `slots.webSearch`, и в `slots.webFetch`. Устаревшие переменные `FIRECRAWL_API_KEY` и `FIRECRAWL_BASE_URL` настраивают именно этого провайдера; загрузка веб-страниц остаётся на `builtin`, пока `webFetch` не назначен ему.
До передачи URL размещённому сервису выполняются проверка разрешённых URL сеанса, строгая нормализация URL и проверка всех адресов локального DNS. Те же проверки применяются к возвращённому конечному URL до принятия содержимого. Размещённый сервис управляет собственным DNS и промежуточными перенаправлениями, поэтому локальные проверки не могут закрепить его сетевые подключения. Текст остаётся недоверенными данными: применяются обнаружение страниц проверки доступа, ограничения размера в байтах, отметка об усечении, прежнее сохранение веб-материалов и формат результата `{trusted, untrusted}`.
## Библиотеки интерактивных сцен
Интерактивные сцены загружают KaTeX, Three.js (вместе с addons), Chart.js и браузерную среду Tailwind v4 из самого развёртывания, а не из публичного CDN. `pnpm install`, `pnpm dev` и `pnpm build` копируют закреплённые версии из установленных пакетов в `public/vendor/interactive`, откуда они отдаются по пути `/vendor/interactive`. KaTeX загружается только на страницах с формулами.
`INTERACTIVE_ASSET_BASE_URL` — публичная настройка времени сборки. Чтобы отдавать эти файлы со своего CDN или из объектного хранилища, скопируйте `public/vendor/interactive`, сохранив пути вида пакет/версия, укажите в переменной новый базовый URL (например, `https://assets.example.com/interactive`) и пересоберите приложение; для Docker передайте её через `--build-arg`. Интерактивные сцены работают в изолированных iframe, поэтому хост должен отдавать JavaScript, CSS и шрифты с корректными MIME-типами и заголовком `Access-Control-Allow-Origin: *`.
Сохранённый интерактивный HTML повторно обрабатывается при открытии в классе и перед экспортом в автономный HTML или видео, поэтому совместимые ссылки на эти библиотеки в публичных CDN переписываются без повторной генерации курса. Ссылки на другие библиотеки, несовместимые версии, устаревший Tailwind v3 Play CDN (`cdn.tailwindcss.com`) и Google Fonts остаются без изменений и по-прежнему требуют доступа к этим хостам.
@@ -863,3 +863,11 @@ DeepSeek 也通过 `deepseek` 预设提供托管 Markdown 提取,复用对话
同一个 `firecrawl` 服务商也提供联网搜索(`POST /v2/search`):一把密钥和一个基础地址同时用于两者,因此可以把同一个服务商同时指定给 `slots.webSearch` 和 `slots.webFetch`。旧版的 `FIRECRAWL_API_KEY` 和 `FIRECRAWL_BASE_URL` 变量配置的就是这个服务商;在把 `webFetch` 指定给它之前,网页抓取仍使用 `builtin`。
网址转发给托管服务之前,必须通过会话网址授权、严格网址规范化和全部本地 DNS 地址检查。接收内容前,也会对服务返回的最终网址执行相同检查。托管服务控制其自身的 DNS 和中间重定向,本地检查无法固定其网络连接。返回的文本仍是不可信数据,经过验证页面检测、字节限制和截断标记处理,并沿用网页素材持久化及 `{trusted, untrusted}` 结果结构。
## 交互场景依赖库
交互场景从部署自身加载 KaTeX、Three.js(含 addons)、Chart.js 和 Tailwind v4 浏览器运行时,而不是从公共 CDN 加载。`pnpm install`、`pnpm dev` 和 `pnpm build` 会把已安装包中的固定版本复制到 `public/vendor/interactive`,并通过 `/vendor/interactive` 提供。只有包含数学公式的页面才会加载 KaTeX。
`INTERACTIVE_ASSET_BASE_URL` 是公开的构建时配置。如需从自己的 CDN 或对象存储提供这些文件,请复制 `public/vendor/interactive` 并保留其中的包名/版本路径,把该变量设为新的基础地址(例如 `https://assets.example.com/interactive`)后重新构建;Docker 构建通过 `--build-arg` 传入。交互场景运行在沙箱 iframe 中,因此该主机必须以正确的 MIME 类型提供 JavaScript、CSS 和字体文件,并返回 `Access-Control-Allow-Origin: *`。
已保存的交互 HTML 会在课堂中打开时,以及导出独立 HTML 或视频之前重新处理,因此指向这些库的兼容公共 CDN 引用会被改写,无需重新生成课程。指向其他库、不兼容版本、旧版 Tailwind v3 Play CDN(`cdn.tailwindcss.com`)和 Google Fonts 的引用保持不变,仍需要能访问对应主机。
@@ -863,3 +863,11 @@ DeepSeek 也透過 `deepseek` 預設提供託管 Markdown 擷取,共用對話
同一個 `firecrawl` 供應商也提供網路搜尋(`POST /v2/search`):一把金鑰和一個基底網址同時用於兩者,因此可以把同一個供應商同時指定給 `slots.webSearch` 和 `slots.webFetch`。舊版的 `FIRECRAWL_API_KEY` 和 `FIRECRAWL_BASE_URL` 變數設定的就是這個供應商;在把 `webFetch` 指定給它之前,網頁擷取仍使用 `builtin`。
網址轉送至託管服務之前,必須通過工作階段網址授權、嚴格網址正規化及全部本機 DNS 位址檢查。接收內容前,也會對服務回傳的最終網址執行相同檢查。託管服務控制其自身的 DNS 和中間重新導向,本機檢查無法固定其網路連線。回傳的文字仍是不可信資料,經過驗證頁面偵測、位元組限制和截斷標記處理,並沿用網頁素材持久化及 `{trusted, untrusted}` 結果結構。
## 互動場景依賴函式庫
互動場景從部署本身載入 KaTeX、Three.js(含 addons)、Chart.js 和 Tailwind v4 瀏覽器執行環境,而不是從公開 CDN 載入。`pnpm install`、`pnpm dev` 和 `pnpm build` 會把已安裝套件中的固定版本複製到 `public/vendor/interactive`,並透過 `/vendor/interactive` 提供。只有包含數學公式的頁面才會載入 KaTeX。
`INTERACTIVE_ASSET_BASE_URL` 是公開的建置時設定。如需從自己的 CDN 或物件儲存提供這些檔案,請複製 `public/vendor/interactive` 並保留其中的套件名稱/版本路徑,把此變數設為新的基礎位址(例如 `https://assets.example.com/interactive`)後重新建置;Docker 建置透過 `--build-arg` 傳入。互動場景在沙箱 iframe 中執行,因此該主機必須以正確的 MIME 類型提供 JavaScript、CSS 和字型檔案,並回傳 `Access-Control-Allow-Origin: *`。
已儲存的互動 HTML 會在課堂中開啟時,以及匯出獨立 HTML 或影片之前重新處理,因此指向這些函式庫的相容公開 CDN 參照會被改寫,無需重新生成課程。指向其他函式庫、不相容版本、舊版 Tailwind v3 Play CDN(`cdn.tailwindcss.com`)和 Google Fonts 的參照保持不變,仍需要能存取對應主機。
+43 -2
View File
@@ -161,6 +161,9 @@ importers:
'@streamdown/math':
specifier: ^1.0.2
version: 1.0.2(react@19.2.3)
'@tailwindcss/browser':
specifier: 4.1.18
version: 4.1.18
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
@@ -173,6 +176,9 @@ importers:
animate.css:
specifier: ^4.1.1
version: 4.1.1
chart.js:
specifier: 4.4.8
version: 4.4.8
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -234,7 +240,7 @@ importers:
specifier: ^3.10.1
version: 3.10.1
katex:
specifier: ^0.16.33
specifier: 0.16.38
version: 0.16.38
linkedom:
specifier: ^0.18.13
@@ -380,6 +386,9 @@ importers:
temml:
specifier: ^0.13.1
version: 0.13.1
three:
specifier: 0.160.0
version: 0.160.0
tinycolor2:
specifier: ^1.6.0
version: 1.6.0
@@ -640,11 +649,14 @@ importers:
'@openmaic/geometry':
specifier: workspace:^
version: link:../geometry
es-module-lexer:
specifier: ^2.0.0
version: 2.0.0
jsonrepair:
specifier: ^3.13.2
version: 3.13.3
katex:
specifier: ^0.16.22
specifier: 0.16.38
version: 0.16.38
nanoid:
specifier: ^5.1.6
@@ -655,6 +667,12 @@ importers:
partial-json:
specifier: ^0.1.7
version: 0.1.7
postcss:
specifier: ^8.5.15
version: 8.5.23
postcss-value-parser:
specifier: ^4.2.0
version: 4.2.0
devDependencies:
typescript:
specifier: ^5
@@ -3518,6 +3536,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@langchain/community@0.0.57':
resolution: {integrity: sha512-tib4UJNkyA4TPNsTNChiBtZmThVJBr7X/iooSmKeCr+yUEha2Yxly3A4OAO95Vlpj4Q+od8HAfCbZih/1XqAMw==}
engines: {node: '>=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:
+39
View File
@@ -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.');
+1 -1
View File
@@ -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);
},
);
+16 -8
View File
@@ -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<string, string> = {
'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', () => {
"<script type=\"module\">import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';</script>",
].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<string, string> = {
'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', () => {
'}}</script>',
"<script type=\"module\">import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';</script>",
].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);
+22
View File
@@ -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);
+8 -1
View File
@@ -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);
}
}
+6
View File
@@ -60,6 +60,12 @@ const ALLOWLIST: Record<string, { kind: 'render-calls' | 'reviewed'; reason: str
'arbitrary model-written scripts in a sandboxed iframe, so a formula there has no more ' +
'power than the page itself',
},
'packages/@openmaic/generation/src/interactive-libraries.ts': {
kind: 'reviewed',
reason:
'lists the KaTeX auto-render file among the self-hosted assets that the interactive ' +
'post-processor injects; it renders nothing itself',
},
};
const NON_PRODUCT_PATH_RE = /(^|\/)(tests?|__tests__|__snapshots__)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
@@ -0,0 +1,274 @@
/** Chromium proof of local-library classroom, standalone, and video math rendering. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { chromium, type Browser } from '@playwright/test';
import { postProcessInteractiveHtml, interactiveLibraryUrl } from '@openmaic/generation/browser';
import { patchHtmlForIframe } from '@/lib/utils/iframe';
import { inlineSceneContent } from '@/lib/export/use-export-classroom';
import { prepareInteractiveHtmlScenes } from '@/lib/video-export-app/prepare-interactive-html';
import type { Scene } from '@/lib/types/stage';
import type { FetchAsset } from '@/lib/export/inline-assets';
const ORIGIN = 'http://classroom.test';
const REQUIRED = process.env.INTERACTIVE_STATIC_BROWSER === '1';
const math =
'<!doctype html><html><head></head><body><p id="formula">$2+2=4$</p><script>window.parserReachedBody = true;</script></body></html>';
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<string, string> = {},
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(
`<iframe sandbox="allow-scripts" srcdoc="${patchHtmlForIframe(math).replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"></iframe>`,
);
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',
`<script>katex.render('x^2', document.getElementById('formula'));</script>`,
{},
],
['an inline katex.renderToString call', `<script>${parseTimeRender}</script>`, {}],
[
'a blocking external script',
'<script src="/app.js"></script>',
{ [`${ORIGIN}/app.js`]: parseTimeRender },
],
[
'an async external script',
'<script async src="/app.js"></script>',
{ [`${ORIGIN}/app.js`]: parseTimeRender },
],
[
'a String.raw template',
'<script>document.getElementById("formula").textContent = String.raw`\\(\\frac{1}{2}\\)`;</script>',
{},
],
])('typesets math rendered during parsing by %s', async (_name, script, extra) => {
const { page } = await openPage(
postProcessInteractiveHtml(
`<!doctype html><html><head></head><body><p>$y$</p><p id="formula"></p>${script}</body></html>`,
),
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(
`<!doctype html><html><head></head><body><p id="formula">@@x^2@@</p><script>renderMathInElement(document.body, { delimiters: [{ left: '@@', right: '@@', display: false }] });</script></body></html>`,
),
);
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('<p>Price: $5 and $10</p>'));
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 = `<script type="importmap">{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js","three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}</script><script type="module">import {Vector3} from 'three'; import {OrbitControls} from 'three/addons/controls/OrbitControls.js'; document.body.textContent = typeof OrbitControls + new Vector3(1,2,3).lengthSq();</script>`;
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: `<script src="${interactiveLibraryUrl('@tailwindcss/browser')}"></script><script src="${interactiveLibraryUrl('chart.js')}"></script><h1 class="text-4xl">Chart</h1>`,
});
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();
}
});
});
@@ -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();