Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"correctness": "error"
},
"plugins": ["react", "typescript"],
"ignorePatterns": ["dist/", "coverage/", "node_modules/"]
"ignorePatterns": ["dist/", "coverage/", "node_modules/", "playground/"]
}
49 changes: 49 additions & 0 deletions packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,55 @@ window.__afterTimeline = window.__timelines.scene;
expect(errorSpy).not.toHaveBeenCalled();
});

it("uses compound selector when authored root is the scoped element itself", () => {
const scoped = scopeCssToComposition(
"#chrome-overlay-root { --primary: #FFDC8B; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
{ compoundAuthoredRoot: true },
);

// Both attributes are on the same element after inlining, so the selector
// must be compound (no space) to match.
expect(scoped).toContain(
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
);
expect(scoped).not.toContain(
'[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
);
});

it("uses compound selector for authored root with descendant combinators", () => {
const scoped = scopeCssToComposition(
"#chrome-overlay-root .chrome { display: flex; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
{ compoundAuthoredRoot: true },
);

// The authored root part is compound with scope, .chrome is a descendant
expect(scoped).toContain(
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
);
expect(scoped).not.toMatch(
/\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
);
});

it("still uses descendant selector for non-root selectors with authoredRootId", () => {
const scoped = scopeCssToComposition(
".child-element { color: red; }",
"chrome-overlay",
undefined,
"chrome-overlay-root",
);

// Regular child selectors still get a descendant combinator (space)
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
});

