feat(lint): warn when a sub-composition's linked stylesheet names its root id (#6533)

* feat(lint): warn when a sub-composition's linked stylesheet names its root id

* chore(skills): regenerate the skills manifest

* fix(lint): the linked-stylesheet hint names fixes that lint clean
This commit is contained in:
Miguel Ángel
2026-10-27 08:48:57 +00:00
committed by GitHub
parent 1bc4bd9ca4
commit 5d7cbc2171
5 changed files with 78 additions and 1 deletions
+6
View File
@@ -192,6 +192,12 @@ Source: `packages/lint/src/rules/composition.ts`.
**Fix:** Set the sub-composition's `data-duration` to fill the host window, or cover the gap with another clip.
### `subcomposition_linked_style_names_root_id`
**Warning.** A stylesheet linked from a sub-composition has a rule on `#<root id>`, the id of the sub-composition's root. When the composition is compiled the root is merged into its host, so that selector matches nothing. The same rule in an inline `<style>` is rewritten and works.
**Fix:** Move the rule into a `<style>` inside the `<template>`, or select a child element of the root in the linked file instead of the root itself.
### `subcomposition_root_styled_by_class`
**Error.** A sub-composition's root element is styled through class selectors, but at render every rule is scoped to `[data-composition-id=...]`, so the class-keyed styles stop applying.
@@ -3050,3 +3050,51 @@ ${body}
});
});
});
describe("subcomposition_linked_style_names_root_id", () => {
const root = `<div id="a" data-composition-id="a" data-width="320" data-height="180"><div class="box"></div></div>`;
const codes = async (html: string, externalStyles?: Array<{ href: string; content: string }>) =>
(await lintHyperframeHtml(html, { isSubComposition: true, externalStyles })).findings.map(
({ code }) => code,
);
it("warns when a linked stylesheet has a rule on the root id", async () => {
const html = `<template id="a-template"><link rel="stylesheet" href="a.css">${root}</template>`;
const found = await codes(html, [{ href: "a.css", content: "#a .box { color: red; }" }]);
expect(found).toContain("subcomposition_linked_style_names_root_id");
});
it("the hinted fixes leave no finding: the rule moved into the template, or a child selected", async () => {
const lint = async (html: string, externalStyles?: Array<{ href: string; content: string }>) =>
(await lintHyperframeHtml(html, { isSubComposition: true, externalStyles })).findings.map(
({ code }) => code,
);
const hinted = [
await lint(
`<template id="a-template">${root}<style>#a .box { color: red; }</style></template>`,
),
await lint(
`<template id="a-template"><link rel="stylesheet" href="a.css">${root}</template>`,
[{ href: "a.css", content: ".box { color: red; }" }],
),
];
for (const found of hinted) {
expect(found).not.toContain("subcomposition_linked_style_names_root_id");
expect(found).not.toContain("subcomposition_root_styled_by_class");
}
});
it("is quiet when the linked stylesheet uses classes, another id, or only a longer id", async () => {
const html = `<template id="a-template"><link rel="stylesheet" href="a.css">${root}</template>`;
for (const content of [
".box { color: red; }",
"#b .box { color: red; }",
"#ab { color: red; }",
"/* #a */ .box { color: red; }",
]) {
expect(await codes(html, [{ href: "a.css", content }])).not.toContain(
"subcomposition_linked_style_names_root_id",
);
}
});
});
+22
View File
@@ -1924,6 +1924,28 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
];
},
// subcomposition_linked_style_names_root_id
// The compiler flattens a sub-composition root into its host and rewrites inline <style>
// selectors to match; a linked stylesheet is not rewritten, so `#root-id` in it matches nothing.
({ styles, rootTag, options }) => {
if (!options.isSubComposition || !rootTag) return [];
const rootId = readAttr(rootTag.raw, "id");
if (!rootId) return [];
const escaped = rootId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const names = new RegExp(`#${escaped}(?![\\w-])`);
return styles
.filter((style) => style.index === -1 && names.test(stripCssComments(style.content)))
.map((style) => ({
code: "subcomposition_linked_style_names_root_id",
severity: "warning" as const,
message:
`The linked stylesheet "${style.rootRelativePath ?? style.file}" has a rule on #${rootId}, the id of this sub-composition's root. ` +
"The root is merged into its host when the composition is compiled, so that selector matches nothing (the same rule in an inline <style> is rewritten and works).",
fixHint: `Move the #${rootId} rules into a <style> inside the <template>, where they are rewritten for the merged root, or select a child element of the root in the linked file instead of the root itself.`,
snippet: truncateSnippet(style.content.slice(0, 120)),
}));
},
];
const CLIP_MEDIA_TAGS = new Set<string>(["img", "video", "audio"]);
+1 -1
View File
@@ -6,7 +6,7 @@
"files": 2
},
"hyperframes": {
"hash": "9aaba3205d9e3f32",
"hash": "bd5afd8858ff0ecf",
"files": 77
},
"hyperframes-cli": {
+1
View File
@@ -150,6 +150,7 @@ Studio's Detach audio writes a muted `<video>` and an `<audio>` of the same file
| `media_missing_id`, `media_crossorigin_breaks_preview` | Give every `<audio>` an `id`; drop `crossorigin` |
| `gsap_timeline_registered_before_async_build` | Register the timeline at the end of the async callback |
| `standalone_composition_wrapped_in_template` | Only scene files use `<template>` |
| `subcomposition_linked_style_names_root_id` | A linked sheet cannot style a scene's root by id: use an inline `<style>`, or select a child |
| `subcomposition_root_styled_by_class` | Style a scene's root as `#root` |
| `subcomposition_blanks_before_host` | Make the scene's slot span the time it should show |
| `clip_ends_past_root_duration` | Raise the root's `data-duration` |