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:
Miguel Ángel
2026-06-27 01:16:40 -04:00
committed by GitHub
parent cdf9c817e1
commit 98d0bdd73c
53 changed files with 745 additions and 656 deletions
+24 -1
View File
@@ -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.
+2 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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)
+21
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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",
+1 -1
View File
@@ -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);
+1 -2
View File
@@ -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);
+2 -5
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
},
+40 -46
View File
@@ -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
+3 -573
View File
@@ -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";
+1
View File
@@ -62,6 +62,7 @@ var __dirname = __hf_dirname(__filename);`,
noExternal: [
"@hyperframes/core",
"@hyperframes/parsers",
"@hyperframes/lint",
"@hyperframes/producer",
"@hyperframes/engine",
"@clack/prompts",
+1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { lintHyperframeHtml } from "../lint/hyperframeLinter";
import { lintHyperframeHtml } from "@hyperframes/lint";
export type HyperframeStaticFailureReason =
| "missing_composition_id"
+6 -2
View File
@@ -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");
});
});
+5 -8
View File
@@ -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";
+2 -7
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from "./slideshow.types";
export * from "./parseSlideshow";
export { isSceneLikeCompositionId } from "./sceneId";
+56
View File
@@ -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"
}
}
+9
View File
@@ -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";
+511
View File
@@ -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,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 };
+18
View File
@@ -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"]
}
+14
View File
@@ -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,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "jsdom",
},
});
+1
View File
@@ -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;
+1
View File
@@ -22,6 +22,7 @@ import { CLI_SEMVER_PATTERN } from "./cli-options.ts";
const PACKAGES = [
"packages/parsers",
"packages/lint",
"packages/core",
"packages/engine",
"packages/player",