it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
const scoped = scopeCssToComposition(
`#intro { background: #111; }
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ function scopeSelector(
scope: string,
compositionId: string,
authoredRootId?: string | null,
compoundAuthoredRoot?: boolean,
): string {
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
Expand All @@ -120,6 +121,15 @@ function scopeSelector(
}
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
if (compoundAuthoredRoot) {
const authoredRootAttr = authoredRootId
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
: null;
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
const rest = trimmed.slice(authoredRootAttr.length);
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
}
}
return `${leading}${scope} ${trimmed}${trailing}`;
}

Expand Down Expand Up @@ -158,6 +168,7 @@ export function scopeCssToComposition(
compositionId: string,
scopeSelectorOverride?: string,
authoredRootId?: string | null,
options?: { compoundAuthoredRoot?: boolean },
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
Expand All @@ -169,7 +180,13 @@ export function scopeCssToComposition(
root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return;
rule.selectors = rule.selectors.map((selector) =>
scopeSelector(selector, scope, trimmedCompositionId, authoredRootId),
scopeSelector(
selector,
scope,
trimmedCompositionId,
authoredRootId,
options?.compoundAuthoredRoot,
),
);
});

Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,35 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {

expect(host.getAttribute("data-composition-id")).toBe("intro");
});

it("producer path: scoped CSS matches host element when both attributes coexist", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;

const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
compoundAuthoredRoot: true,
});

// After inlining, the host has both data-composition-id and data-hf-authored-id.
// CSS selectors targeting the root must be compound (no space) so they match
// when both attributes are on the same element.
expect(host.getAttribute("data-composition-id")).toBe("intro");
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");

const scopedCss = result.styles.join("\n");

// Root-only selector: must be compound
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
// Must NOT have a descendant combinator between the two attribute selectors
expect(scopedCss).not.toMatch(
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
);

// Descendant selector: compound root + space + child
expect(scopedCss).toMatch(
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
);
});
});
18 changes: 16 additions & 2 deletions packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ export interface InlineSubCompositionsOptions {
*/
flattenInnerRoot?: (innerRoot: Element) => Element;

/**
* When true, CSS selectors targeting the authored root use a compound
* selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
* Enable this in the producer path where the inner root merges onto
* the host element via innerHTML — both attributes end up on the same
* element and a descendant selector won't match.
*/
compoundAuthoredRoot?: boolean;

/**
* Read declared variable defaults from a sub-composition's `<html>` element.
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
Expand Down Expand Up @@ -139,6 +148,7 @@ export function inlineSubCompositions(
hostIdentityMap,
rewriteInlineStyles = false,
flattenInnerRoot,
compoundAuthoredRoot,
readVariableDefaults,
parseHostVariables,
buildScopeSelector = defaultBuildScopeSelector,
Expand Down Expand Up @@ -211,7 +221,9 @@ export function inlineSubCompositions(
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
})
: css,
);
}
Expand All @@ -228,7 +240,9 @@ export function inlineSubCompositions(
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
})
: css,
);
s.remove();
Expand Down
158 changes: 157 additions & 1 deletion packages/core/src/studio-api/helpers/sourceMutation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { removeElementFromHtml } from "./sourceMutation.js";
import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js";

describe("removeElementFromHtml", () => {
it("removes a self-closing element by id", () => {
Expand Down Expand Up @@ -28,3 +28,159 @@ describe("removeElementFromHtml", () => {
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
});
});

describe("patchElementInHtml", () => {
const FIXTURE = `<!doctype html><html><head></head><body>
<div id="root" data-composition-id="main">
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
<div class="chrome">
<span class="brand">HyperFrames</span>
</div>
</div>
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
</div>
</body></html>`;

it("patches inline style by id", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "red" },
]);

expect(result).toMatch(/color:\s*red/);
expect(result).toContain('id="hero"');
});

it("patches inline style by class selector", () => {
const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
{ type: "inline-style", property: "font-size", value: "72px" },
]);

expect(result).toMatch(/font-size:\s*72px/);
});

it("patches data attribute", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
]);

expect(result).toContain('data-hf-studio-path-offset="true"');
});

it("patches html attribute", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "title", value: "greeting" },
]);

expect(result).toContain('title="greeting"');
});

it("patches text content", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "text-content", property: "", value: "New Title" },
]);

expect(result).toContain("New Title");
expect(result).not.toContain("Hello World");
});

it("applies multiple operations in one call", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
{ type: "inline-style", property: "font-size", value: "96px" },
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
]);

expect(result).toMatch(/color:\s*blue/);
expect(result).toMatch(/font-size:\s*96px/);
expect(result).toContain('data-hf-studio-path-offset="true"');
});

it("finds element by composition-id selector", () => {
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
{ type: "inline-style", property: "opacity", value: "0.5" },
]);

expect(result).toMatch(/opacity:\s*0\.5/);
});

it("finds element by class with selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
{ type: "text-content", property: "", value: "Changed" },
]);

expect(result).toContain("A");
expect(result).toContain("Changed");
expect(result).not.toContain(">B<");
});

it("returns unchanged html when target not found", () => {
const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
{ type: "inline-style", property: "color", value: "red" },
]);

expect(result).toBe(FIXTURE);
});

it("removes inline style when value is null", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "font-size", value: null },
]);

expect(result).not.toContain("font-size");
});

it("removes attribute when value is null", () => {
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
{ type: "html-attribute", property: "data-composition-src", value: null },
]);

expect(result).not.toContain("data-composition-src");
});

it("patches fragment html without doctype", () => {
const fragment = `<div id="card" style="padding: 8px"><span>Title</span></div>`;
const result = patchElementInHtml(fragment, { id: "card" }, [
{ type: "inline-style", property: "padding", value: "16px" },
]);

expect(result).toMatch(/padding:\s*16px/);
});

it("rejects event handler attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "onload", value: "fetch('/evil')" },
]);

expect(result).not.toContain("onload");
expect(result).not.toContain("fetch");
});

it("rejects javascript: URLs in src", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "src", value: "javascript:alert(1)" },
]);

expect(result).not.toContain("javascript:");
});

it("allows aria-* and data-* attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "aria-label", value: "greeting" },
{ type: "html-attribute", property: "data-custom", value: "test" },
]);

expect(result).toContain('aria-label="greeting"');
expect(result).toContain('data-custom="test"');
});

it("rejects srcdoc and formaction attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "srcdoc", value: "<script>alert(1)</script>" },
{ type: "html-attribute", property: "formaction", value: "javascript:void(0)" },
]);

expect(result).not.toContain("srcdoc");
expect(result).not.toContain("formaction");
});
});
Loading
Loading