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
23 changes: 23 additions & 0 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,29 @@ describe("core rules", () => {
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});

it("does not mistake a <tag>-shaped CSS comment inside <style> for the composition root", async () => {
// Regression: a CSS comment referencing an SVG tag name (e.g. `/* <g> wrapper */`)
// inside a <style> block reads as a real open tag to the flat TAG_PATTERN scan,
// manufacturing a phantom root before the real composition root and firing
// root_missing_composition_id/root_missing_dimensions/head_leaked_text on an
// otherwise valid sub-composition.
const html = `
<html><body>
<style>
/* <g> wrapper for icon groups */
.icon { fill: currentColor; }
</style>
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<g class="icon"></g>
</svg>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined();
});

it("reports error when timeline registry is missing", async () => {
const html = `
<html><body>
Expand Down
21 changes: 21 additions & 0 deletions packages/lint/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,34 @@ const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\
const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN =
/(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;

// <style>/<script> content is CSS/JS text, not markup — a CSS comment like
// `/* <g> wrapper */` or a JS comment mentioning a tag name otherwise reads as
// a real open tag to TAG_PATTERN's flat regex scan, throwing off every
// consumer that relies on tag order/position (findRootTag in particular:
// a phantom tag from inside a <style> block can outrank the real
// composition root). Compute the content spans up front and skip any match
// that falls inside one, the same way findRootTag already special-cases
// <style>/<script> tags themselves.
function findStyleAndScriptContentRanges(source: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
for (const pattern of [STYLE_BLOCK_PATTERN, SCRIPT_BLOCK_PATTERN]) {
for (const block of extractBlocks(source, pattern)) {
const contentStart = block.index + block.raw.indexOf(block.content);
ranges.push([contentStart, contentStart + block.content.length]);
}
}
return ranges;
}

export function extractOpenTags(source: string): OpenTag[] {
const tags: OpenTag[] = [];
const skipRanges = findStyleAndScriptContentRanges(source);
let match: RegExpExecArray | null;
const pattern = new RegExp(TAG_PATTERN.source, TAG_PATTERN.flags);
while ((match = pattern.exec(source)) !== null) {
const raw = match[0];
if (raw.startsWith("</") || raw.startsWith("<!")) continue;
if (skipRanges.some(([start, end]) => match!.index >= start && match!.index < end)) continue;
tags.push({
raw,
name: (match[1] || "").toLowerCase(),
Expand Down
Loading