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
16 changes: 10 additions & 6 deletions packages/cli/src/utils/lintProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,17 +142,19 @@ describe("lintProject", () => {
});
writeFileSync(
join(project, "compositions", "scene.css"),
'[data-composition-id="scene"] .title { opacity: 0; }',
'[data-composition-id="no-such-comp"] .title { opacity: 0; }',
);

const { results } = await lintProject(project);
const subResult = results.find((result) => result.file === "compositions/scene.html");
// The linked stylesheet scopes CSS to a composition id that has no wrapper
// here, so this finding can only come from the linked file being read.
const finding = subResult?.result.findings.find(
(item) => item.code === "composition_self_attribute_selector",
(item) => item.code === "scoped_css_missing_wrapper",
);

expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
expect(finding?.selector).toBe('[data-composition-id="no-such-comp"]');
});

it("lints percent-encoded linked CSS filenames that exist decoded on disk", async () => {
Expand All @@ -165,17 +167,19 @@ describe("lintProject", () => {
});
writeFileSync(
join(project, "compositions", decodeURIComponent(encodedFilename)),
'[data-composition-id="scene"] .title { opacity: 0; }',
'[data-composition-id="no-such-comp"] .title { opacity: 0; }',
);

const { results } = await lintProject(project);
const subResult = results.find((result) => result.file === "compositions/scene.html");
// The linked stylesheet scopes CSS to a composition id that has no wrapper
// here, so this finding can only come from the linked file being read.
const finding = subResult?.result.findings.find(
(item) => item.code === "composition_self_attribute_selector",
(item) => item.code === "scoped_css_missing_wrapper",
);

expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
expect(finding?.selector).toBe('[data-composition-id="no-such-comp"]');
});

