mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-02 03:36:52 +00:00
fix(core): a mounted composition's import map and module scripts run as such (#4446)
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl * test(core): a template composition inside a mounted file resolves assets from that file * docs: assetUrl is for paths beside the composition, not project-root paths * docs: say assetUrl misses a project-root path * fix(core): a mounted composition's import map and module scripts run as such * refactor(core): scope a mounted module script under the same condition as a classic one * fix(core): a mounted module script loaded by src stays a module * fix(core): a mounted import map keeps the trailing slash of a prefix address * chore(core): keep the module-script helpers' comment share at the base
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
buildVariablesByCompScript,
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
scopedModulePrelude,
|
||||
wrapScopedCompositionScript,
|
||||
} from "./compositionScoping";
|
||||
|
||||
@@ -165,6 +166,26 @@ body { margin: 0; }
|
||||
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
|
||||
});
|
||||
|
||||
it("gives a mounted module script its composition's own __hyperframes", () => {
|
||||
const { document } = parseHTML(`<div></div>`);
|
||||
Object.defineProperty(document, "baseURI", { value: "https://p.test/preview/" });
|
||||
const fakeWindow = {
|
||||
document,
|
||||
__hfVariablesByComp: { blk: { title: "Hi" }, other: { title: "No" } },
|
||||
__hyperframes: { assetUrl: () => "TOP-LEVEL", getVariables: () => ({}), fitTextFontSize: 1 },
|
||||
};
|
||||
const scoped = new Function(
|
||||
"window",
|
||||
`${scopedModulePrelude("blk", "compositions/blk/blk.html")}return __hyperframes;`,
|
||||
)(fakeWindow);
|
||||
|
||||
expect(scoped.assetUrl("assets/env.hdr")).toBe(
|
||||
"https://p.test/preview/compositions/blk/assets/env.hdr",
|
||||
);
|
||||
expect(scoped.getVariables()).toEqual({ title: "Hi" });
|
||||
expect(scoped.fitTextFontSize).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves __hyperframes.assetUrl against the mounted composition's own file", () => {
|
||||
const run = (compositionSrc?: string) => {
|
||||
const { document } = parseHTML(`<div data-composition-id="blk"></div>`);
|
||||
|
||||
@@ -276,6 +276,30 @@ function jsonScriptLiteral(value: unknown): string {
|
||||
return JSON.stringify(value).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
const SCOPED_HYPERFRAMES_EXPRESSION = `!__hfBaseHyperframes
|
||||
? __hfBaseHyperframes
|
||||
: Object.assign({}, __hfBaseHyperframes, {
|
||||
assetUrl: function(path) {
|
||||
var page = window.document.baseURI;
|
||||
return new URL(path, __hfCompositionSrc ? new URL(__hfCompositionSrc, page) : page).href;
|
||||
},
|
||||
getVariables: function() {
|
||||
var byComp = window.__hfVariablesByComp;
|
||||
var scoped = byComp && __hfTimelineCompId ? byComp[__hfTimelineCompId] : null;
|
||||
return scoped ? Object.assign({}, scoped) : {};
|
||||
},
|
||||
})`;
|
||||
|
||||
export function scopedModulePrelude(
|
||||
timelineCompositionId: string,
|
||||
compositionSrc?: string | null,
|
||||
): string {
|
||||
return `const __hyperframes = (function(__hfBaseHyperframes, __hfTimelineCompId, __hfCompositionSrc) {
|
||||
return ${SCOPED_HYPERFRAMES_EXPRESSION};
|
||||
})(window.__hyperframes, ${jsonScriptLiteral(timelineCompositionId)}, ${jsonScriptLiteral(compositionSrc?.trim() || null)});
|
||||
`;
|
||||
}
|
||||
|
||||
export function wrapScopedCompositionScript(
|
||||
source: string,
|
||||
compositionId: string,
|
||||
@@ -611,19 +635,7 @@ export function wrapScopedCompositionScript(
|
||||
},
|
||||
});
|
||||
var __hfBaseHyperframes = window.__hyperframes;
|
||||
var __hfScopedHyperframes = !__hfBaseHyperframes
|
||||
? __hfBaseHyperframes
|
||||
: Object.assign({}, __hfBaseHyperframes, {
|
||||
assetUrl: function(path) {
|
||||
var page = window.document.baseURI;
|
||||
return new URL(path, __hfCompositionSrc ? new URL(__hfCompositionSrc, page) : page).href;
|
||||
},
|
||||
getVariables: function() {
|
||||
var byComp = window.__hfVariablesByComp;
|
||||
var scoped = byComp && __hfTimelineCompId ? byComp[__hfTimelineCompId] : null;
|
||||
return scoped ? Object.assign({}, scoped) : {};
|
||||
},
|
||||
});
|
||||
var __hfScopedHyperframes = ${SCOPED_HYPERFRAMES_EXPRESSION};
|
||||
var __hfRun = function() {
|
||||
try {
|
||||
(function(document, gsap, window, __hyperframes) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export interface ExternalScriptAttributes {
|
||||
integrity?: string;
|
||||
crossorigin?: string;
|
||||
type?: "module";
|
||||
}
|
||||
|
||||
export function readExternalScriptAttributes(el: Element): ExternalScriptAttributes {
|
||||
const attributes: ExternalScriptAttributes = {};
|
||||
if (el.hasAttribute("integrity")) attributes.integrity = el.getAttribute("integrity") || "";
|
||||
if (el.hasAttribute("crossorigin")) attributes.crossorigin = el.getAttribute("crossorigin") || "";
|
||||
if ((el.getAttribute("type") || "").trim().toLowerCase() === "module") attributes.type = "module";
|
||||
return attributes;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export function ensureExternalScriptTag(
|
||||
if (integrity) el.setAttribute("integrity", integrity);
|
||||
if (attributes.crossorigin !== undefined)
|
||||
el.setAttribute("crossorigin", attributes.crossorigin);
|
||||
if (attributes.type) el.setAttribute("type", attributes.type);
|
||||
}
|
||||
if (!existing.length) {
|
||||
const el = elements[0]!;
|
||||
|
||||
@@ -198,6 +198,55 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(bundled).toContain('var __hfCompositionSrc = "compositions/blk/blk.html";');
|
||||
});
|
||||
|
||||
it("keeps a mounted file's import map and module script as such, bound to that file", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="blk-host" data-composition-id="blk" data-composition-src="compositions/blk/blk.html"
|
||||
data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
"compositions/blk/assets/three.js": "export const REVISION = 1;",
|
||||
"compositions/blk/assets/addons/env.js": "export const ENV = 1;",
|
||||
"compositions/blk/assets/scene.js": 'import * as THREE from "three"; export const SCENE = 1;',
|
||||
"compositions/blk/blk.html": `<div data-composition-id="blk" data-width="1920" data-height="1080">
|
||||
<script type="module" src="./assets/scene.js"></script>
|
||||
<script type="module" src="https://cdn.test/mod.js"></script>
|
||||
<script type="importmap">{ "imports": { "three": "./assets/three.js", "three/addons/": "./assets/addons/", "cdn": "https://cdn.test/x.js" } }</script>
|
||||
<script type="module">import * as THREE from "three"; window.__url = __hyperframes.assetUrl("assets/leaf.webp");</script>
|
||||
<script>window.__classic = 1;</script>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
const { document } = parseHTML(await bundleToSingleHtml(dir));
|
||||
const importMaps = [...document.querySelectorAll('script[type="importmap"]')];
|
||||
const modules = [...document.querySelectorAll('script[type="module"]')];
|
||||
const classic = [...document.querySelectorAll("script:not([type])")].map((s) => s.textContent);
|
||||
|
||||
expect(importMaps).toHaveLength(1);
|
||||
expect(JSON.parse(importMaps[0]!.textContent || "")).toEqual({
|
||||
imports: {
|
||||
three: "./compositions/blk/assets/three.js",
|
||||
"three/addons/": "./compositions/blk/assets/addons/",
|
||||
cdn: "https://cdn.test/x.js",
|
||||
},
|
||||
});
|
||||
expect(modules.map((m) => m.getAttribute("src")).filter(Boolean)).toEqual([
|
||||
"compositions/blk/assets/scene.js",
|
||||
"https://cdn.test/mod.js",
|
||||
]);
|
||||
const inline = modules.filter((m) => !m.hasAttribute("src"));
|
||||
expect(inline).toHaveLength(1);
|
||||
expect(inline[0]!.textContent).toMatch(/^const __hyperframes = /);
|
||||
expect(inline[0]!.textContent).toContain('"compositions/blk/blk.html"');
|
||||
expect(inline[0]!.textContent).toContain('import * as THREE from "three";');
|
||||
expect(classic.join("")).not.toContain("SCENE");
|
||||
expect(classic.join("")).toContain(".__classic = 1;");
|
||||
expect(classic.join("")).not.toContain('"imports"');
|
||||
expect(classic.join("")).not.toContain("import * as THREE");
|
||||
});
|
||||
|
||||
it("binds a <template> composition authored in a mounted file to that file", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
readExternalScriptAttributes,
|
||||
type ExternalScriptAttributes,
|
||||
} from "./externalScripts";
|
||||
import { emitMountedModuleScripts } from "./importMaps";
|
||||
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
|
||||
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
|
||||
import { parseHostVariableValues, warnUnknownEnumValues } from "../runtime/getVariables";
|
||||
@@ -846,7 +847,7 @@ function hoistExternalScript(
|
||||
}
|
||||
if (seenSrcs.has(src)) return;
|
||||
seenSrcs.add(src);
|
||||
if (!isNonRelativeUrl(src) && !isAbsolute(src)) {
|
||||
if (!isNonRelativeUrl(src) && !isAbsolute(src) && attributes.type !== "module") {
|
||||
const jsPath = resolveWithinProject(projectDir, src);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
@@ -1023,7 +1024,7 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
if (seenCompScriptSrcs.has(extSrc)) continue;
|
||||
seenCompScriptSrcs.add(extSrc);
|
||||
if (isRelativeUrl(extSrc)) {
|
||||
if (isRelativeUrl(extSrc) && scriptItem.type !== "module") {
|
||||
const jsPath = resolveEntryPath(extSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
@@ -1214,6 +1215,7 @@ export async function bundleToSingleHtml(
|
||||
);
|
||||
document.body.appendChild(compScript);
|
||||
}
|
||||
emitMountedModuleScripts(document, subCompResult.importMaps, subCompResult.moduleScripts);
|
||||
|
||||
emitRootCompositionVariableStyles(document, compVariablesByComp);
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mergeImportMapsIntoDocument, parseImportMap } from "./importMaps";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const pageWith = (head: string) =>
|
||||
parseHTML(`<!doctype html><html><head>${head}</head><body></body></html>`).document;
|
||||
const mapOf = (doc: Document) =>
|
||||
JSON.parse(doc.querySelector('script[type="importmap"]')?.textContent || "null");
|
||||
|
||||
describe("parseImportMap", () => {
|
||||
it("rebases imports and scopes, and rejects what the browser would reject", () => {
|
||||
const rebase = (url: string) => `R(${url})`;
|
||||
expect(
|
||||
parseImportMap(`{"imports":{"a":"./a.js"},"scopes":{"./s/":{"b":"./b.js"}}}`, rebase),
|
||||
).toEqual({ imports: { a: "R(./a.js)" }, scopes: { "R(./s/)": { b: "R(./b.js)" } } });
|
||||
expect(parseImportMap("{ not json", rebase)).toBeNull();
|
||||
expect(parseImportMap("null", rebase)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeImportMapsIntoDocument", () => {
|
||||
it("creates the page's one import map ahead of every script when it has none", () => {
|
||||
const doc = pageWith(`<script type="module" src="x.js"></script>`);
|
||||
mergeImportMapsIntoDocument(doc, [{ imports: { three: "./t.js" } }]);
|
||||
expect(doc.head.firstElementChild?.getAttribute("type")).toBe("importmap");
|
||||
expect(mapOf(doc)).toEqual({ imports: { three: "./t.js" } });
|
||||
});
|
||||
|
||||
it("keeps the first mapping of a specifier and warns about a conflicting one", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const doc = pageWith(
|
||||
`<script type="importmap">{"imports":{"three":"./page.js"},"integrity":{"./page.js":"sha384-x"}}</script>`,
|
||||
);
|
||||
mergeImportMapsIntoDocument(doc, [
|
||||
{ imports: { three: "./mounted.js", gsap: "./g.js" }, scopes: { "./s/": { a: "./a.js" } } },
|
||||
{ imports: { gsap: "./g.js" }, scopes: { "./s/": { a: "./other.js" } } },
|
||||
]);
|
||||
expect(doc.querySelectorAll('script[type="importmap"]')).toHaveLength(1);
|
||||
expect(mapOf(doc)).toEqual({
|
||||
imports: { three: "./page.js", gsap: "./g.js" },
|
||||
scopes: { "./s/": { a: "./a.js" } },
|
||||
integrity: { "./page.js": "sha384-x" },
|
||||
});
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain('"three"');
|
||||
});
|
||||
|
||||
it("leaves the page alone when no mounted file declares a map", () => {
|
||||
const doc = pageWith("");
|
||||
mergeImportMapsIntoDocument(doc, []);
|
||||
expect(doc.querySelector('script[type="importmap"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/** The subset of an import map a composition can declare. */
|
||||
export interface ImportMap {
|
||||
imports?: Record<string, string>;
|
||||
scopes?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
const rebaseEntries = (
|
||||
entries: Record<string, string> | undefined,
|
||||
rebase: (url: string) => string,
|
||||
): Record<string, string> | undefined =>
|
||||
entries &&
|
||||
Object.fromEntries(Object.entries(entries).map(([specifier, url]) => [specifier, rebase(url)]));
|
||||
|
||||
/**
|
||||
* Parses a mounted composition's import map, rebasing each address the way its `src`/`href`
|
||||
* attributes are rebased. Returns null for a map the browser would reject too.
|
||||
*/
|
||||
export function parseImportMap(json: string, rebase: (url: string) => string): ImportMap | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const { imports, scopes } = parsed as ImportMap;
|
||||
return {
|
||||
imports: rebaseEntries(imports, rebase),
|
||||
scopes:
|
||||
scopes &&
|
||||
Object.fromEntries(
|
||||
Object.entries(scopes).map(([prefix, entries]) => [
|
||||
rebase(prefix),
|
||||
rebaseEntries(entries, rebase) ?? {},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeEntries(
|
||||
into: Record<string, string>,
|
||||
from: Record<string, string> | undefined,
|
||||
where: string,
|
||||
): void {
|
||||
for (const [specifier, url] of Object.entries(from ?? {})) {
|
||||
if (!(specifier in into)) into[specifier] = url;
|
||||
else if (into[specifier] !== url) {
|
||||
console.warn(
|
||||
`[HyperFrames] import map conflict for "${specifier}"${where}: keeping ${into[specifier]}, ignoring ${url}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page has one import map, and it must precede every module script. Mounted compositions'
|
||||
* maps merge into the page's own (created at the top of <head> if absent); the first mapping
|
||||
* of a specifier wins and a conflicting later one is dropped with a warning.
|
||||
*/
|
||||
export function mergeImportMapsIntoDocument(doc: Document, maps: ImportMap[]): void {
|
||||
if (maps.length === 0) return;
|
||||
let el = doc.querySelector('script[type="importmap"]');
|
||||
let page: ImportMap & Record<string, unknown> = {};
|
||||
try {
|
||||
page = el ? JSON.parse(el.textContent || "{}") : {};
|
||||
} catch {
|
||||
console.warn("[HyperFrames] the page's import map is not valid JSON; replacing it.");
|
||||
}
|
||||
const imports = { ...page.imports };
|
||||
const scopes = { ...page.scopes };
|
||||
for (const map of maps) {
|
||||
mergeEntries(imports, map.imports, "");
|
||||
for (const [prefix, entries] of Object.entries(map.scopes ?? {})) {
|
||||
mergeEntries((scopes[prefix] ??= {}), entries, ` in scope ${prefix}`);
|
||||
}
|
||||
}
|
||||
if (!el) {
|
||||
el = doc.createElement("script");
|
||||
el.setAttribute("type", "importmap");
|
||||
doc.head.prepend(el);
|
||||
}
|
||||
el.textContent = JSON.stringify({
|
||||
...page,
|
||||
imports,
|
||||
...(Object.keys(scopes).length ? { scopes } : {}),
|
||||
}).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
/** Emits what inlineSubCompositions collected from mounted files' module and import-map scripts. */
|
||||
export function emitMountedModuleScripts(
|
||||
doc: Document,
|
||||
importMaps: ImportMap[],
|
||||
moduleScripts: string[],
|
||||
): void {
|
||||
mergeImportMapsIntoDocument(doc, importMaps);
|
||||
for (const content of moduleScripts) {
|
||||
const el = doc.createElement("script");
|
||||
el.setAttribute("type", "module");
|
||||
el.textContent = content;
|
||||
doc.body.appendChild(el);
|
||||
}
|
||||
}
|
||||
@@ -100,3 +100,4 @@ export {
|
||||
} from "./mediaRenderIds";
|
||||
|
||||
export { ensureExternalScriptTag } from "./externalScripts";
|
||||
export { emitMountedModuleScripts } from "./importMaps";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readExternalScriptAttributes, type ExternalScriptAttributes } from "./externalScripts";
|
||||
import { parseImportMap, type ImportMap } from "./importMaps";
|
||||
/**
|
||||
* Shared sub-composition inlining logic.
|
||||
*
|
||||
@@ -20,6 +21,7 @@ import { warnUnknownEnumValues } from "../runtime/getVariables";
|
||||
import {
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
scopedModulePrelude,
|
||||
wrapScopedCompositionScript,
|
||||
} from "./compositionScoping";
|
||||
import {
|
||||
@@ -142,6 +144,10 @@ export interface InlineSubCompositionsResult {
|
||||
>;
|
||||
externalLinks: { href: string; rel: string; crossorigin?: string }[];
|
||||
variablesByComp: Record<string, Record<string, unknown>>;
|
||||
/** Mounted files' import maps, addresses rebased; emit with `emitMountedModuleScripts`. */
|
||||
importMaps: ImportMap[];
|
||||
/** Mounted files' inline module scripts, each already carrying its scoped `__hyperframes`. */
|
||||
moduleScripts: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -197,6 +203,8 @@ export function inlineSubCompositions(
|
||||
const scripts: string[] = [];
|
||||
const externalScriptSrcs: string[] = [];
|
||||
const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
|
||||
const importMaps: ImportMap[] = [];
|
||||
const moduleScripts: string[] = [];
|
||||
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
|
||||
const seenLinkHrefs = new Set<string>();
|
||||
const variablesByComp: Record<string, Record<string, unknown>> = {};
|
||||
@@ -340,7 +348,26 @@ export function inlineSubCompositions(
|
||||
// discarded on render while the mount path executed it.
|
||||
for (const scriptEl of plan.scriptSources) {
|
||||
const externalSrc = resolveSubAssetPath(scriptEl.getAttribute("src"));
|
||||
if (externalSrc) {
|
||||
const type = (scriptEl.getAttribute("type") || "").trim().toLowerCase();
|
||||
if (type === "importmap") {
|
||||
const map = parseImportMap(scriptEl.textContent || "", (url) => {
|
||||
// The rebase drops a leading "./" and a trailing "/"; an import map address needs both.
|
||||
const authored = url.trim();
|
||||
const rebased = resolveSubAssetPath(authored);
|
||||
if (rebased === authored) return rebased;
|
||||
const urlLike = /^(\/|\.\.?\/|[a-z][a-z\d+.-]*:)/i.test(rebased)
|
||||
? rebased
|
||||
: `./${rebased}`;
|
||||
return authored.endsWith("/") && !urlLike.endsWith("/") ? `${urlLike}/` : urlLike;
|
||||
});
|
||||
if (map) importMaps.push(map);
|
||||
else console.warn(`[HyperFrames] ${src}: import map is not valid JSON, so it is skipped.`);
|
||||
} else if (type === "module" && !externalSrc) {
|
||||
const prelude = scriptCompositionId
|
||||
? scopedModulePrelude(runtimeCompId || scopeCompId || scriptCompositionId, src)
|
||||
: "";
|
||||
moduleScripts.push(prelude + (scriptEl.textContent || ""));
|
||||
} else if (externalSrc) {
|
||||
if (!externalScriptSrcs.includes(externalSrc)) {
|
||||
externalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
@@ -461,5 +488,14 @@ export function inlineSubCompositions(
|
||||
}
|
||||
}
|
||||
|
||||
return { styles, scripts, externalScriptSrcs, scriptItems, externalLinks, variablesByComp };
|
||||
return {
|
||||
styles,
|
||||
scripts,
|
||||
externalScriptSrcs,
|
||||
scriptItems,
|
||||
externalLinks,
|
||||
variablesByComp,
|
||||
importMaps,
|
||||
moduleScripts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +54,30 @@ describe("loadExternalCompositions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("injects a fetched composition's import map and module script bound to its own URL", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/blocks/blk/blk.html");
|
||||
host.setAttribute("data-composition-id", "blk");
|
||||
document.body.appendChild(host);
|
||||
const compositionHtml =
|
||||
`<div data-composition-id="blk">` +
|
||||
`<script type="importmap">{ "imports": { "three": "./lib/three.js" } }</scr` +
|
||||
`ipt><script type="module">import "three"; window.__url = __hyperframes.assetUrl("a.png");</scr` +
|
||||
`ipt></div>`;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
await loadExternalCompositions({ ...defaultParams, injectedScripts });
|
||||
|
||||
const importMap = injectedScripts.find((script) => script.type === "importmap");
|
||||
const moduleScript = injectedScripts.find((script) => script.type === "module");
|
||||
expect(JSON.parse(importMap?.textContent || "")).toEqual({
|
||||
imports: { three: "https://example.com/blocks/blk/lib/three.js" },
|
||||
});
|
||||
expect(moduleScript?.textContent).toMatch(/^const __hyperframes = /);
|
||||
expect(moduleScript?.textContent).toContain('"https://example.com/blocks/blk/blk.html"');
|
||||
});
|
||||
|
||||
it("fetches and mounts external composition HTML", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/comp.html");
|
||||
|
||||
@@ -2,7 +2,12 @@ import {
|
||||
planCompositionAssembly,
|
||||
EXTRACTED_COMPOSITION_ASSET_SELECTOR,
|
||||
} from "../compiler/compositionAssembly";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||
import {
|
||||
scopeCssToComposition,
|
||||
scopedModulePrelude,
|
||||
wrapScopedCompositionScript,
|
||||
} from "../compiler/compositionScoping";
|
||||
import { parseImportMap } from "../compiler/importMaps";
|
||||
import { markFlattenedInnerRoot } from "./flattenedRoot";
|
||||
import {
|
||||
applyCssVariables,
|
||||
@@ -556,8 +561,19 @@ async function mountCompositionContent(params: {
|
||||
injectedScript.async = false;
|
||||
if (scriptPayload.kind === "external") {
|
||||
injectedScript.src = scriptPayload.src;
|
||||
} else if (scriptPayload.type.toLowerCase() === "importmap") {
|
||||
const map = parseImportMap(scriptPayload.content, (url) =>
|
||||
resolveScriptSourceUrl(url, params.compositionUrl),
|
||||
);
|
||||
injectedScript.textContent = map ? JSON.stringify(map) : scriptPayload.content;
|
||||
} else if (scriptPayload.type.toLowerCase() === "module") {
|
||||
injectedScript.textContent = scriptPayload.content;
|
||||
const prelude = scriptPayload.scopeCompositionId
|
||||
? scopedModulePrelude(
|
||||
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
|
||||
params.compositionUrl?.href,
|
||||
)
|
||||
: "";
|
||||
injectedScript.textContent = prelude + scriptPayload.content;
|
||||
} else if (scriptPayload.scopeCompositionId) {
|
||||
injectedScript.textContent = wrapScopedCompositionScript(
|
||||
scriptPayload.content,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-module-scripts-"));
|
||||
tempDirs.push(dir);
|
||||
for (const [relative, content] of Object.entries(files)) {
|
||||
const path = join(dir, relative);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("compileForRender mounted module scripts", () => {
|
||||
it("keeps a mounted file's import map and module script as such, bound to that file", async () => {
|
||||
const dir = project({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180">
|
||||
<div data-composition-id="blk" data-composition-src="compositions/blk/blk.html"
|
||||
data-start="0" data-duration="2"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
"compositions/blk/assets/three.js": "export const REVISION = 1;",
|
||||
"compositions/blk/assets/scene.js": 'import * as THREE from "three"; export const SCENE = 1;',
|
||||
"compositions/blk/blk.html": `<div data-composition-id="blk" data-width="320" data-height="180">
|
||||
<script type="module" src="./assets/scene.js"></script>
|
||||
<script type="importmap">{ "imports": { "three": "./assets/three.js" } }</script>
|
||||
<script type="module">import * as THREE from "three"; window.__url = __hyperframes.assetUrl("assets/leaf.webp");</script>
|
||||
</div>`,
|
||||
});
|
||||
const { html } = await compileForRender(dir, join(dir, "index.html"), join(dir, ".downloads"), {
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
const { document } = parseHTML(html);
|
||||
const importMap = document.querySelector('script[type="importmap"]');
|
||||
const modules = [...document.querySelectorAll('script[type="module"]')];
|
||||
const classic = [...document.querySelectorAll("script:not([type])")].map((s) => s.textContent);
|
||||
|
||||
expect(JSON.parse(importMap?.textContent || "null")).toEqual({
|
||||
imports: { three: "./compositions/blk/assets/three.js" },
|
||||
});
|
||||
expect(modules.map((m) => m.getAttribute("src")).filter(Boolean)).toEqual([
|
||||
"compositions/blk/assets/scene.js",
|
||||
]);
|
||||
const inline = modules.filter((m) => !m.hasAttribute("src"));
|
||||
expect(inline).toHaveLength(1);
|
||||
expect(inline[0]!.textContent).toMatch(/^const __hyperframes = /);
|
||||
expect(inline[0]!.textContent).toContain('"compositions/blk/blk.html"');
|
||||
expect(classic.join("")).not.toContain("SCENE");
|
||||
expect(classic.join("")).not.toContain('"imports"');
|
||||
expect(classic.join("")).not.toContain("import * as THREE");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
buildVariablesByCompScript,
|
||||
inlineSubCompositions as inlineSubCompositionsShared,
|
||||
ensureExternalScriptTag,
|
||||
emitMountedModuleScripts,
|
||||
prepareFlattenedInnerRoot,
|
||||
emitRootCompositionVariableStyles,
|
||||
readDeclaredDefaults,
|
||||
@@ -1097,6 +1098,13 @@ function inlineSubCompositions(
|
||||
}
|
||||
flushInline();
|
||||
}
|
||||
if (body) {
|
||||
emitMountedModuleScripts(
|
||||
document as unknown as Document,
|
||||
result.importMaps,
|
||||
result.moduleScripts,
|
||||
);
|
||||
}
|
||||
|
||||
// Compile-time CSS custom properties (mirrors the preview bundler): root
|
||||
// declarers plus one scoped rule per sub-composition host, so var(--slug)
|
||||
|
||||
Reference in New Issue
Block a user