From b1f971d7ebe7d370b6651c92b5ac4fc06e3382f9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 21:20:32 -0400 Subject: [PATCH 1/6] refactor: extract @hyperframes/lint package from core Moves all lint rules, hyperframeLinter, lintProject, and related types from packages/core/src/lint/ into a new standalone packages/lint package. Core keeps a thin re-export stub at @hyperframes/core/lint for backward compatibility. Consumer imports (cli lint command, producer hyperframeLint) are updated to import from @hyperframes/lint directly. Depends on @hyperframes/parsers (PR #1755). --- .fallowrc.jsonc | 25 +- .github/workflows/publish.yml | 1 + bun.lock | 28 +- package.json | 2 +- packages/cli/package.json | 1 + packages/cli/src/commands/lint.ts | 2 +- packages/cli/src/commands/preview.ts | 3 +- packages/cli/src/commands/publish.ts | 7 +- packages/cli/src/commands/render.ts | 2 +- packages/cli/src/server/studioServer.ts | 2 +- packages/cli/src/utils/lintProject.ts | 576 +------ packages/cli/tsup.config.ts | 1 + packages/core/package.json | 4 +- packages/core/src/index.ts | 10 +- packages/core/src/lint/index.ts | 9 +- packages/core/src/slideshow/index.ts | 1 + packages/lint/package.json | 56 + packages/lint/src/context.ts | 66 + packages/lint/src/hyperframeLinter.test.ts | 124 ++ packages/lint/src/hyperframeLinter.ts | 155 ++ packages/lint/src/index.ts | 9 + packages/lint/src/project.ts | 511 +++++++ packages/lint/src/rules/adapters.test.ts | 144 ++ packages/lint/src/rules/adapters.ts | 55 + packages/lint/src/rules/captions.test.ts | 166 +++ packages/lint/src/rules/captions.ts | 271 ++++ packages/lint/src/rules/composition.test.ts | 1104 ++++++++++++++ packages/lint/src/rules/composition.ts | 723 +++++++++ packages/lint/src/rules/core.test.ts | 730 +++++++++ packages/lint/src/rules/core.ts | 475 ++++++ packages/lint/src/rules/fonts.test.ts | 285 ++++ packages/lint/src/rules/fonts.ts | 222 +++ packages/lint/src/rules/gsap.test.ts | 1318 +++++++++++++++++ packages/lint/src/rules/gsap.ts | 1142 ++++++++++++++ packages/lint/src/rules/media.test.ts | 251 ++++ packages/lint/src/rules/media.ts | 526 +++++++ packages/lint/src/rules/slideshow.test.ts | 73 + packages/lint/src/rules/slideshow.ts | 85 ++ packages/lint/src/rules/textures.test.ts | 145 ++ packages/lint/src/rules/textures.ts | 226 +++ packages/lint/src/types.ts | 40 + packages/lint/src/utils.ts | 300 ++++ packages/lint/tsconfig.json | 18 + packages/lint/tsup.config.ts | 14 + packages/lint/vitest.config.ts | 8 + packages/producer/package.json | 1 + .../producer/src/services/hyperframeLint.ts | 2 +- scripts/set-version.ts | 1 + 48 files changed, 9311 insertions(+), 609 deletions(-) create mode 100644 packages/lint/package.json create mode 100644 packages/lint/src/context.ts create mode 100644 packages/lint/src/hyperframeLinter.test.ts create mode 100644 packages/lint/src/hyperframeLinter.ts create mode 100644 packages/lint/src/index.ts create mode 100644 packages/lint/src/project.ts create mode 100644 packages/lint/src/rules/adapters.test.ts create mode 100644 packages/lint/src/rules/adapters.ts create mode 100644 packages/lint/src/rules/captions.test.ts create mode 100644 packages/lint/src/rules/captions.ts create mode 100644 packages/lint/src/rules/composition.test.ts create mode 100644 packages/lint/src/rules/composition.ts create mode 100644 packages/lint/src/rules/core.test.ts create mode 100644 packages/lint/src/rules/core.ts create mode 100644 packages/lint/src/rules/fonts.test.ts create mode 100644 packages/lint/src/rules/fonts.ts create mode 100644 packages/lint/src/rules/gsap.test.ts create mode 100644 packages/lint/src/rules/gsap.ts create mode 100644 packages/lint/src/rules/media.test.ts create mode 100644 packages/lint/src/rules/media.ts create mode 100644 packages/lint/src/rules/slideshow.test.ts create mode 100644 packages/lint/src/rules/slideshow.ts create mode 100644 packages/lint/src/rules/textures.test.ts create mode 100644 packages/lint/src/rules/textures.ts create mode 100644 packages/lint/src/types.ts create mode 100644 packages/lint/src/utils.ts create mode 100644 packages/lint/tsconfig.json create mode 100644 packages/lint/tsup.config.ts create mode 100644 packages/lint/vitest.config.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index d393d3acb2..22e9af259f 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -267,6 +267,18 @@ "packages/parsers/src/gsapWriterParity.acorn.test.ts", "packages/parsers/src/htmlParser.roundtrip.test.ts", "packages/parsers/src/htmlParser.test.ts", + // @hyperframes/lint rule test files: parallel arrange/act/assert test cases + // (pre-existing structure from when lint lived in packages/core/src/lint/). + "packages/lint/src/rules/adapters.test.ts", + "packages/lint/src/rules/captions.test.ts", + "packages/lint/src/rules/composition.test.ts", + "packages/lint/src/rules/core.test.ts", + "packages/lint/src/rules/fonts.test.ts", + "packages/lint/src/rules/gsap.test.ts", + "packages/lint/src/rules/media.test.ts", + "packages/lint/src/rules/slideshow.test.ts", + "packages/lint/src/rules/textures.test.ts", + "packages/lint/src/hyperframeLinter.test.ts", // slideshowPanelHelpers.ts: setSlideNotes/addFragment/addHotspot share an // intentional parallel shape (signature + mapSlidesIn → exists-check → // map/append); the per-slide mutation differs, so a shared abstraction @@ -311,6 +323,13 @@ "packages/parsers/src/htmlParser.ts", // executeGsapMutation (CRITICAL) pre-dates this PR; studio-api still lives in core. "packages/core/src/studio-api/routes/files.ts", + // lint rule implementations and project linter: pre-existing complexity + // (moved from packages/core/src/lint/). File-level exemption avoids the + // line-shift fingerprint problem for inherited findings. + "packages/lint/src/rules/media.ts", + "packages/lint/src/rules/textures.ts", + "packages/lint/src/rules/gsap.ts", + "packages/lint/src/project.ts", // SlideshowPanel.tsx: top-level editor panel that wires several independent // sections (slides/inspector/branches/hotspot). Its cyclomatic count comes // from that fan-out; splitting it would scatter shared state without @@ -329,7 +348,7 @@ // generated table's shape test; this is dev tooling, not shipped runtime. "packages/cli/scripts/sync-agent-dirs.ts", // Files modified only for import-path updates (one-line changes to switch - // from @hyperframes/core/* subpaths to @hyperframes/parsers). Their complexity + // from @hyperframes/core/* subpaths to the new packages). Their complexity // is pre-existing; the line-shift fingerprint problem makes fallow treat // the violations as new even though no logic changed. "packages/core/src/core.types.ts", @@ -338,6 +357,10 @@ "packages/studio/src/hooks/gsapShared.ts", "packages/studio/src/hooks/gsapDragPositionCommit.ts", "packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts", + "packages/cli/src/commands/lint.ts", + "packages/cli/src/commands/preview.ts", + "packages/cli/src/commands/publish.ts", + "packages/cli/src/server/studioServer.ts", // set-version.ts: compareSemver helper has pre-existing complexity from // semver string parsing logic; line-shift fingerprint problem from new // packages added to PACKAGES array makes fallow treat it as new. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 42123e75f1..e12a14e47c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -125,6 +125,7 @@ jobs: } publish_pkg "@hyperframes/parsers" "@hyperframes/parsers" + publish_pkg "@hyperframes/lint" "@hyperframes/lint" publish_pkg "@hyperframes/core" "@hyperframes/core" publish_pkg "@hyperframes/sdk" "@hyperframes/sdk" publish_pkg "@hyperframes/engine" "@hyperframes/engine" diff --git a/bun.lock b/bun.lock index 35c28338b2..3d83ce6005 100644 --- a/bun.lock +++ b/bun.lock @@ -82,6 +82,7 @@ "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/gcp-cloud-run": "workspace:*", + "@hyperframes/lint": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", @@ -104,11 +105,11 @@ "version": "0.7.13", "dependencies": { "@chenglou/pretext": "^0.0.5", + "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", "bpm-detective": "^2.0.5", "linkedom": "^0.18.12", "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -167,6 +168,22 @@ "typescript": "^5.7.2", }, }, + "packages/lint": { + "name": "@hyperframes/lint", + "version": "0.7.11", + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", + "postcss": "^8.5.8", + }, + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, "packages/parsers": { "name": "@hyperframes/parsers", "version": "0.7.11", @@ -220,6 +237,7 @@ "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", "@hyperframes/engine": "workspace:^", + "@hyperframes/lint": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", "postcss": "^8.4.0", @@ -685,6 +703,8 @@ "@hyperframes/gcp-cloud-run": ["@hyperframes/gcp-cloud-run@workspace:packages/gcp-cloud-run"], + "@hyperframes/lint": ["@hyperframes/lint@workspace:packages/lint"], + "@hyperframes/parsers": ["@hyperframes/parsers@workspace:packages/parsers"], "@hyperframes/player": ["@hyperframes/player@workspace:packages/player"], @@ -1815,7 +1835,7 @@ "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], - "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], @@ -2213,8 +2233,6 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -2231,8 +2249,6 @@ "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], diff --git a/package.json b/package.json index ac70315d6a..6751dd3a85 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter @hyperframes/parsers build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", + "build": "bun run --filter '@hyperframes/{parsers,lint}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", diff --git a/packages/cli/package.json b/packages/cli/package.json index 11237086fa..5c8e474508 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,7 @@ "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/gcp-cloud-run": "workspace:*", + "@hyperframes/lint": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", "@types/adm-zip": "^0.5.7", diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 73b74555e6..e64a973e9a 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -38,7 +38,7 @@ export default defineCommand({ async run({ args }) { try { const project = resolveProject(args.dir); - const lintResult = await lintProject(project); + const lintResult = await lintProject(project.dir); if (args.json) { const allFindings = lintResult.results.flatMap((r) => r.result.findings); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 80749a8a6e..8a26503cda 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -123,11 +123,10 @@ export default defineCommand({ const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; const project = resolveProject(rawArg); const dir = project.dir; - const indexPath = project.indexPath; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name; // Lint before starting — surface issues for the agent to fix. - const lintResult = await lintProject({ dir, name: projectName, indexPath }); + const lintResult = await lintProject(dir); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 182743fcc9..260eb082d5 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,4 +1,4 @@ -import { basename, resolve } from "node:path"; +import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { defineCommand } from "citty"; @@ -33,12 +33,9 @@ export default defineCommand({ async run({ args }) { const rawArg = args.dir; const dir = resolve(rawArg ?? "."); - const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; - const projectName = isImplicitCwd ? basename(process.env["PWD"] ?? dir) : basename(dir); - const indexPath = join(dir, "index.html"); if (existsSync(indexPath)) { - const lintResult = await lintProject({ dir, name: projectName, indexPath }); + const lintResult = await lintProject(dir); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 77660857dd..65f12f67d2 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -738,7 +738,7 @@ export default defineCommand({ // ── Pre-render lint ────────────────────────────────────────────────── { - const lintResult = await lintProject(project); + const lintResult = await lintProject(project.dir); if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(""); for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index f25228b562..3b284cee72 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -339,7 +339,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }, async lint(html: string, opts?: { filePath?: string }) { - const { lintHyperframeHtml } = await import("@hyperframes/core/lint"); + const { lintHyperframeHtml } = await import("@hyperframes/lint"); return await lintHyperframeHtml(html, opts); }, diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index 8558c852bf..f63ce12608 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -1,573 +1,3 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path"; -import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; -import type { HyperframeLintFinding } from "@hyperframes/core/lint"; -import { decodeUrlPathVariants, rewriteAssetPath } from "@hyperframes/core"; -import type { ProjectDir } from "./project.js"; - -/** - * An HTML source paired with the sub-composition path it came from, if any. - * Sub-composition relative paths (`../assets/foo.mp3`) need to be resolved - * against the sub-composition's directory before checking the filesystem — - * the root index.html is the only source where a bare `resolve(projectDir, src)` - * is correct. - */ -interface HtmlSource { - html: string; - /** `data-composition-src` value (e.g. "compositions/scene.html"); undefined for the root. */ - compSrcPath?: string; -} - -interface CssSource { - content: string; - /** Root-relative path to the CSS file. Undefined means inline HTML CSS. */ - rootRelativePath?: string; -} - -export interface ProjectLintResult { - results: Array<{ file: string; result: HyperframeLintResult }>; - totalErrors: number; - totalWarnings: number; - totalInfos: number; -} - -const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]); -const STYLE_BLOCK_RE = /]*>([\s\S]*?)<\/style>/gi; -const OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi; -const MASK_IMAGE_URL_RE = - /\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi; - -function readHtmlAttr(tag: string, name: string): string | null { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = tag.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")); - return match?.[1] ?? match?.[2] ?? null; -} - -function isLocalStylesheetHref(href: string): boolean { - return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href); -} - -function collectExternalStyles( - projectDir: string, - html: string, - compSrcPath?: string, -): Array<{ href: string; content: string }> { - const styles: Array<{ href: string; content: string }> = []; - const linkRe = /]*>/gi; - let match: RegExpExecArray | null; - while ((match = linkRe.exec(html)) !== null) { - const tag = match[0]; - const rel = tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? ""; - if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue; - const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? ""; - if (!isLocalStylesheetHref(href)) continue; - const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href; - const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative); - if (!stylesheet) continue; - styles.push({ href, content: readFileSync(stylesheet.resolved, "utf-8") }); - } - return styles; -} - -function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] { - const sources: CssSource[] = []; - - let styleMatch: RegExpExecArray | null; - const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags); - while ((styleMatch = stylePattern.exec(html)) !== null) { - sources.push({ content: styleMatch[1] ?? "" }); - } - - const linkRe = /]*>/gi; - let linkMatch: RegExpExecArray | null; - while ((linkMatch = linkRe.exec(html)) !== null) { - const tag = linkMatch[0]; - const rel = readHtmlAttr(tag, "rel") ?? ""; - if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue; - const href = readHtmlAttr(tag, "href") ?? ""; - if (!isLocalStylesheetHref(href)) continue; - - const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href; - const stylesheet = resolveExistingLocalAsset(projectDir, rootRelativePath); - if (!stylesheet) continue; - sources.push({ - content: readFileSync(stylesheet.resolved, "utf-8"), - rootRelativePath: stylesheet.rootRelativePath, - }); - } - - let tagMatch: RegExpExecArray | null; - const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags); - while ((tagMatch = tagPattern.exec(html)) !== null) { - const tag = tagMatch[0]; - const style = readHtmlAttr(tag, "style"); - if (!style) continue; - sources.push({ content: style }); - } - - return sources; -} - -function isRemoteOrInlineUrl(url: string): boolean { - return /^(https?:|data:|blob:|\/\/|#)/i.test(url); -} - -function cleanAssetUrl(url: string): string { - return url.trim().split(/[?#]/, 1)[0] ?? ""; -} - -function isWithinProjectRoot(projectDir: string, candidate: string): boolean { - const projectRoot = resolve(projectDir); - const relativePath = relative(projectRoot, candidate); - return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); -} - -function addCandidate(candidates: string[], candidate: string): void { - if (!candidates.includes(candidate)) candidates.push(candidate); -} - -function resolveLocalAssetCandidates(projectDir: string, url: string): string[] { - const cleanUrl = cleanAssetUrl(url); - const projectRoot = resolve(projectDir); - const candidates: string[] = []; - - for (const variant of decodeUrlPathVariants(cleanUrl)) { - const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant; - const resolved = resolve(projectRoot, projectRelative); - if (isWithinProjectRoot(projectRoot, resolved)) { - addCandidate(candidates, resolved); - continue; - } - - const normalized = posix.normalize(projectRelative.replace(/\\/g, "/")); - const clamped = normalized.replace(/^(\.\.\/)+/, ""); - if (clamped && !clamped.startsWith("..")) { - addCandidate(candidates, resolve(projectRoot, clamped)); - } - } - - return candidates; -} - -function resolveExistingLocalAsset( - projectDir: string, - url: string, -): { resolved: string; rootRelativePath: string } | null { - const projectRoot = resolve(projectDir); - const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync); - if (!resolved) return null; - return { resolved, rootRelativePath: relative(projectRoot, resolved) }; -} - -function resolveCssAssetCandidates( - projectDir: string, - url: string, - htmlCompSrcPath?: string, - cssRootRelativePath?: string, -): string[] { - if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url); - if (cssRootRelativePath) { - return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url)); - } - if (htmlCompSrcPath) { - return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url)); - } - return resolveLocalAssetCandidates(projectDir, url); -} - -/** - * Lint the root index.html and all sub-compositions in the compositions/ directory. - * Returns aggregated results across all files. - */ -export async function lintProject(project: ProjectDir): Promise { - const results: Array<{ file: string; result: HyperframeLintResult }> = []; - let totalErrors = 0; - let totalWarnings = 0; - let totalInfos = 0; - - // Lint root composition - const rootHtml = readFileSync(project.indexPath, "utf-8"); - const rootResult = await lintHyperframeHtml(rootHtml, { - filePath: project.indexPath, - externalStyles: collectExternalStyles(project.dir, rootHtml), - }); - results.push({ file: "index.html", result: rootResult }); - totalErrors += rootResult.errorCount; - totalWarnings += rootResult.warningCount; - totalInfos += rootResult.infoCount; - - // Lint sub-compositions in compositions/ directory, collecting HTML for project-level checks - const allHtmlSources: HtmlSource[] = [{ html: rootHtml }]; - const compositionsDir = resolve(project.dir, "compositions"); - if (existsSync(compositionsDir)) { - // Recurse: per-frame compositions live in nested dirs (e.g. compositions/frames/*.html). - // A non-recursive readdir silently skipped them, so sub-composition rules never ran on - // the frames that make up the video. Walk the whole tree; keep posix-style src paths. - const collectHtmlFiles = (dir: string, rel: string): string[] => { - const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const relPath = rel ? `${rel}/${entry.name}` : entry.name; - if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath)); - else if (entry.isFile() && entry.name.endsWith(".html")) out.push(relPath); - } - return out; - }; - const files = collectHtmlFiles(compositionsDir, "").sort(); - for (const file of files) { - const filePath = join(compositionsDir, file); - const html = readFileSync(filePath, "utf-8"); - const compSrcPath = `compositions/${file}`; - allHtmlSources.push({ html, compSrcPath }); - const result = await lintHyperframeHtml(html, { - filePath, - isSubComposition: true, - externalStyles: collectExternalStyles(project.dir, html, compSrcPath), - }); - results.push({ file: `compositions/${file}`, result }); - totalErrors += result.errorCount; - totalWarnings += result.warningCount; - totalInfos += result.infoCount; - } - } - - // ── Project-level checks ────────────────────────────────────────────── - - const projectFindings = [ - ...lintProjectAudioFiles(project.dir, allHtmlSources), - ...lintAudioSrcNotFound(project.dir, allHtmlSources), - ...lintMissingLocalAsset(project.dir, allHtmlSources), - ...lintTextureMaskAssetNotFound(project.dir, allHtmlSources), - ...lintMultipleRootCompositions(project.dir), - ...lintDuplicateAudioTracks(allHtmlSources), - ]; - if (projectFindings.length > 0) { - // Append project-level findings to the root index.html result - for (const finding of projectFindings) { - rootResult.findings.push(finding); - if (finding.severity === "error") { - rootResult.errorCount++; - rootResult.ok = false; - totalErrors++; - } else if (finding.severity === "warning") { - rootResult.warningCount++; - totalWarnings++; - } else { - rootResult.infoCount++; - totalInfos++; - } - } - } - - return { results, totalErrors, totalWarnings, totalInfos }; -} - -/** - * Check for audio files in the project directory that have no corresponding - *