diff --git a/packages/cli/package.json b/packages/cli/package.json index 7016b2411..0d4e76389 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,6 +15,7 @@ ], "type": "module", "scripts": { + "test": "vitest run", "dev": "tsx src/cli.ts", "build": "bun run build:fonts && bun run build:studio && tsup && bun run build:runtime && bun run build:copy", "build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts", @@ -52,7 +53,8 @@ "picocolors": "^1.1.1", "tsup": "^8.0.0", "tsx": "^4.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^3.2.4" }, "engines": { "node": ">=22" diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index d3be75aef..1f33c9286 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -7,6 +7,8 @@ import { createRequire } from "node:module"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { isDevMode } from "../utils/env.js"; +import { lintProject } from "../utils/lintProject.js"; +import { formatLintFindings } from "../utils/lintFormat.js"; /** * Try to start a server on the given port, auto-incrementing up to maxAttempts @@ -71,6 +73,19 @@ export default defineCommand({ const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : basename(dir); + // Lint before starting — surface issues for the agent to fix. + // dev.ts doesn't use resolveProject() because it needs to proceed even without index.html. + const indexPath = join(dir, "index.html"); + if (existsSync(indexPath)) { + const project = { dir, name: projectName, indexPath }; + const lintResult = lintProject(project); + if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { + console.log(); + for (const line of formatLintFindings(lintResult)) console.log(line); + console.log(); + } + } + if (isDevMode()) { return runDevMode(dir, projectName); } diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 8d4031f46..b108b4a05 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,11 +1,8 @@ import { defineCommand } from "citty"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { lintHyperframeHtml } from "@hyperframes/core/lint"; -import type { HyperframeLintFinding } from "@hyperframes/core/lint"; -import { walkDir } from "@hyperframes/core/studio-api"; import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; +import { lintProject } from "../utils/lintProject.js"; +import { formatLintFindings } from "../utils/lintFormat.js"; import { withMeta } from "../utils/updateCheck.js"; export default defineCommand({ @@ -16,62 +13,32 @@ export default defineCommand({ }, async run({ args }) { const project = resolveProject(args.dir); - const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html")); - - const allFindings: (HyperframeLintFinding & { file: string })[] = []; - let totalErrors = 0; - let totalWarnings = 0; - - for (const file of htmlFiles) { - const html = readFileSync(join(project.dir, file), "utf-8"); - const result = lintHyperframeHtml(html, { filePath: file }); - for (const f of result.findings) { - allFindings.push({ ...f, file }); - } - totalErrors += result.errorCount; - totalWarnings += result.warningCount; - } + const lintResult = lintProject(project); if (args.json) { - console.log( - JSON.stringify( - withMeta({ - ok: totalErrors === 0, - findings: allFindings, - errorCount: totalErrors, - warningCount: totalWarnings, - filesScanned: htmlFiles.length, - }), - null, - 2, - ), - ); - process.exit(totalErrors > 0 ? 1 : 0); + const combined = { + ok: lintResult.totalErrors === 0, + errorCount: lintResult.totalErrors, + warningCount: lintResult.totalWarnings, + findings: lintResult.results.flatMap((r) => r.result.findings), + }; + console.log(JSON.stringify(withMeta(combined), null, 2)); + process.exit(combined.ok ? 0 : 1); } - console.log( - `${c.accent("◆")} Linting ${c.accent(project.name)} (${htmlFiles.length} HTML files)`, - ); + const fileCount = lintResult.results.length; + const fileLabel = fileCount === 1 ? lintResult.results[0]!.file : `${fileCount} files`; + console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/" + fileLabel)}`); console.log(); - if (allFindings.length === 0) { + if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) { console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`); return; } - for (const finding of allFindings) { - const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠"); - const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; - console.log( - `${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`, - ); - if (finding.fixHint) { - console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); - } - } + const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true }); + for (const line of lines) console.log(line); - const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇"); - console.log(`\n${summaryIcon} ${totalErrors} error(s), ${totalWarnings} warning(s)`); - process.exit(totalErrors > 0 ? 1 : 0); + process.exit(lintResult.totalErrors > 0 ? 1 : 0); }, }); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4f1ff6818..4bc73211c 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -3,6 +3,8 @@ import { existsSync, mkdirSync, statSync } from "node:fs"; import { cpus, freemem } from "node:os"; import { resolve, dirname, join } from "node:path"; import { resolveProject } from "../utils/project.js"; +import { lintProject, shouldBlockRender } from "../utils/lintProject.js"; +import { formatLintFindings } from "../utils/lintFormat.js"; import { loadProducer } from "../utils/producer.js"; import { c } from "../ui/colors.js"; import { formatBytes, formatDuration, errorBox } from "../ui/format.js"; @@ -75,6 +77,16 @@ Examples: description: "Suppress verbose output", default: false, }, + strict: { + type: "boolean", + description: "Fail render on lint errors", + default: false, + }, + "strict-all": { + type: "boolean", + description: "Fail render on lint errors AND warnings", + default: false, + }, }, async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── @@ -131,6 +143,8 @@ Examples: const useDocker = args.docker ?? false; const useGpu = args.gpu ?? false; const quiet = args.quiet ?? false; + const strictAll = args["strict-all"] ?? false; + const strictErrors = (args.strict ?? false) || strictAll; // ── Print render plan ───────────────────────────────────────────────── const workerCount = workers ?? defaultWorkerCount(); @@ -193,6 +207,31 @@ Examples: } } + // ── Pre-render lint ────────────────────────────────────────────────── + { + const lintResult = lintProject(project); + if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { + console.log(""); + for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); + if ( + shouldBlockRender( + strictErrors, + strictAll, + lintResult.totalErrors, + lintResult.totalWarnings, + ) + ) { + const mode = strictAll ? "--strict-all" : "--strict"; + console.log(""); + console.log(c.error(` Aborting render due to lint issues (${mode} mode).`)); + console.log(""); + process.exit(1); + } + console.log(c.dim(" Continuing render despite lint issues. Use --strict to block.")); + console.log(""); + } + } + // ── Render ──────────────────────────────────────────────────────────── if (useDocker) { await renderDocker(project.dir, outputPath, { diff --git a/packages/cli/src/utils/lintFormat.ts b/packages/cli/src/utils/lintFormat.ts new file mode 100644 index 000000000..7410fc508 --- /dev/null +++ b/packages/cli/src/utils/lintFormat.ts @@ -0,0 +1,51 @@ +import { c } from "../ui/colors.js"; +import type { ProjectLintResult } from "./lintProject.js"; + +export interface LintFormatOptions { + /** Show elementId in brackets after the code (default: true) */ + showElementId?: boolean; + /** Show summary line with error/warning counts (default: false) */ + showSummary?: boolean; + /** Group errors before warnings per file (default: false — interleaved) */ + errorsFirst?: boolean; +} + +/** + * Format lint findings for console output. Used by lint, render, and dev commands. + */ +export function formatLintFindings( + { results, totalErrors, totalWarnings }: ProjectLintResult, + options: LintFormatOptions = {}, +): string[] { + const { showElementId = true, showSummary = false, errorsFirst = false } = options; + const lines: string[] = []; + const multiFile = results.length > 1; + + for (const { file, result } of results) { + if (result.findings.length === 0) continue; + + const format = (finding: (typeof result.findings)[0]) => { + const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠"); + const fileLabel = multiFile ? c.dim(`[${file}] `) : ""; + const loc = + showElementId && finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; + lines.push(` ${prefix} ${fileLabel}${c.bold(finding.code)}${loc}: ${finding.message}`); + if (finding.fixHint) lines.push(` ${c.dim(`Fix: ${finding.fixHint}`)}`); + }; + + if (errorsFirst) { + for (const f of result.findings) if (f.severity === "error") format(f); + for (const f of result.findings) if (f.severity === "warning") format(f); + } else { + for (const f of result.findings) format(f); + } + } + + if (showSummary) { + const icon = totalErrors > 0 ? c.error("◇") : c.success("◇"); + lines.push(""); + lines.push(`${icon} ${totalErrors} error(s), ${totalWarnings} warning(s)`); + } + + return lines; +} diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts new file mode 100644 index 000000000..21dc05870 --- /dev/null +++ b/packages/cli/src/utils/lintProject.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { lintProject, shouldBlockRender } from "./lintProject.js"; +import type { ProjectDir } from "./project.js"; + +function tmpProject(name: string): string { + const dir = join(tmpdir(), `hf-test-${name}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function validHtml(compId = "main"): string { + return ` +
+ +`; +} + +function htmlWithMissingMediaId(): string { + return ` +
+ +
+ +`; +} + +function htmlWithPreloadNone(): string { + return ` +
+ +
+ +`; +} + +let dirs: string[] = []; + +function makeProject(indexHtml: string, subComps?: Record): ProjectDir { + const dir = tmpProject("lint"); + dirs.push(dir); + writeFileSync(join(dir, "index.html"), indexHtml); + if (subComps) { + const compsDir = join(dir, "compositions"); + mkdirSync(compsDir, { recursive: true }); + for (const [name, html] of Object.entries(subComps)) { + writeFileSync(join(compsDir, name), html); + } + } + return { dir, name: "test-project", indexPath: join(dir, "index.html") }; +} + +afterEach(() => { + for (const d of dirs) { + rmSync(d, { recursive: true, force: true }); + } + dirs = []; +}); + +describe("lintProject", () => { + it("returns zero errors/warnings for a clean project", () => { + const project = makeProject(validHtml()); + const { totalErrors, totalWarnings, results } = lintProject(project); + + expect(totalErrors).toBe(0); + expect(totalWarnings).toBe(0); + expect(results).toHaveLength(1); + expect(results[0]!.file).toBe("index.html"); + }); + + it("detects errors in index.html", () => { + const project = makeProject(htmlWithMissingMediaId()); + const { totalErrors, results } = lintProject(project); + + expect(totalErrors).toBeGreaterThan(0); + const mediaFinding = results[0]!.result.findings.find((f) => f.code === "media_missing_id"); + expect(mediaFinding).toBeDefined(); + }); + + it("lints sub-compositions in compositions/ directory", () => { + const project = makeProject(validHtml(), { + "captions.html": htmlWithMissingMediaId(), + }); + const { totalErrors, results } = lintProject(project); + + expect(results).toHaveLength(2); + expect(results[1]!.file).toBe("compositions/captions.html"); + expect(totalErrors).toBeGreaterThan(0); + const subFindings = results[1]!.result.findings; + expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true); + }); + + it("aggregates errors across index.html and sub-compositions", () => { + const project = makeProject(htmlWithMissingMediaId(), { + "overlay.html": htmlWithMissingMediaId(), + }); + const { totalErrors, results } = lintProject(project); + + expect(results).toHaveLength(2); + // Both files have media_missing_id errors + const rootErrors = results[0]!.result.errorCount; + const subErrors = results[1]!.result.errorCount; + expect(totalErrors).toBe(rootErrors + subErrors); + }); + + it("aggregates warnings from sub-compositions", () => { + const project = makeProject(validHtml(), { + "captions.html": htmlWithPreloadNone(), + }); + const { totalWarnings, results } = lintProject(project); + + expect(results).toHaveLength(2); + expect(totalWarnings).toBeGreaterThan(0); + const preloadWarning = results[1]!.result.findings.find((f) => f.code === "media_preload_none"); + expect(preloadWarning).toBeDefined(); + }); + + it("handles project with no compositions/ directory", () => { + const project = makeProject(validHtml()); + // No compositions/ dir created + const { results } = lintProject(project); + + expect(results).toHaveLength(1); + }); + + it("ignores non-HTML files in compositions/", () => { + const project = makeProject(validHtml(), { + "captions.html": validHtml("captions"), + }); + // Add a non-HTML file + writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html"); + + const { results } = lintProject(project); + + expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt + }); +}); + +describe("shouldBlockRender", () => { + it("default: does not block on errors", () => { + expect(shouldBlockRender(false, false, 5, 0)).toBe(false); + }); + + it("default: does not block on warnings", () => { + expect(shouldBlockRender(false, false, 0, 3)).toBe(false); + }); + + it("--strict: blocks on errors", () => { + expect(shouldBlockRender(true, false, 1, 0)).toBe(true); + }); + + it("--strict: does not block on warnings only", () => { + expect(shouldBlockRender(true, false, 0, 5)).toBe(false); + }); + + it("--strict-all: blocks on errors", () => { + expect(shouldBlockRender(true, true, 1, 0)).toBe(true); + }); + + it("--strict-all: blocks on warnings", () => { + expect(shouldBlockRender(true, true, 0, 1)).toBe(true); + }); + + it("--strict-all: does not block when clean", () => { + expect(shouldBlockRender(true, true, 0, 0)).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts new file mode 100644 index 000000000..c612f33f8 --- /dev/null +++ b/packages/cli/src/utils/lintProject.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; +import type { ProjectDir } from "./project.js"; + +export interface ProjectLintResult { + results: Array<{ file: string; result: HyperframeLintResult }>; + totalErrors: number; + totalWarnings: number; +} + +/** + * Lint the root index.html and all sub-compositions in the compositions/ directory. + * Returns aggregated results across all files. + */ +export function lintProject(project: ProjectDir): ProjectLintResult { + const results: Array<{ file: string; result: HyperframeLintResult }> = []; + let totalErrors = 0; + let totalWarnings = 0; + + // Lint root composition + const rootHtml = readFileSync(project.indexPath, "utf-8"); + const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath }); + results.push({ file: "index.html", result: rootResult }); + totalErrors += rootResult.errorCount; + totalWarnings += rootResult.warningCount; + + // Lint sub-compositions in compositions/ directory + const compositionsDir = resolve(project.dir, "compositions"); + if (existsSync(compositionsDir)) { + const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html")); + for (const file of files) { + const filePath = join(compositionsDir, file); + const html = readFileSync(filePath, "utf-8"); + const result = lintHyperframeHtml(html, { filePath }); + results.push({ file: `compositions/${file}`, result }); + totalErrors += result.errorCount; + totalWarnings += result.warningCount; + } + } + + return { results, totalErrors, totalWarnings }; +} + +/** + * Determine whether a render should be blocked based on lint results and strict mode. + * --strict blocks on errors; --strict-all blocks on errors or warnings. + */ +export function shouldBlockRender( + strictErrors: boolean, + strictAll: boolean, + totalErrors: number, + totalWarnings: number, +): boolean { + return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0)); +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts index 201c455bb..1a5d31758 100644 --- a/packages/core/src/lint/hyperframeLinter.test.ts +++ b/packages/core/src/lint/hyperframeLinter.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { lintHyperframeHtml } from "./hyperframeLinter.js"; +import { describe, it, expect, vi } from "vitest"; +import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js"; describe("lintHyperframeHtml", () => { const validComposition = ` @@ -130,4 +130,335 @@ describe("lintHyperframeHtml", () => { ); expect(missing).toHaveLength(0); }); + + it("reports error when timeline registry is assigned without initializing", () => { + const html = ` + +
+
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("without initializing"); + }); + + it("does not flag timeline assignment when init guard is present", () => { + const result = lintHyperframeHtml(validComposition); + const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init"); + expect(finding).toBeUndefined(); + }); + + it("reports error for audio with data-start but no id", () => { + const html = ` + +
+ +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "media_missing_id"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("SILENT"); + }); + + it("reports error for video with data-start but no id", () => { + const html = ` + +
+ +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "media_missing_id"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("FROZEN"); + }); + + it("does not flag media elements that have id", () => { + const html = ` + +
+ + +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "media_missing_id"); + expect(finding).toBeUndefined(); + }); + + it("reports warning for media with preload=none", () => { + const html = ` + +
+ +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "media_preload_none"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("reports error for media with id but no src", () => { + const html = ` + +
+ +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "media_missing_src"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); +}); + +describe("lintScriptUrls", () => { + it("reports error for script URL returning non-2xx", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 }); + vi.stubGlobal("fetch", mockFetch); + + const html = ` +
+ +`; + const findings = await lintScriptUrls(html); + const finding = findings.find((f) => f.code === "inaccessible_script_url"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("404"); + + vi.unstubAllGlobals(); + }); + + it("reports error for unreachable script URL", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError")); + vi.stubGlobal("fetch", mockFetch); + + const html = ` +
+ +`; + const findings = await lintScriptUrls(html); + const finding = findings.find((f) => f.code === "inaccessible_script_url"); + expect(finding).toBeDefined(); + + vi.unstubAllGlobals(); + }); + + it("does not flag accessible script URLs", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", mockFetch); + + const html = ` +
+ +`; + const findings = await lintScriptUrls(html); + expect(findings.length).toBe(0); + + vi.unstubAllGlobals(); + }); + + it("skips inline scripts without src", async () => { + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const html = ` +
+ +`; + const findings = await lintScriptUrls(html); + expect(findings.length).toBe(0); + expect(mockFetch).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + // ── gsap_css_transform_conflict ────────────────────────────────────────── + + it("warns when tl.to animates x on an element with CSS translateX", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + expect(finding?.selector).toBe("#title"); + expect(finding?.fixHint).toMatch(/fromTo/); + expect(finding?.fixHint).toMatch(/xPercent/); + }); + + it("warns when tl.to animates scale on an element with CSS scale transform", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + expect(finding?.selector).toBe("#hero"); + }); + + it("does NOT warn when tl.to targets element without CSS transform", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(conflict).toBeUndefined(); + }); + + it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict"); + expect(conflict).toBeUndefined(); + }); + + it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => { + const html = ` + +
+
+
+ + +`; + const result = lintHyperframeHtml(html); + const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict"); + expect(conflicts).toHaveLength(1); + expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/); + }); +}); + +describe("template_literal_selector rule", () => { + it("reports error when querySelector uses template literal variable", () => { + const html = ` + +
+
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "template_literal_selector"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("reports error for querySelectorAll with template literal variable", () => { + const html = ` + +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "template_literal_selector"); + expect(finding).toBeDefined(); + }); + + it("does not report error for hardcoded querySelector strings", () => { + const html = ` + +
+
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "template_literal_selector"); + expect(finding).toBeUndefined(); + }); }); diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index 57bf8ce27..5936179e7 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -379,8 +379,8 @@ export function lintHyperframeHtml( if (!parentClosePattern.test(between)) { pushFinding({ code: "video_nested_in_timed_element", - severity: "warning", - message: `