it("aggregates errors across index.html and sub-compositions", async () => {
Expand Down
19 changes: 14 additions & 5 deletions packages/lint/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,22 +245,31 @@ describe("template shell style sources", () => {
<div id="scene" data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
<template data-composition-id="shell">
<link rel="stylesheet" href="shell.css">
<style>[data-composition-id="main"] .title { opacity: 0; }</style>
<style>[data-composition-id="from-style-block"] .title { opacity: 0; }</style>
<div style="mask-image: url(missing-inline-mask.png)"></div>
<template><style>[data-composition-id="main"] .nested { opacity: 0; }</style></template>
<template><style>[data-composition-id="from-nested-template"] .nested { opacity: 0; }</style></template>
</template>
<script>window.__timelines = {};</script>
</body></html>`);
writeFileSync(
join(project, "shell.css"),
'[data-composition-id="main"] .from-link { opacity: 0; }',
'[data-composition-id="from-link"] .from-link { opacity: 0; }',
);

const { results } = await lintProject(project);
const findings = results.flatMap((entry) => entry.result.findings);
// Each style source scopes CSS to a composition id that has no wrapper, so
// one scoped_css_missing_wrapper per source proves all three were collected.
expect(
findings.filter((finding) => finding.code === "composition_self_attribute_selector"),
).toHaveLength(3);
findings
.filter((finding) => finding.code === "scoped_css_missing_wrapper")
.map((finding) => finding.selector)
.sort(),
).toEqual([
'[data-composition-id="from-link"]',
'[data-composition-id="from-nested-template"]',
'[data-composition-id="from-style-block"]',
]);
expect(findings.some((finding) => finding.code === "texture_mask_asset_not_found")).toBe(true);
});
});
Expand Down
52 changes: 0 additions & 52 deletions packages/lint/src/rules/captions.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,5 @@
import type { LintContext, HyperframeLintFinding } from "../context";

/** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */
// fallow-ignore-next-line complexity
function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null {
const openIdx = varMatch.index + varMatch[0].length - 1;
let depth = 0;
let inStr = false;
let strChar = "";
for (let i = openIdx; i < src.length; i++) {
const c = src[i]!;
if (inStr) {
if (c === "\\") {
i++;
continue;
}
if (c === strChar) inStr = false;
} else if (c === '"' || c === "'") {
inStr = true;
strChar = c;
} else if (c === "[") {
depth++;
} else if (c === "]") {
depth--;
if (depth === 0) return src.slice(openIdx, i + 1);
}
}
return null;
}

export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// caption_exit_missing_hard_kill
({ scripts, styles, options, rootCompositionId }) => {
Expand Down Expand Up @@ -122,30 +94,6 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
});
}

if (hasInlineTranscript) {
// Verify the inline transcript can be parsed.
// Use a balanced-bracket scan instead of a regex to correctly handle
// nested arrays (e.g. word-level timing arrays inside each entry).
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
if (transcriptJson) {
try {
JSON.parse(transcriptJson);
} catch {
findings.push({
code: "caption_transcript_parse_error",
severity: "error",
message:
"Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " +
"to parse it. Common cause: unquoted property keys with apostrophes in text.",
fixHint:
'Use JSON-quoted keys: { "text": "don\'t", "start": 0, "end": 1 } instead of ' +
'{ text: "don\'t", start: 0, end: 1 }.',
});
}
}
}

return findings;
},

Expand Down
30 changes: 0 additions & 30 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,36 +416,6 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},

// timed_element_missing_visibility_hidden
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
if (!readAttr(tag.raw, "data-start")) continue;
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-composition-src")) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const styleAttr = readAttr(tag.raw, "style") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
const hasHiddenStyle =
/visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
if (!hasClip && !hasHiddenStyle) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "timed_element_missing_visibility_hidden",
severity: "info",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
elementId,
fixHint:
'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},

// deprecated_data_layer + deprecated_data_end
// fallow-ignore-next-line complexity
({ tags }) => {
Expand Down
60 changes: 0 additions & 60 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1096,64 +1096,4 @@ body {
expect(finding).toBeUndefined();
});
});

describe("composition_self_attribute_selector", () => {
it("warns when inline CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>
[data-composition-id="scene"] .title { opacity: 0; }
[data-composition-id="other"] .title { color: red; }
</style>
<h1 class="title">Hello</h1>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "composition_self_attribute_selector",
);

expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("warning");
expect(findings[0]?.selector).toBe('[data-composition-id="scene"] .title');
expect(findings[0]?.fixHint).toContain("#scene");
expect(findings[0]?.fixHint).not.toContain("#556");
});

it("warns when external CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html, {
externalStyles: [
{
href: "scene.css",
content: '[data-composition-id="scene"] .title { opacity: 0; }',
},
],
});
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");

expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
});

it("does not warn when CSS targets a different composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="other"] .title { opacity: 0; }</style>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");

expect(finding).toBeUndefined();
});
});
});
98 changes: 0 additions & 98 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,6 @@ import {
INVALID_SCRIPT_CLOSE_PATTERN,
} from "../utils";

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function selectorTargetsCompositionId(selector: string, compositionId: string): boolean {
const escaped = escapeRegExp(compositionId);
return new RegExp(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`,
).test(selector);
}

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

Expand Down Expand Up @@ -512,40 +501,6 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},

// composition_self_attribute_selector
({ styles, rootCompositionId, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootCompositionId) return findings;
const seenSelectors = new Set<string>();
const rootId = readAttr(rootTag?.raw || "", "id");
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkRules((rule) => {
for (const selector of rule.selectors) {
if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
if (seenSelectors.has(selector)) continue;
seenSelectors.add(selector);
findings.push({
code: "composition_self_attribute_selector",
severity: "warning",
message:
"Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
selector,
fixHint: rootId
? `Use #${rootId} for clearer authoring intent and instance-isolated styling.`
: "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling.",
});
}
});
}
return findings;
},

// studio_missing_editable_id
({ tags, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down Expand Up @@ -628,57 +583,4 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
}
return findings;
},

// pointer_events_none
// fallow-ignore-next-line complexity
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();

for (const tag of tags) {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
const inlineStyle = readAttr(tag.raw, "style") ?? "";
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
const id = readAttr(tag.raw, "id");
const key = id ?? tag.raw;
if (reported.has(key)) continue;
reported.add(key);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
elementId: id || undefined,
fixHint:
"If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
snippet: truncateSnippet(tag.raw),
});
}

for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkDecls("pointer-events", (decl) => {
if (decl.value.trim().toLowerCase() !== "none") return;
const rule = decl.parent;
if (!rule || rule.type !== "rule") return;
const selector = (rule as postcss.Rule).selector;
if (reported.has(selector)) return;
reported.add(selector);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
selector,
fixHint:
"If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
});
});
}

return findings;
},
];
Loading
Loading