-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Expand file tree
/
Copy pathinlineSubCompositions.ts
More file actions
349 lines (314 loc) · 12.9 KB
/
Copy pathinlineSubCompositions.ts
File metadata and controls
349 lines (314 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
/**
* Shared sub-composition inlining logic.
*
* Both the core bundler (preview) and the producer compiler (render) need to
* inline sub-composition HTML referenced via `data-composition-src`. This
* module is the single source of truth for that transformation, eliminating
* divergence that previously caused bugs (e.g. producer not setting
* `data-composition-file`).
*/
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./rewriteSubCompPaths";
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
export interface InlineSubCompositionsOptions {
/**
* Resolve the HTML content for a sub-composition given its `data-composition-src` value.
* Return `null` when the file cannot be found.
*/
resolveHtml: (srcPath: string) => string | null;
/**
* Parse an HTML string into a Document. The returned object must expose
* standard DOM APIs (querySelector, querySelectorAll, body, head, etc.).
* Both linkedom's `parseHTML(...).document` and the core bundler's
* `parseHTMLContent(...)` satisfy this contract.
*/
parseHtml: (html: string) => Document;
/**
* Identity map produced by `assignBundledRuntimeCompositionIds`.
* When provided, authoredCompositionId and runtimeCompositionId are read
* from this map instead of from the host element's attributes directly.
* The bundler uses this; the producer can omit it.
*/
hostIdentityMap?: Map<
Element,
{ authoredCompositionId: string | null; runtimeCompositionId: string | null }
>;
/**
* When true, rewrite `url(...)` references in inline `style` attributes
* on sub-composition elements. The bundler enables this; the producer
* can skip it.
*/
rewriteInlineStyles?: boolean;
/**
* Prepare the inner root element before injecting it into the host.
* The bundler's `prepareFlattenedInnerRoot` clones the element, strips
* timing attributes, and adds `data-hf-inner-root`. When omitted, the
* inner root's outerHTML is injected as-is.
*/
flattenInnerRoot?: (innerRoot: Element) => Element;
/**
* Read declared variable defaults from a sub-composition's `<html>` element.
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
*/
readVariableDefaults?: (docElement: Element) => Record<string, unknown>;
/**
* Parse host-level variable overrides from `data-variable-values`.
* The bundler passes `parseHostVariableValues`; the producer can omit this.
*/
parseHostVariables?: (host: Element) => Record<string, unknown>;
/**
* Build a CSS attribute selector for scoping, e.g.
* `[data-composition-id="my-comp"]`. Defaults to a simple implementation
* when not provided. The bundler passes `cssAttributeSelector` which
* handles escaping.
*/
buildScopeSelector?: (compId: string) => string;
/**
* Error label prefix used in wrapped composition scripts.
* Defaults to `"[HyperFrames] composition script error:"`.
*/
scriptErrorLabel?: string;
/**
* Log a warning when a composition file cannot be resolved.
* Defaults to `console.warn`.
*/
onMissingComposition?: (srcPath: string) => void;
}
export interface InlineSubCompositionsResult {
styles: string[];
scripts: string[];
externalScriptSrcs: string[];
externalLinks: { href: string; rel: string; crossorigin?: string }[];
variablesByComp: Record<string, Record<string, unknown>>;
}
// ---------------------------------------------------------------------------
// Default helpers
// ---------------------------------------------------------------------------
function defaultBuildScopeSelector(compId: string): string {
const escaped = compId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `[data-composition-id="${escaped}"]`;
}
// ---------------------------------------------------------------------------
// Core implementation
// ---------------------------------------------------------------------------
/**
* Inline sub-compositions into a document. For each host element in `hosts`:
*
* 1. Resolve the sub-composition HTML via `options.resolveHtml`
* 2. Parse it, find `<template>` or `<body>` content
* 3. Find the inner `[data-composition-id]` root
* 4. Extract `<style>` elements, scope CSS, collect them
* 5. Extract `<script>` elements, wrap inline scripts, collect them
* 6. Collect external script `src` URLs for deduplication
* 7. Rewrite asset paths (and optionally inline-style asset URLs)
* 8. Copy dimension attrs from inner root to host if missing
* 9. Set `data-composition-file` on host
* 10. Remove `data-composition-src` from host
* 11. Inject the content into the host element
*/
export function inlineSubCompositions(
document: Document,
hosts: Element[],
options: InlineSubCompositionsOptions,
): InlineSubCompositionsResult {
const {
resolveHtml,
parseHtml,
hostIdentityMap,
rewriteInlineStyles = false,
flattenInnerRoot,
readVariableDefaults,
parseHostVariables,
buildScopeSelector = defaultBuildScopeSelector,
scriptErrorLabel = "[HyperFrames] composition script error:",
onMissingComposition,
} = options;
const styles: string[] = [];
const scripts: string[] = [];
const externalScriptSrcs: string[] = [];
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
const seenLinkHrefs = new Set<string>();
const variablesByComp: Record<string, Record<string, unknown>> = {};
for (const hostEl of hosts) {
const src = hostEl.getAttribute("data-composition-src");
if (!src) continue;
const compHtml = resolveHtml(src);
if (compHtml == null) {
if (onMissingComposition) {
onMissingComposition(src);
}
continue;
}
const compDoc = parseHtml(compHtml);
// Determine composition IDs
let compId: string | null;
let runtimeCompId: string;
if (hostIdentityMap) {
const identity = hostIdentityMap.get(hostEl);
compId = identity?.authoredCompositionId || null;
runtimeCompId = identity?.runtimeCompositionId || compId || "";
} else {
compId = hostEl.getAttribute("data-composition-id") || null;
runtimeCompId = compId || "";
}
// Find content: prefer <template>, fall back to <body>
const contentRoot = compDoc.querySelector("template");
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
const contentDoc = parseHtml(contentHtml);
// Find the inner composition root
const innerRoot = compId
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
: contentDoc.querySelector("[data-composition-id]");
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const scopeCompId = compId || inferredCompId;
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
// Variable merging (bundler feature)
if (readVariableDefaults && parseHostVariables && runtimeCompId) {
const mergedVariables = {
...readVariableDefaults(compDoc.documentElement),
...parseHostVariables(hostEl),
};
if (Object.keys(mergedVariables).length > 0) {
variablesByComp[runtimeCompId] = mergedVariables;
}
}
// When a sub-composition is a full HTML document (no <template>), styles
// and scripts in <head> are not part of contentDoc (which only has body
// content). Extract them so backgrounds, positioning, fonts, and library
// scripts (e.g. GSAP CDN) are not silently dropped.
if (!contentRoot && compDoc.head) {
for (const s of [...compDoc.head.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
: css,
);
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
if (externalSrc && !externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc);
}
}
for (const link of [
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
]) {
const href = (link.getAttribute("href") || "").trim();
if (href && !seenLinkHrefs.has(href)) {
seenLinkHrefs.add(href);
const rel = (link.getAttribute("rel") || "").trim();
const crossorigin = link.hasAttribute("crossorigin")
? link.getAttribute("crossorigin") || ""
: undefined;
externalLinks.push({ href, rel, crossorigin });
}
}
}
// Extract styles from content
for (const s of [...contentDoc.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
: css,
);
s.remove();
}
// Extract scripts from content
for (const s of [...contentDoc.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
if (externalSrc) {
if (!externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc);
}
} else {
scripts.push(
scopeCompId
? wrapScopedCompositionScript(
s.textContent || "",
scopeCompId,
scriptErrorLabel,
runtimeScope || undefined,
runtimeCompId || scopeCompId,
authoredRootId,
)
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error(${JSON.stringify(scriptErrorLabel)}, _err); } })();`,
);
}
s.remove();
}
// Rewrite relative asset paths before inlining so ../foo.svg from
// compositions/ resolves correctly when the content moves to root.
const assetEls = innerRoot
? innerRoot.querySelectorAll("[src], [href]")
: contentDoc.querySelectorAll("[src], [href]");
rewriteAssetPaths(
assetEls,
src,
(el: Element, attr: string) => el.getAttribute(attr),
(el: Element, attr: string, val: string) => {
el.setAttribute(attr, val);
},
);
if (rewriteInlineStyles) {
const styledEls = innerRoot
? innerRoot.querySelectorAll("[style]")
: contentDoc.querySelectorAll("[style]");
rewriteInlineStyleAssetUrls(
styledEls,
src,
(el: Element) => el.getAttribute("style"),
(el: Element, val: string) => {
el.setAttribute("style", val);
},
);
}
if (innerRoot?.hasAttribute("data-timeline-locked")) {
hostEl.setAttribute("data-timeline-locked", "");
}
// Copy dimension attributes from inner root to host if missing
if (innerRoot) {
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");
if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
if (innerH && !hostEl.getAttribute("data-height")) {
hostEl.setAttribute("data-height", innerH);
}
}
// Inject content into the host element
if (innerRoot) {
innerRoot.setAttribute("data-composition-file", src);
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
if (flattenInnerRoot) {
const prepared = flattenInnerRoot(innerRoot);
hostEl.innerHTML = prepared.outerHTML || "";
} else {
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
// When the producer path strips the inner root (innerHTML), the
// authored id attribute is lost. Propagate it to the host so that
// rewritten #ID selectors ([data-hf-authored-id="X"]) still resolve.
if (compId && authoredRootId) {
hostEl.setAttribute("data-hf-authored-id", authoredRootId);
}
}
} else {
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
// linkedom fragment parsing: when content is `<div data-composition-id="X">...</div>`,
// the div becomes documentElement and body is empty. Fall back to documentElement.outerHTML
// to preserve the composition wrapper.
const bodyHtml = contentDoc.body?.innerHTML || "";
hostEl.innerHTML = bodyHtml || contentDoc.documentElement?.outerHTML || "";
}
hostEl.setAttribute("data-composition-file", src);
hostEl.removeAttribute("data-composition-src");
}
return { styles, scripts, externalScriptSrcs, externalLinks, variablesByComp };
}