mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
refactor: extract @hyperframes/lint from core (#1756)
* 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). * fix: restore postcss-selector-parser in core (sourceMutation.ts still uses it) * fix(ci): add parsers+lint to Dockerfile and build before preview tests * chore: update bun.lock after restoring postcss-selector-parser dep * test(cli): update lintProject test for string-dir signature from @hyperframes/lint * refactor(core): single-source the lint engine in @hyperframes/lint Delete core's byte-identical copy of the lint rule engine and re-point staticGuard at @hyperframes/lint, so the render-time render-gate and the studio preview share one rule engine instead of two copies that could silently diverge. Back-compat preserved via the @hyperframes/core/lint stub. Addresses review feedback on the dual-copy footgun.
This commit is contained in:
+24
-1
@@ -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.
|
||||
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
preview:
|
||||
- "packages/core/**"
|
||||
- "packages/parsers/**"
|
||||
- "packages/lint/**"
|
||||
- "packages/player/**"
|
||||
- "packages/studio/**"
|
||||
- "packages/cli/**"
|
||||
@@ -76,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Build workspace packages (required for vite config loading)
|
||||
run: |
|
||||
bun run --filter '@hyperframes/parsers' build
|
||||
bun run --filter '@hyperframes/{parsers,lint}' build
|
||||
bun run --cwd packages/core build
|
||||
|
||||
- name: Run Studio preview routing regression
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-1
@@ -75,6 +75,7 @@ ENV PATH="/root/.bun/bin:$PATH"
|
||||
# lockfile change and fails.
|
||||
COPY package.json bun.lock ./
|
||||
COPY packages/parsers/package.json packages/parsers/package.json
|
||||
COPY packages/lint/package.json packages/lint/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/engine/package.json packages/engine/package.json
|
||||
COPY packages/player/package.json packages/player/package.json
|
||||
@@ -90,12 +91,13 @@ RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY packages/parsers/ packages/parsers/
|
||||
COPY packages/lint/ packages/lint/
|
||||
COPY packages/core/ packages/core/
|
||||
COPY packages/engine/ packages/engine/
|
||||
COPY packages/producer/ packages/producer/
|
||||
|
||||
# Build workspace packages so "node" export conditions resolve to built dist
|
||||
RUN bun run --filter '@hyperframes/parsers' build \
|
||||
RUN bun run --filter '@hyperframes/{parsers,lint}' build \
|
||||
&& bun run --cwd packages/core build
|
||||
|
||||
# Build core runtime artifacts (needed by renderer)
|
||||
|
||||
@@ -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,6 +105,7 @@
|
||||
"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",
|
||||
@@ -167,6 +169,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 +238,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 +704,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"],
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, 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 {
|
||||
return mkdtempSync(join(tmpdir(), `hf-test-${name}-`));
|
||||
@@ -37,7 +36,7 @@ function htmlWithPreloadNone(): string {
|
||||
|
||||
let dirs: string[] = [];
|
||||
|
||||
function makeProject(indexHtml: string, subComps?: Record<string, string>): ProjectDir {
|
||||
function makeProject(indexHtml: string, subComps?: Record<string, string>): string {
|
||||
const dir = tmpProject("lint");
|
||||
dirs.push(dir);
|
||||
writeFileSync(join(dir, "index.html"), indexHtml);
|
||||
@@ -48,7 +47,7 @@ function makeProject(indexHtml: string, subComps?: Record<string, string>): Proj
|
||||
writeFileSync(join(compsDir, name), html);
|
||||
}
|
||||
}
|
||||
return { dir, name: "test-project", indexPath: join(dir, "index.html") };
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,12 +107,7 @@ describe("lintProject", () => {
|
||||
</template>`;
|
||||
writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml);
|
||||
|
||||
const project: ProjectDir = {
|
||||
dir,
|
||||
name: "test-project",
|
||||
indexPath: join(dir, "index.html"),
|
||||
};
|
||||
const { results } = await lintProject(project);
|
||||
const { results } = await lintProject(dir);
|
||||
|
||||
const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html");
|
||||
expect(frameResult).toBeDefined();
|
||||
@@ -146,7 +140,7 @@ describe("lintProject", () => {
|
||||
</body></html>`,
|
||||
});
|
||||
writeFileSync(
|
||||
join(project.dir, "compositions", "scene.css"),
|
||||
join(project, "compositions", "scene.css"),
|
||||
'[data-composition-id="scene"] .title { opacity: 0; }',
|
||||
);
|
||||
|
||||
@@ -169,7 +163,7 @@ describe("lintProject", () => {
|
||||
</body></html>`,
|
||||
});
|
||||
writeFileSync(
|
||||
join(project.dir, "compositions", decodeURIComponent(encodedFilename)),
|
||||
join(project, "compositions", decodeURIComponent(encodedFilename)),
|
||||
'[data-composition-id="scene"] .title { opacity: 0; }',
|
||||
);
|
||||
|
||||
@@ -227,7 +221,7 @@ describe("lintProject", () => {
|
||||
"captions.html": validHtml("captions"),
|
||||
});
|
||||
// Add a non-HTML file
|
||||
writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html");
|
||||
writeFileSync(join(project, "compositions", "readme.txt"), "not html");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -270,7 +264,7 @@ function validHtmlWithMaskImageUrl(url: string): string {
|
||||
describe("audio_file_without_element", () => {
|
||||
it("warns when audio file exists but no <audio> element", async () => {
|
||||
const project = makeProject(validHtml());
|
||||
writeFileSync(join(project.dir, "music.mp3"), "fake");
|
||||
writeFileSync(join(project, "music.mp3"), "fake");
|
||||
|
||||
const { totalWarnings, results } = await lintProject(project);
|
||||
|
||||
@@ -285,7 +279,7 @@ describe("audio_file_without_element", () => {
|
||||
|
||||
it("does not warn when audio file exists and <audio> element is present", async () => {
|
||||
const project = makeProject(validHtmlWithAudio());
|
||||
writeFileSync(join(project.dir, "song.mp3"), "fake");
|
||||
writeFileSync(join(project, "song.mp3"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -308,8 +302,8 @@ describe("audio_file_without_element", () => {
|
||||
|
||||
it("detects multiple audio file extensions", async () => {
|
||||
const project = makeProject(validHtml());
|
||||
writeFileSync(join(project.dir, "narration.wav"), "fake");
|
||||
writeFileSync(join(project.dir, "bgm.ogg"), "fake");
|
||||
writeFileSync(join(project, "narration.wav"), "fake");
|
||||
writeFileSync(join(project, "bgm.ogg"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -325,7 +319,7 @@ describe("audio_file_without_element", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"captions.html": validHtmlWithAudio("captions"),
|
||||
});
|
||||
writeFileSync(join(project.dir, "song.mp3"), "fake");
|
||||
writeFileSync(join(project, "song.mp3"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -354,7 +348,7 @@ describe("audio_src_not_found", () => {
|
||||
|
||||
it("does not error when <audio> src file exists", async () => {
|
||||
const project = makeProject(validHtmlWithAudio());
|
||||
writeFileSync(join(project.dir, "song.mp3"), "fake");
|
||||
writeFileSync(join(project, "song.mp3"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -404,8 +398,8 @@ describe("audio_src_not_found", () => {
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "assets", "bgm.mp3"), "fake");
|
||||
mkdirSync(join(project, "assets"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", "bgm.mp3"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -419,8 +413,8 @@ describe("audio_src_not_found", () => {
|
||||
const encodedFilename =
|
||||
"%D9%87%D9%86%D8%A7%20%D9%85%D8%B1%D9%88%D8%A7%20-%20%D9%85%D8%A8%D8%A7%D8%B1%D9%83.mp4";
|
||||
const project = makeProject(validHtmlWithAudioSrc(`assets/${encodedFilename}`));
|
||||
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "assets", decodeURIComponent(encodedFilename)), "fake");
|
||||
mkdirSync(join(project, "assets"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", decodeURIComponent(encodedFilename)), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -433,8 +427,8 @@ describe("audio_src_not_found", () => {
|
||||
it("does not error for malformed percent sequences that are literal filenames", async () => {
|
||||
const filename = "100%-discount.mp4";
|
||||
const project = makeProject(validHtmlWithAudioSrc(`assets/${filename}`));
|
||||
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "assets", filename), "fake");
|
||||
mkdirSync(join(project, "assets"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", filename), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -483,8 +477,8 @@ describe("audio_src_not_found", () => {
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["captions"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(validHtml(), { "captions.html": subComp });
|
||||
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "assets", "bgm.mp3"), "fake");
|
||||
mkdirSync(join(project, "assets"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", "bgm.mp3"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -630,8 +624,8 @@ describe("missing_local_asset", () => {
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
writeFileSync(join(project.dir, "hero.png"), "fake");
|
||||
writeFileSync(join(project.dir, "clip.mp4"), "fake");
|
||||
writeFileSync(join(project, "hero.png"), "fake");
|
||||
writeFileSync(join(project, "clip.mp4"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -647,8 +641,8 @@ describe("missing_local_asset", () => {
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(validHtml(), { "scene.html": subComp });
|
||||
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "assets", "foo.png"), "fake");
|
||||
mkdirSync(join(project, "assets"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", "foo.png"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
@@ -766,8 +760,8 @@ describe("texture_mask_asset_not_found", () => {
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
mkdirSync(join(project.dir, "masks"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "masks", "lava.png"), "fake");
|
||||
mkdirSync(join(project, "masks"), { recursive: true });
|
||||
writeFileSync(join(project, "masks", "lava.png"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
@@ -787,11 +781,11 @@ describe("texture_mask_asset_not_found", () => {
|
||||
</body></html>`,
|
||||
});
|
||||
writeFileSync(
|
||||
join(project.dir, "compositions", "scene.css"),
|
||||
join(project, "compositions", "scene.css"),
|
||||
'.hf-texture-lava { mask-image: url("masks/lava.png"); }',
|
||||
);
|
||||
mkdirSync(join(project.dir, "compositions", "masks"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "compositions", "masks", "lava.png"), "fake");
|
||||
mkdirSync(join(project, "compositions", "masks"), { recursive: true });
|
||||
writeFileSync(join(project, "compositions", "masks", "lava.png"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
@@ -812,7 +806,7 @@ describe("texture_mask_asset_not_found", () => {
|
||||
</body></html>`,
|
||||
});
|
||||
writeFileSync(
|
||||
join(project.dir, "compositions", decodeURIComponent(encodedFilename)),
|
||||
join(project, "compositions", decodeURIComponent(encodedFilename)),
|
||||
'.hf-texture-lava { mask-image: url("masks/missing.png"); }',
|
||||
);
|
||||
|
||||
@@ -839,10 +833,10 @@ describe("texture_mask_asset_not_found", () => {
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html);
|
||||
mkdirSync(join(project.dir, "assets", "texture-mask-text", "masks"), {
|
||||
mkdirSync(join(project, "assets", "texture-mask-text", "masks"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(join(project.dir, "assets", "texture-mask-text", "masks", "lava.png"), "fake");
|
||||
writeFileSync(join(project, "assets", "texture-mask-text", "masks", "lava.png"), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
@@ -855,8 +849,8 @@ describe("texture_mask_asset_not_found", () => {
|
||||
it("does not error for percent-encoded non-Latin mask filenames that exist on disk", async () => {
|
||||
const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.png";
|
||||
const project = makeProject(validHtmlWithMaskImageUrl(`assets/${encodedFilename}`));
|
||||
mkdirSync(join(project.dir, "assets"), { recursive: true });
|
||||
writeFileSync(join(project.dir, "assets", decodeURIComponent(encodedFilename)), "fake");
|
||||
mkdirSync(join(project, "assets"), { recursive: true });
|
||||
writeFileSync(join(project, "assets", decodeURIComponent(encodedFilename)), "fake");
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
@@ -884,7 +878,7 @@ describe("multiple_root_compositions", () => {
|
||||
it("fires when two HTML files have data-composition-id", async () => {
|
||||
const project = makeProject(validHtml());
|
||||
writeFileSync(
|
||||
join(project.dir, "scaffold.html"),
|
||||
join(project, "scaffold.html"),
|
||||
'<div data-composition-id="scaffold" data-width="1920" data-height="1080" data-duration="10"></div>',
|
||||
);
|
||||
const { totalErrors, results } = await lintProject(project);
|
||||
@@ -909,7 +903,7 @@ describe("multiple_root_compositions", () => {
|
||||
it("ignores root-level caption-skin.html source files", async () => {
|
||||
const project = makeProject(validHtml());
|
||||
writeFileSync(
|
||||
join(project.dir, "caption-skin.html"),
|
||||
join(project, "caption-skin.html"),
|
||||
'<div data-composition-id="captions" data-width="0" data-height="0"></div>',
|
||||
);
|
||||
const { results } = await lintProject(project);
|
||||
@@ -921,7 +915,7 @@ describe("multiple_root_compositions", () => {
|
||||
|
||||
it("ignores HTML files without data-composition-id", async () => {
|
||||
const project = makeProject(validHtml());
|
||||
writeFileSync(join(project.dir, "readme.html"), "<html><body>Not a composition</body></html>");
|
||||
writeFileSync(join(project, "readme.html"), "<html><body>Not a composition</body></html>");
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results[0]?.result.findings.find(
|
||||
(f) => f.code === "multiple_root_compositions",
|
||||
@@ -979,7 +973,7 @@ describe("duplicate_audio_track", () => {
|
||||
const project = makeProject(validHtmlWithAudio(), {
|
||||
"scene.html": validHtmlWithAudio("scene"),
|
||||
});
|
||||
writeFileSync(join(project.dir, "song.mp3"), "fake");
|
||||
writeFileSync(join(project, "song.mp3"), "fake");
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "duplicate_audio_track");
|
||||
expect(finding).toBeUndefined();
|
||||
@@ -1024,8 +1018,8 @@ describe("duplicate_audio_track", () => {
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`,
|
||||
});
|
||||
writeFileSync(join(project.dir, "song.mp3"), "fake");
|
||||
writeFileSync(join(project.dir, "music.wav"), "fake");
|
||||
writeFileSync(join(project, "song.mp3"), "fake");
|
||||
writeFileSync(join(project, "music.wav"), "fake");
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results[0]?.result.findings.find((f) => f.code === "duplicate_audio_track");
|
||||
// song.mp3@0 (from validHtmlWithAudio, no data-duration → Infinity) and music.wav@5-25 overlap
|
||||
|
||||
@@ -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 = /<style\b[^>]*>([\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 = /<link\b[^>]*>/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 = /<link\b[^>]*>/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<ProjectLintResult> {
|
||||
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
|
||||
* <audio> element in any composition HTML. This catches the common mistake of
|
||||
* placing an audio file in the project but forgetting the <audio> tag, which
|
||||
* results in a silent render.
|
||||
*/
|
||||
function lintProjectAudioFiles(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
// Scan project root for audio files (non-recursive — only top-level)
|
||||
let audioFiles: string[];
|
||||
try {
|
||||
audioFiles = readdirSync(projectDir).filter((f) =>
|
||||
AUDIO_EXTENSIONS.has(extname(f).toLowerCase()),
|
||||
);
|
||||
} catch {
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (audioFiles.length === 0) return findings;
|
||||
|
||||
// Check if any HTML source contains an <audio> element
|
||||
const hasAudioElement = htmlSources.some(({ html }) => /<audio\b/i.test(html));
|
||||
|
||||
if (!hasAudioElement) {
|
||||
findings.push({
|
||||
code: "audio_file_without_element",
|
||||
severity: "warning",
|
||||
message: `Found audio file(s) in project (${audioFiles.join(", ")}) but no <audio> element in any composition. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
'Add an <audio id="my-audio" src="' +
|
||||
audioFiles[0] +
|
||||
'" data-start="0" data-duration="__DURATION__" data-track-index="0" data-volume="1"></audio> element inside the composition root. Replace __DURATION__ with the audio length in seconds.',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for <audio> elements whose src points to a file that doesn't exist
|
||||
* in the project directory. The renderer will silently skip missing audio,
|
||||
* producing a silent video with no indication of what went wrong.
|
||||
*/
|
||||
function lintAudioSrcNotFound(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const missingSrcs: string[] = [];
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioSrcRe.exec(html)) !== null) {
|
||||
const src = match[1]!;
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue; // Skip template placeholders
|
||||
// Sub-composition srcs are written relative to the sub-composition file
|
||||
// (e.g. "../assets/foo.mp3"); the bundler rewrites them to root-relative
|
||||
// before serving. Mirror that rewrite here so the existence check sees
|
||||
// the same path the renderer will. Root-html srcs pass through unchanged.
|
||||
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
|
||||
missingSrcs.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingSrcs.length > 0) {
|
||||
const unique = [...new Set(missingSrcs)];
|
||||
findings.push({
|
||||
code: "audio_src_not_found",
|
||||
severity: "error",
|
||||
message: `<audio> element references file(s) not found in the project: ${unique.join(", ")}. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Add the file "${unique[0]}" to the project directory, or update the src attribute to point to an existing file.`
|
||||
: `Add the missing files to the project directory, or update the src attributes to point to existing files.`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Same-length whitespace preserves offsets.
|
||||
function maskRange(src: string, pattern: RegExp): string {
|
||||
return src.replace(pattern, (m) => " ".repeat(m.length));
|
||||
}
|
||||
|
||||
// Closing tags allow junk before `>` (`</script foo>` is valid HTML); use `[^>]*` to mask permissively.
|
||||
function maskNonScannableRanges(html: string): string {
|
||||
let out = maskRange(html, /<!--[\s\S]*?-->/g);
|
||||
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
|
||||
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
|
||||
return out;
|
||||
}
|
||||
|
||||
// <audio> is handled by lintAudioSrcNotFound — its "silent video" message is tailored.
|
||||
// fallow-ignore-next-line complexity
|
||||
function lintMissingLocalAsset(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
// Dedup by resolved path: same missing file from root + sub-comp → ONE finding.
|
||||
const missingByTag = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
const re = new RegExp(localAssetSrcRe.source, localAssetSrcRe.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(scannable)) !== null) {
|
||||
const tagName = (match[1] ?? "").toLowerCase();
|
||||
const rawSrc = match[2] ?? "";
|
||||
const src = cleanAssetUrl(rawSrc);
|
||||
if (!src) continue;
|
||||
if (isRemoteOrInlineUrl(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue; // template placeholder
|
||||
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
// resolveExistingLocalAsset matches the bundler's notion of "resolves" (handles root-absolute, rejects escapes).
|
||||
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
|
||||
if (resolvedAsset) continue;
|
||||
|
||||
const resolvedKey = resolve(projectDir, rootRelative);
|
||||
let bucket = missingByTag.get(tagName);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, string>();
|
||||
missingByTag.set(tagName, bucket);
|
||||
}
|
||||
if (!bucket.has(resolvedKey)) bucket.set(resolvedKey, src);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tagName, byResolved] of missingByTag) {
|
||||
const unique = [...byResolved.values()];
|
||||
findings.push({
|
||||
code: "missing_local_asset",
|
||||
severity: "error",
|
||||
message:
|
||||
`<${tagName}> element references local file(s) not found in the project: ${unique.join(", ")}. ` +
|
||||
"The renderer will silently skip these and produce a video with missing visuals.",
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Add "${unique[0]}" to the project directory, or update the src attribute to point to an existing file. ` +
|
||||
"Common cause: captured asset filenames are unreliable (heygen-logo.svg often contains Google, nvidia-logo.svg may contain Autodesk, etc.). " +
|
||||
"Open the contact sheets and verify the file actually exists at this path before referencing it."
|
||||
: "Add the missing files to the project directory, or update the src attributes to point to existing files. " +
|
||||
"Captured asset filenames are unreliable — verify against capture/contact-sheets/ and capture/extracted/asset-descriptions.md.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintTextureMaskAssetNotFound(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const missing = new Map<string, string>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
for (const cssSource of collectCssSources(projectDir, html, compSrcPath)) {
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
|
||||
while ((match = pattern.exec(cssSource.content)) !== null) {
|
||||
const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
|
||||
const url = cleanAssetUrl(rawUrl);
|
||||
if (!url || isRemoteOrInlineUrl(url)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(url)) continue;
|
||||
|
||||
const candidates = resolveCssAssetCandidates(
|
||||
projectDir,
|
||||
url,
|
||||
compSrcPath,
|
||||
cssSource.rootRelativePath,
|
||||
);
|
||||
if (candidates.some(existsSync)) continue;
|
||||
missing.set(url, candidates[0] ?? resolve(projectDir, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size === 0) return [];
|
||||
const urls = [...missing.keys()];
|
||||
return [
|
||||
{
|
||||
code: "texture_mask_asset_not_found",
|
||||
severity: "error",
|
||||
message: `CSS mask-image references file(s) not found in the project: ${urls.join(", ")}.`,
|
||||
fixHint:
|
||||
urls.length === 1
|
||||
? `Add "${urls[0]}" to the project, or update the mask-image URL to point to an existing texture mask.`
|
||||
: "Add the missing texture mask files to the project, or update the mask-image URLs to point to existing files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error if multiple root-level HTML files with data-composition-id exist.
|
||||
* Scans the project directory filesystem (not just what lintProject chose to read)
|
||||
* to catch stray scaffold files, duplicates, or backup copies.
|
||||
*/
|
||||
function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
try {
|
||||
const rootHtmlFiles = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
|
||||
const rootCompositions: string[] = [];
|
||||
for (const file of rootHtmlFiles) {
|
||||
if (file === "caption-skin.html") continue;
|
||||
const content = readFileSync(join(projectDir, file), "utf-8");
|
||||
if (/data-composition-id/i.test(content)) {
|
||||
rootCompositions.push(file);
|
||||
}
|
||||
}
|
||||
if (rootCompositions.length > 1) {
|
||||
findings.push({
|
||||
code: "multiple_root_compositions",
|
||||
severity: "error",
|
||||
message: `Multiple root-level HTML files with data-composition-id: ${rootCompositions.join(", ")}. The runtime may discover both as entry points, causing duplicate audio playback.`,
|
||||
fixHint:
|
||||
"A project should have exactly one root index.html with data-composition-id. Remove or rename extra files.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* directory read failed — skip */
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn if multiple <audio> elements on the same data-track-index overlap in time.
|
||||
* Extracts each attribute independently (order-insensitive) to handle any HTML attribute order.
|
||||
* Deduplicates by (src, start, duration) to avoid flagging the same audio reached via sub-compositions.
|
||||
*/
|
||||
function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
function extractAttr(tag: string, name: string): string | null {
|
||||
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
||||
const m = tag.match(re);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const { html } of htmlSources) {
|
||||
// Regex with g flag must be created inside the loop — a shared g-regex
|
||||
// carries lastIndex across strings, silently skipping matches.
|
||||
const audioTagRe = /<audio\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioTagRe.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const trackStr = extractAttr(tag, "data-track-index");
|
||||
const startStr = extractAttr(tag, "data-start");
|
||||
const durStr = extractAttr(tag, "data-duration");
|
||||
const src = extractAttr(tag, "src") ?? "unknown";
|
||||
if (!trackStr || !startStr) continue;
|
||||
|
||||
const trackIndex = parseInt(trackStr, 10);
|
||||
const start = parseFloat(startStr);
|
||||
// Runtime falls back to Infinity when data-duration is absent (plays full track).
|
||||
// Mirror that here so audio without explicit duration still participates in overlap checks.
|
||||
const duration = durStr ? parseFloat(durStr) : Infinity;
|
||||
// Deduplicate: same audio reached from multiple HTML sources
|
||||
const key = `${src}:${start}:${duration}:${trackIndex}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
tracks.push({ trackIndex, start, end: start + duration, src });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
for (let j = i + 1; j < tracks.length; j++) {
|
||||
const a = tracks[i]!;
|
||||
const b = tracks[j]!;
|
||||
if (a.trackIndex !== b.trackIndex) continue;
|
||||
if (a.start < b.end && b.start < a.end) {
|
||||
findings.push({
|
||||
code: "duplicate_audio_track",
|
||||
severity: "warning",
|
||||
message: `Multiple <audio> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${Number.isFinite(a.end) ? a.end.toFixed(1) : "end"}s, ${b.src} at ${b.start}-${Number.isFinite(b.end) ? b.end.toFixed(1) : "end"}s). This causes layered audio playback.`,
|
||||
fixHint: "Use non-overlapping time windows or different track indices.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
// ponytail: thin re-export — lintProject lives in @hyperframes/lint so it's usable without the CLI
|
||||
export { lintProject, shouldBlockRender } from "@hyperframes/lint";
|
||||
export type { ProjectLintResult } from "@hyperframes/lint";
|
||||
|
||||
@@ -62,6 +62,7 @@ var __dirname = __hf_dirname(__filename);`,
|
||||
noExternal: [
|
||||
"@hyperframes/core",
|
||||
"@hyperframes/parsers",
|
||||
"@hyperframes/lint",
|
||||
"@hyperframes/producer",
|
||||
"@hyperframes/engine",
|
||||
"@clack/prompts",
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"@hyperframes/lint": "workspace:*",
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"bpm-detective": "^2.0.5",
|
||||
"linkedom": "^0.18.12",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lintHyperframeHtml } from "../lint/hyperframeLinter";
|
||||
import { lintHyperframeHtml } from "@hyperframes/lint";
|
||||
|
||||
export type HyperframeStaticFailureReason =
|
||||
| "missing_composition_id"
|
||||
|
||||
@@ -128,8 +128,12 @@ describe("@hyperframes/core public API exports", () => {
|
||||
});
|
||||
|
||||
describe("lint exports", () => {
|
||||
it("exports lintHyperframeHtml", () => {
|
||||
expect(typeof core.lintHyperframeHtml).toBe("function");
|
||||
it("exposes lintHyperframeHtml via the @hyperframes/core/lint back-compat stub", async () => {
|
||||
// Lint moved to @hyperframes/lint; core's main entry no longer re-exports
|
||||
// it (that would cycle through the lint package). The subpath stub keeps
|
||||
// existing @hyperframes/core/lint imports working.
|
||||
const lint = await import("./lint/index.js");
|
||||
expect(typeof lint.lintHyperframeHtml).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -138,18 +138,15 @@ export {
|
||||
MEDIA_DURATION_CLAMP_EPSILON_SECONDS,
|
||||
} from "./compiler/timingCompiler";
|
||||
|
||||
// Lint
|
||||
export type {
|
||||
HyperframeLintSeverity,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintResult,
|
||||
HyperframeLinterOptions,
|
||||
} from "./lint/types";
|
||||
export { lintHyperframeHtml } from "./lint/hyperframeLinter";
|
||||
// Lint moved to @hyperframes/lint. Import lint APIs from @hyperframes/lint
|
||||
// directly, or via the back-compat stub at @hyperframes/core/lint. Not
|
||||
// re-exported here — doing so would cycle core's main entry through the lint
|
||||
// package (which imports core utilities back).
|
||||
export {
|
||||
rewriteAssetPaths,
|
||||
rewriteAssetPath,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./compiler/rewriteSubCompPaths";
|
||||
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
|
||||
export { queryByAttr } from "./utils/cssSelector";
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
export type {
|
||||
HyperframeLintSeverity,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintResult,
|
||||
HyperframeLinterOptions,
|
||||
} from "./types";
|
||||
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter";
|
||||
/** @deprecated Import from @hyperframes/lint */
|
||||
export * from "@hyperframes/lint";
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./slideshow.types";
|
||||
export * from "./parseSlideshow";
|
||||
export { isSceneLikeCompositionId } from "./sceneId";
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@hyperframes/lint",
|
||||
"version": "0.7.11",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/lint"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"node": "./dist/index.js",
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "echo skip"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
HyperframeLintSeverity,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintResult,
|
||||
HyperframeLinterOptions,
|
||||
} from "./types.js";
|
||||
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
|
||||
export { lintProject, shouldBlockRender } from "./project.js";
|
||||
export type { ProjectLintResult } from "./project.js";
|
||||
@@ -0,0 +1,511 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
||||
import { decodeUrlPathVariants, rewriteAssetPath } from "@hyperframes/core";
|
||||
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
||||
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
|
||||
|
||||
interface HtmlSource {
|
||||
html: string;
|
||||
compSrcPath?: string;
|
||||
}
|
||||
|
||||
interface CssSource {
|
||||
content: string;
|
||||
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 = /<style\b[^>]*>([\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 = /<link\b[^>]*>/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 = /<link\b[^>]*>/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);
|
||||
}
|
||||
|
||||
export async function lintProject(projectDir: string): Promise<ProjectLintResult> {
|
||||
const indexPath = resolve(projectDir, "index.html");
|
||||
const results: Array<{ file: string; result: HyperframeLintResult }> = [];
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
let totalInfos = 0;
|
||||
|
||||
const rootHtml = readFileSync(indexPath, "utf-8");
|
||||
const rootResult = await lintHyperframeHtml(rootHtml, {
|
||||
filePath: indexPath,
|
||||
externalStyles: collectExternalStyles(projectDir, rootHtml),
|
||||
});
|
||||
results.push({ file: "index.html", result: rootResult });
|
||||
totalErrors += rootResult.errorCount;
|
||||
totalWarnings += rootResult.warningCount;
|
||||
totalInfos += rootResult.infoCount;
|
||||
|
||||
const allHtmlSources: HtmlSource[] = [{ html: rootHtml }];
|
||||
const compositionsDir = resolve(projectDir, "compositions");
|
||||
if (existsSync(compositionsDir)) {
|
||||
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(projectDir, html, compSrcPath),
|
||||
});
|
||||
results.push({ file: `compositions/${file}`, result });
|
||||
totalErrors += result.errorCount;
|
||||
totalWarnings += result.warningCount;
|
||||
totalInfos += result.infoCount;
|
||||
}
|
||||
}
|
||||
|
||||
const projectFindings = [
|
||||
...lintProjectAudioFiles(projectDir, allHtmlSources),
|
||||
...lintAudioSrcNotFound(projectDir, allHtmlSources),
|
||||
...lintMissingLocalAsset(projectDir, allHtmlSources),
|
||||
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
||||
...lintMultipleRootCompositions(projectDir),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
];
|
||||
if (projectFindings.length > 0) {
|
||||
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 };
|
||||
}
|
||||
|
||||
export function shouldBlockRender(
|
||||
strictErrors: boolean,
|
||||
strictAll: boolean,
|
||||
totalErrors: number,
|
||||
totalWarnings: number,
|
||||
): boolean {
|
||||
return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0));
|
||||
}
|
||||
|
||||
function lintProjectAudioFiles(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
let audioFiles: string[];
|
||||
try {
|
||||
audioFiles = readdirSync(projectDir).filter((f) =>
|
||||
AUDIO_EXTENSIONS.has(extname(f).toLowerCase()),
|
||||
);
|
||||
} catch {
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (audioFiles.length === 0) return findings;
|
||||
|
||||
const hasAudioElement = htmlSources.some(({ html }) => /<audio\b/i.test(html));
|
||||
|
||||
if (!hasAudioElement) {
|
||||
findings.push({
|
||||
code: "audio_file_without_element",
|
||||
severity: "warning",
|
||||
message: `Found audio file(s) in project (${audioFiles.join(", ")}) but no <audio> element in any composition. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
'Add an <audio id="my-audio" src="' +
|
||||
audioFiles[0] +
|
||||
'" data-start="0" data-duration="__DURATION__" data-track-index="0" data-volume="1"></audio> element inside the composition root. Replace __DURATION__ with the audio length in seconds.',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintAudioSrcNotFound(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const missingSrcs: string[] = [];
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioSrcRe.exec(html)) !== null) {
|
||||
const src = match[1]!;
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue;
|
||||
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
|
||||
missingSrcs.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingSrcs.length > 0) {
|
||||
const unique = [...new Set(missingSrcs)];
|
||||
findings.push({
|
||||
code: "audio_src_not_found",
|
||||
severity: "error",
|
||||
message: `<audio> element references file(s) not found in the project: ${unique.join(", ")}. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Add the file "${unique[0]}" to the project directory, or update the src attribute to point to an existing file.`
|
||||
: `Add the missing files to the project directory, or update the src attributes to point to existing files.`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function maskRange(src: string, pattern: RegExp): string {
|
||||
return src.replace(pattern, (m) => " ".repeat(m.length));
|
||||
}
|
||||
|
||||
function maskNonScannableRanges(html: string): string {
|
||||
let out = maskRange(html, /<!--[\s\S]*?-->/g);
|
||||
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
|
||||
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
|
||||
return out;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function lintMissingLocalAsset(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const missingByTag = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
const re = new RegExp(localAssetSrcRe.source, localAssetSrcRe.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(scannable)) !== null) {
|
||||
const tagName = (match[1] ?? "").toLowerCase();
|
||||
const rawSrc = match[2] ?? "";
|
||||
const src = cleanAssetUrl(rawSrc);
|
||||
if (!src) continue;
|
||||
if (isRemoteOrInlineUrl(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue;
|
||||
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
|
||||
if (resolvedAsset) continue;
|
||||
|
||||
const resolvedKey = resolve(projectDir, rootRelative);
|
||||
let bucket = missingByTag.get(tagName);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, string>();
|
||||
missingByTag.set(tagName, bucket);
|
||||
}
|
||||
if (!bucket.has(resolvedKey)) bucket.set(resolvedKey, src);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tagName, byResolved] of missingByTag) {
|
||||
const unique = [...byResolved.values()];
|
||||
findings.push({
|
||||
code: "missing_local_asset",
|
||||
severity: "error",
|
||||
message:
|
||||
`<${tagName}> element references local file(s) not found in the project: ${unique.join(", ")}. ` +
|
||||
"The renderer will silently skip these and produce a video with missing visuals.",
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Add "${unique[0]}" to the project directory, or update the src attribute to point to an existing file. ` +
|
||||
"Common cause: captured asset filenames are unreliable (heygen-logo.svg often contains Google, nvidia-logo.svg may contain Autodesk, etc.). " +
|
||||
"Open the contact sheets and verify the file actually exists at this path before referencing it."
|
||||
: "Add the missing files to the project directory, or update the src attributes to point to existing files. " +
|
||||
"Captured asset filenames are unreliable — verify against capture/contact-sheets/ and capture/extracted/asset-descriptions.md.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintTextureMaskAssetNotFound(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const missing = new Map<string, string>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
for (const cssSource of collectCssSources(projectDir, html, compSrcPath)) {
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
|
||||
while ((match = pattern.exec(cssSource.content)) !== null) {
|
||||
const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
|
||||
const url = cleanAssetUrl(rawUrl);
|
||||
if (!url || isRemoteOrInlineUrl(url)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(url)) continue;
|
||||
|
||||
const candidates = resolveCssAssetCandidates(
|
||||
projectDir,
|
||||
url,
|
||||
compSrcPath,
|
||||
cssSource.rootRelativePath,
|
||||
);
|
||||
if (candidates.some(existsSync)) continue;
|
||||
missing.set(url, candidates[0] ?? resolve(projectDir, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size === 0) return [];
|
||||
const urls = [...missing.keys()];
|
||||
return [
|
||||
{
|
||||
code: "texture_mask_asset_not_found",
|
||||
severity: "error",
|
||||
message: `CSS mask-image references file(s) not found in the project: ${urls.join(", ")}.`,
|
||||
fixHint:
|
||||
urls.length === 1
|
||||
? `Add "${urls[0]}" to the project, or update the mask-image URL to point to an existing texture mask.`
|
||||
: "Add the missing texture mask files to the project, or update the mask-image URLs to point to existing files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
try {
|
||||
const rootHtmlFiles = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
|
||||
const rootCompositions: string[] = [];
|
||||
for (const file of rootHtmlFiles) {
|
||||
if (file === "caption-skin.html") continue;
|
||||
const content = readFileSync(join(projectDir, file), "utf-8");
|
||||
if (/data-composition-id/i.test(content)) {
|
||||
rootCompositions.push(file);
|
||||
}
|
||||
}
|
||||
if (rootCompositions.length > 1) {
|
||||
findings.push({
|
||||
code: "multiple_root_compositions",
|
||||
severity: "error",
|
||||
message: `Multiple root-level HTML files with data-composition-id: ${rootCompositions.join(", ")}. The runtime may discover both as entry points, causing duplicate audio playback.`,
|
||||
fixHint:
|
||||
"A project should have exactly one root index.html with data-composition-id. Remove or rename extra files.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* directory read failed — skip */
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
function extractAttr(tag: string, name: string): string | null {
|
||||
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
||||
const m = tag.match(re);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const { html } of htmlSources) {
|
||||
const audioTagRe = /<audio\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioTagRe.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const trackStr = extractAttr(tag, "data-track-index");
|
||||
const startStr = extractAttr(tag, "data-start");
|
||||
const durStr = extractAttr(tag, "data-duration");
|
||||
const src = extractAttr(tag, "src") ?? "unknown";
|
||||
if (!trackStr || !startStr) continue;
|
||||
|
||||
const trackIndex = parseInt(trackStr, 10);
|
||||
const start = parseFloat(startStr);
|
||||
const duration = durStr ? parseFloat(durStr) : Infinity;
|
||||
const key = `${src}:${start}:${duration}:${trackIndex}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
tracks.push({ trackIndex, start, end: start + duration, src });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
for (let j = i + 1; j < tracks.length; j++) {
|
||||
const a = tracks[i]!;
|
||||
const b = tracks[j]!;
|
||||
if (a.trackIndex !== b.trackIndex) continue;
|
||||
if (a.start < b.end && b.start < a.end) {
|
||||
findings.push({
|
||||
code: "duplicate_audio_track",
|
||||
severity: "warning",
|
||||
message: `Multiple <audio> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${Number.isFinite(a.end) ? a.end.toFixed(1) : "end"}s, ${b.src} at ${b.start}-${Number.isFinite(b.end) ? b.end.toFixed(1) : "end"}s). This causes layered audio playback.`,
|
||||
fixHint: "Use non-overlapping time windows or different track indices.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
|
||||
import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils";
|
||||
import { COMPOSITION_VARIABLE_TYPES } from "../../core.types";
|
||||
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers";
|
||||
|
||||
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
|
||||
// to inspect and revise reliably in a single composition.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "../../fonts/aliases";
|
||||
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases";
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import type { LintRule } from "../types";
|
||||
import { readAttr } from "../utils";
|
||||
import { parseSlideshowManifest, resolveSlideshow } from "../../slideshow/parseSlideshow";
|
||||
import { isSceneLikeCompositionId } from "../../slideshow/sceneId";
|
||||
import {
|
||||
parseSlideshowManifest,
|
||||
resolveSlideshow,
|
||||
isSceneLikeCompositionId,
|
||||
} from "@hyperframes/core/slideshow";
|
||||
|
||||
type Scene = { id: string; start: number; duration: number };
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
format: ["esm"],
|
||||
outDir: "dist",
|
||||
target: "node22",
|
||||
platform: "node",
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
dts: true,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
@@ -70,6 +70,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint";
|
||||
|
||||
export interface PreparedHyperframeLintInput {
|
||||
entryFile: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CLI_SEMVER_PATTERN } from "./cli-options.ts";
|
||||
|
||||
const PACKAGES = [
|
||||
"packages/parsers",
|
||||
"packages/lint",
|
||||
"packages/core",
|
||||
"packages/engine",
|
||||
"packages/player",
|
||||
|
||||
Reference in New Issue
Block a user