Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,7 @@
"@hyperframes/lint": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"acorn": "^8.17.0",
"bpm-detective": "^2.0.5",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/compiler/compositionAssembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ describe("planCompositionAssembly", () => {
expect(ids(plan.scriptSources)).toEqual(["head-script", "body-script"]);
});

it.each([
[`<script nomodule></script>`, false],
[`<script type="module" nomodule></script>`, true],
])("extracts %s only when a browser runs it: %s", (script, runs) => {
const plan = planFor(`<template><div data-composition-id="s">${script}</div></template>`, "s");
expect(plan.scriptSources).toHaveLength(runs ? 1 : 0);
});

it("does not treat a templated composition's page head as an asset source", () => {
// The head belongs to the host page, not to the composition; only the
// non-templated (full-document) shape carries composition assets in <head>.
Expand Down
78 changes: 70 additions & 8 deletions packages/core/src/compiler/compositionAssembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,64 @@ const SCRIPT_SELECTOR = "script";
*/
const HOISTED_LINK_SELECTOR = 'link[rel="stylesheet"], link[rel="preconnect"]';

export const EXTRACTED_COMPOSITION_ASSET_SELECTOR = `${STYLE_SELECTOR}, ${SCRIPT_SELECTOR}, ${HOISTED_LINK_SELECTOR}`;
// The HTML spec's JavaScript MIME type essence matches: a script with any of these types runs as classic.
const JAVASCRIPT_TYPES = new Set([
"",
"application/ecmascript",
"application/javascript",
"application/x-ecmascript",
"application/x-javascript",
"text/ecmascript",
"text/javascript",
"text/javascript1.0",
"text/javascript1.1",
"text/javascript1.2",
"text/javascript1.3",
"text/javascript1.4",
"text/javascript1.5",
"text/jscript",
"text/livescript",
"text/x-ecmascript",
"text/x-javascript",
]);

const scriptType = (el: AssemblyAttributed) => (el.getAttribute("type") || "").trim().toLowerCase();

export function isJavaScriptType(el: AssemblyAttributed): boolean {
return JAVASCRIPT_TYPES.has(scriptType(el));
}

/**
* A data block (application/json, text/template, ...) or a classic nomodule script never runs,
* so it is mounted as authored.
*/
function isExecutableScript(el: AssemblyAttributed): boolean {
const type = scriptType(el);
if (type === "module" || type === "importmap") return true;
return JAVASCRIPT_TYPES.has(type) && el.getAttribute("nomodule") === null;
}

function inertScripts<TElement extends AssemblyAttributed>(
node: AssemblyQueryable<TElement> | null | undefined,
): TElement[] {
return toArray(node?.querySelectorAll(SCRIPT_SELECTOR)).filter((el) => !isExecutableScript(el));
}

export function executableScripts<TElement extends AssemblyAttributed>(
node: AssemblyQueryable<TElement> | null | undefined,
): TElement[] {
return toArray(node?.querySelectorAll(SCRIPT_SELECTOR)).filter(isExecutableScript);
}

/** The styles, scripts and links both paths extract, so the mounted copy must drop them. */
export function extractedCompositionAssets<TElement extends AssemblyAttributed>(
node: AssemblyQueryable<TElement>,
): TElement[] {
return [
...toArray(node.querySelectorAll(`${STYLE_SELECTOR}, ${HOISTED_LINK_SELECTOR}`)),
...executableScripts(node),
];
}

/**
* The compiler's nesting cap, enforced against the ancestry chain rather than a
Expand Down Expand Up @@ -136,6 +193,9 @@ export interface CompositionAssemblyPlan<TElement extends AssemblyAttributed> {
/** Head and content links to hoist into the host document. */
linkSources: TElement[];

/** Inert scripts the mounted root would leave behind; both paths mount them next to it. */
inertScriptsOutsideRoot: TElement[];

/**
* Nodes that may declare the composition's variable defaults, in precedence
* order — later wins. Full-document compositions declare on `<html>`;
Expand All @@ -159,9 +219,9 @@ function toArray<TElement>(items: Iterable<TElement> | null | undefined): TEleme
* how it is identified. Pure: it reads attributes and runs selectors, and does
* not mutate, fetch, or touch a filesystem.
*/
export function planCompositionAssembly<TElement extends AssemblyAttributed>(
input: CompositionAssemblyInput<TElement>,
): CompositionAssemblyPlan<TElement> {
export function planCompositionAssembly<
TElement extends AssemblyAttributed & AssemblyQueryable<TElement>,
>(input: CompositionAssemblyInput<TElement>): CompositionAssemblyPlan<TElement> {
const { contentNode, head, documentElement, hasTemplate, compositionId } = input;

const compositionRoots = toArray(contentNode.querySelectorAll(COMPOSITION_ROOT_SELECTOR));
Expand All @@ -177,6 +237,7 @@ export function planCompositionAssembly<TElement extends AssemblyAttributed>(

// A templated composition's <head> belongs to its host page, not to it.
const assetHead = hasTemplate ? null : (head ?? null);
const mountedInert = innerRoot ? inertScripts(innerRoot) : [];

return {
innerRoot,
Expand All @@ -187,14 +248,15 @@ export function planCompositionAssembly<TElement extends AssemblyAttributed>(
...toArray(assetHead?.querySelectorAll(STYLE_SELECTOR)),
...toArray(contentNode.querySelectorAll(STYLE_SELECTOR)),
],
scriptSources: [
...toArray(assetHead?.querySelectorAll(SCRIPT_SELECTOR)),
...toArray(contentNode.querySelectorAll(SCRIPT_SELECTOR)),
],
scriptSources: [...executableScripts(assetHead), ...executableScripts(contentNode)],
linkSources: [
...toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
...toArray(contentNode.querySelectorAll(HOISTED_LINK_SELECTOR)),
],
inertScriptsOutsideRoot: [
...inertScripts(assetHead),
...(innerRoot ? inertScripts(contentNode).filter((el) => !mountedInert.includes(el)) : []),
],
variableDefaultCarriers: [documentElement, innerRoot].filter(
(carrier): carrier is TElement => carrier != null,
),
Expand Down
Loading
Loading