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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/lint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8"
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.4"
},
"devDependencies": {
"@types/node": "^25.0.10",
Expand Down
102 changes: 102 additions & 0 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,108 @@ body {
});
});

describe("repeated_id_descendant_selector", () => {
it("reports a selector that nests the same id inside itself", async () => {
const html = `<div id="scene_01" data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01 #scene_01 .headline { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "repeated_id_descendant_selector");
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#scene_01 #scene_01 .headline");
});

it("does not report distinct descendant ids", async () => {
const html = `<div id="scene_01" data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01 #headline { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeUndefined();
});

it.each(["#scene_01 > #scene_01", "#scene_01 .wrapper #scene_01"])(
"reports repeated ids across descendant combinators: %s",
async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeDefined();
},
);

it.each([
":is(#scene_01) #scene_01",
":where(#scene_01) #scene_01",
"#scene_01 :is(#scene_01)",
])("reports repeated ids required by selector pseudos: %s", async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeDefined();
});

it.each([
":is(#scene_01, .scene) #scene_01",
":not(#scene_01) #scene_01",
":has(#scene_01) #scene_01",
])("does not report ids that are not required by a selector pseudo: %s", async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeUndefined();
});

it.each(["& #scene_01 .headline", "#scene_01 .headline"])(
"reports repeated ids created by nested CSS: %s",
async (nestedSelector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01 { ${nestedSelector} { color: red; } }</style>
</div>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "repeated_id_descendant_selector",
);
expect(finding).toBeDefined();
expect(finding?.selector).toContain("#scene_01 #scene_01");
},
);

it("preserves dollar sequences while resolving nested selectors", async () => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>#scene_01[data-query="$1"] { & #scene_01 { color: red; } }</style>
</div>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "repeated_id_descendant_selector",
);
expect(finding?.selector).toBe('#scene_01[data-query="$1"] #scene_01');
});

it.each(['[data-query="#scene_01 #scene_01"]', String.raw`#scene_01 #scene_01\:child`])(
"does not report non-repeated parsed ids: %s",
async (selector) => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
<style>${selector} { color: red; }</style>
</div>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
).toBeUndefined();
},
);
});

it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
const html = `
<html><body>
Expand Down
111 changes: 111 additions & 0 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import {
readAttr,
readDecodedAttr,
Expand All @@ -25,6 +26,87 @@ function selectorTargetsCompositionId(selector: string, compositionId: string):
).test(selector);
}

function repeatedDescendantId(selector: string): string | null {
let repeated: string | null = null;

const requiredPseudoIds = (pseudo: selectorParser.Pseudo): Set<string> => {
if (![":is", ":where"].includes(pseudo.value.toLowerCase()) || pseudo.nodes.length === 0) {
return new Set<string>();
}

const optionIdSets: Set<string>[] = [];
for (const option of pseudo.nodes) {
// Only promote ids from a single compound. For selector-list branches with
// combinators, determining which compound is the subject requires fuller
// selector semantics; skipping them avoids false positives.
if (option.nodes.some((node) => node.type === "combinator")) return new Set<string>();
const optionIds = new Set<string>(
option.nodes.filter((node) => node.type === "id").map((node) => node.value),
);
optionIdSets.push(optionIds);
}
const [firstOptionIds, ...remainingOptionIds] = optionIdSets;
return new Set<string>(
[...(firstOptionIds ?? [])].filter((id) =>
remainingOptionIds.every((optionIds) => optionIds.has(id)),
),
);
};

try {
selectorParser((root) => {
root.each((selectorNode) => {
const firstCompoundById = new Map<string, number>();
let compound = 0;
selectorNode.each((node) => {
Comment thread
jrusso1020 marked this conversation as resolved.
if (repeated) return;
if (node.type === "combinator") {
compound += 1;
return;
}
const requiredIds =
node.type === "id"
? [node.value]
: node.type === "pseudo"
? [...requiredPseudoIds(node)]
: [];
for (const id of requiredIds) {
const firstCompound = firstCompoundById.get(id);
if (firstCompound !== undefined && firstCompound !== compound) {
repeated = id;
return;
}
firstCompoundById.set(id, compound);
}
});
});
}).processSync(selector);
} catch {
return null;
}
return repeated;
}

function resolvedRuleSelectors(rule: postcss.Rule): string[] {
let ancestor: postcss.AnyNode | undefined = rule.parent;
while (ancestor && ancestor.type !== "rule") ancestor = ancestor.parent;
if (!ancestor || ancestor.type !== "rule") return rule.selectors;

const parentSelectors = resolvedRuleSelectors(ancestor);
return parentSelectors.flatMap((parentSelector) =>
rule.selectors.map((childSelector) => {
const nestingToken = /(^|[\s>+~,(])&/g;
if (nestingToken.test(childSelector)) {
return childSelector.replace(
nestingToken,
(_, separator: string) => separator + parentSelector,
);
}
return `${parentSelector} ${childSelector}`;
}),
);
}

function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
Expand Down Expand Up @@ -321,6 +403,35 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},

// repeated_id_descendant_selector
({ styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkRules((rule) => {
for (const selector of resolvedRuleSelectors(rule)) {
const repeatedId = repeatedDescendantId(selector);
if (!repeatedId || reported.has(repeatedId)) continue;
reported.add(repeatedId);
findings.push({
code: "repeated_id_descendant_selector",
severity: "error",
message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`,
selector,
fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`,
});
}
});
}
return findings;
},

// invalid_inline_script_syntax (malformed close tag)
({ source }) => {
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
Expand Down
28 changes: 28 additions & 0 deletions packages/lint/src/rules/fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,34 @@ describe("font rules", () => {
expect(result.errorCount).toBe(0);
});

it("accepts a URL-style plus alias used literally as the CSS family", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@import url("https://fonts.googleapis.com/css2?family=DM+Mono&family=IBM+Plex+Mono&display=swap");
h1 { font-family: 'DM+Mono', monospace; }
code { font-family: 'IBM+Plex+Mono', monospace; }
</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});

it("does not decode percent escapes in a literal CSS family", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@import url("https://fonts.googleapis.com/css2?family=DM+Mono&display=swap");
h1 { font-family: 'DM%20Mono', monospace; }
</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(1);
});

it("still flags non-bundled families not covered by the Google Fonts URL", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
Expand Down
5 changes: 4 additions & 1 deletion packages/lint/src/rules/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,10 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const googleFonts = collectGoogleFontFamilies(source, styles);

const undeclared = used.filter(
(name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name),
(name) =>
!declared.has(name) &&
!FONT_ALIAS_KEYS.has(name) &&
!googleFonts.has(name.replace(/\+/g, " ")),
);
if (undeclared.length === 0) return findings;

Expand Down
Loading
Loading