Skip to content
Merged
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
9 changes: 8 additions & 1 deletion packages/lint/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
// hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
// stay ReDoS-free and catch markers that re-form when a comment is removed.
let source = stripHtmlComments(rawSource);
const sourceWithoutTemplates = source.replace(
/<template\b[^>]*>[\s\S]*?<\/template(?:\s[^>]*)?>/gi,
" ",
);
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
if (templateMatch?.[1]) source = templateMatch[1];
// Some sub-composition files are HTML shells whose real root lives inside a
// <template>. Keep nested templates intact when the visible document already
// has a composition root; only unwrap when no root exists outside templates.
if (templateMatch?.[1] && !findRootTag(sourceWithoutTemplates)) source = templateMatch[1];

const tags = extractOpenTags(source);
const styles = [
Expand Down
81 changes: 81 additions & 0 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,87 @@ body {
expect(finding).toBeUndefined();
});

it("reports error when CSS block comment syntax leaks into visible markup", async () => {
const html = compositionWithBodyPrefix(
"",
`
/* Main Content Block */
<div class="editorial-block">Hello</div>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");

expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("visible HTML markup");
expect(finding?.snippet).toContain("Main Content Block");
});

it("reports error when a misbalanced style block leaves block comment syntax visible", async () => {
const html = compositionWithBodyPrefix(
"",
`
<style>
.editorial-block { color: #fff; }
</style>
</style>
/* Main Content Block */
<div class="editorial-block">Hello</div>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");

expect(finding).toBeDefined();
expect(finding?.snippet).toContain("Main Content Block");
});

it("does not report block comments inside style or script blocks", async () => {
const html = `
<html>
<head>
<title>/* tab name */ Particle Field</title>
<style>
/* Layout reset */
body { margin: 0; }
</style>
<noscript>/* fallback note */</noscript>
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
/* Timeline registry */
window.__timelines = {};
</script>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");

expect(finding).toBeUndefined();
});

it("does not report block comments in attributes, html comments, or protected text contexts", async () => {
const html = compositionWithBodyPrefix(
"",
`
<!-- /* hidden implementation note */ -->
<div data-note="/* attribute note */"></div>
<div data-note="a > b /* quoted attribute note */"></div>
<pre>/* visible code sample */</pre>
<code>/* visible inline code sample */</code>
<textarea>/* editable code sample */</textarea>
<template>/* template-only note */</template>
<svg viewBox="0 0 100 20"><text x="0" y="15">/* svg label */</text></svg>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");

expect(finding).toBeUndefined();
});

it("reports error when a stray style close tag is left in the document head", async () => {
const html = compositionWithHead(`
<style>
Expand Down
69 changes: 69 additions & 0 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ const ORPHAN_CSS_AT_RULE_PATTERN =
/(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
const ORPHAN_CSS_RULE_PATTERN =
/(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
const VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
const VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN =
/<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;

interface SourceRange {
start: number;
end: number;
}

function findCodeFenceLeak(headWithoutValidBlocks: string): string | null {
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
Expand Down Expand Up @@ -127,6 +135,50 @@ function findLeakedTextBeforeCompositionRoot(
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
}

function findProtectedVisibleMarkupRanges(source: string): SourceRange[] {
const ranges: SourceRange[] = [];
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
}

function isInsideSourceRange(index: number, ranges: SourceRange[]): boolean {
return ranges.some((range) => range.start <= index && index < range.end);
}

function isInsideHtmlTag(source: string, index: number): boolean {
let inTag = false;
let quote: '"' | "'" | null = null;
for (let i = 0; i < index; i++) {
const char = source[i];
if (!inTag) {
if (char === "<") inTag = true;
continue;
}
if (quote) {
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
} else if (char === ">") {
inTag = false;
}
}
return inTag;
}

function findVisibleMarkupCommentLeak(source: string): string | null {
const protectedRanges = findProtectedVisibleMarkupRanges(source);
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PATTERN)) {
if (isInsideHtmlTag(source, match.index)) continue;
if (isInsideSourceRange(match.index, protectedRanges)) continue;
return match[0];
}
return null;
}

export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// root_missing_composition_id + root_missing_dimensions
({ rootTag }) => {
Expand Down Expand Up @@ -174,6 +226,23 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
];
},

// visible_markup_comment
({ source }) => {
const snippet = findVisibleMarkupCommentLeak(source);
if (!snippet) return [];
return [
{
code: "visible_markup_comment",
severity: "error",
message:
"CSS/JS block comment syntax (`/* ... */`) appears in visible HTML markup. HTML only treats `<!-- ... -->` as comments, so this renders as on-screen text.",
fixHint:
"Remove the text or convert it to a real HTML comment (`<!-- ... -->`). Keep CSS comments inside `<style>` and JS comments inside `<script>`.",
snippet: truncateSnippet(snippet),
},
];
},

// missing_timeline_registry + timeline_registry_missing_init
({ source, rawSource, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
Expand Down
Loading