Files
hyperframes/packages/core/src/lint/hyperframeLinter.ts
T
Miguel Ángel fb2e21090f feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
2026-05-28 19:16:34 -04:00

233 lines
7.1 KiB
TypeScript

import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types";
import { buildLintContext } from "./context";
import { readAttr, truncateSnippet } from "./utils";
import { coreRules } from "./rules/core";
import { mediaRules } from "./rules/media";
import { gsapRules } from "./rules/gsap";
import { captionRules } from "./rules/captions";
import { compositionRules } from "./rules/composition";
import { adapterRules } from "./rules/adapters";
import { textureRules } from "./rules/textures";
import { fontRules } from "./rules/fonts";
const ALL_RULES = [
...coreRules,
...mediaRules,
...gsapRules,
...captionRules,
...compositionRules,
...adapterRules,
...textureRules,
...fontRules,
];
export async function lintHyperframeHtml(
html: string,
options: HyperframeLinterOptions = {},
): Promise<HyperframeLintResult> {
const ctx = buildLintContext(html, options);
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
for (const rule of ALL_RULES) {
for (const finding of await Promise.resolve(rule(ctx))) {
const dedupeKey = [
finding.code,
finding.severity,
finding.selector || "",
finding.elementId || "",
finding.message,
].join("|");
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
}
}
const errorCount = findings.filter((f) => f.severity === "error").length;
const warningCount = findings.filter((f) => f.severity === "warning").length;
const infoCount = findings.filter((f) => f.severity === "info").length;
return {
ok: errorCount === 0,
errorCount,
warningCount,
infoCount,
findings,
};
}
// ── Async media URL accessibility checker ─────────────────────────────────
function extractMediaUrls(html: string): Array<{
url: string;
tagName: string;
elementId?: string;
snippet: string;
}> {
const results: Array<{
url: string;
tagName: string;
elementId?: string;
snippet: string;
}> = [];
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = tagRe.exec(html)) !== null) {
const tagName = (match[1] ?? "").toLowerCase();
const raw = match[0];
const src = readAttr(raw, "src");
if (!src) continue;
if (/^https?:\/\//i.test(src)) {
results.push({
url: src,
tagName,
elementId: readAttr(raw, "id") || undefined,
snippet: truncateSnippet(raw) ?? "",
});
}
}
return results;
}
/**
* Async lint pass: HEAD-checks every remote media URL in the HTML.
* Returns findings for URLs that are unreachable (non-2xx status or network error).
*
* Call this after `lintHyperframeHtml()` and merge the findings.
*
* @param timeoutMs - per-request timeout (default 8000ms)
*/
export async function lintMediaUrls(
html: string,
options: { timeoutMs?: number } = {},
): Promise<HyperframeLintFinding[]> {
const urls = extractMediaUrls(html);
if (urls.length === 0) return [];
const timeout = options.timeoutMs ?? 8000;
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
const unique = urls.filter((u) => {
if (seen.has(u.url)) return false;
seen.add(u.url);
return true;
});
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const resp = await fetch(url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timer);
if (!resp.ok) {
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
} catch (err) {
const reason = err instanceof Error ? err.name : "unknown";
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
});
await Promise.all(checks);
return findings;
}
function extractScriptUrls(html: string): Array<{ url: string; snippet: string }> {
const results: Array<{ url: string; snippet: string }> = [];
const scriptRe = /<script\b[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = scriptRe.exec(html)) !== null) {
const raw = match[0];
const src = readAttr(raw, "src");
if (!src) continue;
if (/^https?:\/\//i.test(src)) {
results.push({
url: src,
snippet: truncateSnippet(raw) ?? "",
});
}
}
return results;
}
/**
* Async lint pass: HEAD-checks every external script URL in the HTML.
* Returns findings for URLs that are unreachable (non-2xx status or network error).
*
* Call this after `lintHyperframeHtml()` and merge the findings.
*
* @param timeoutMs - per-request timeout (default 8000ms)
*/
export async function lintScriptUrls(
html: string,
options: { timeoutMs?: number } = {},
): Promise<HyperframeLintFinding[]> {
const urls = extractScriptUrls(html);
if (urls.length === 0) return [];
const timeout = options.timeoutMs ?? 8000;
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
const unique = urls.filter((u) => {
if (seen.has(u.url)) return false;
seen.add(u.url);
return true;
});
const checks = unique.map(async ({ url, snippet }) => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const resp = await fetch(url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timer);
if (!resp.ok) {
findings.push({
code: "inaccessible_script_url",
severity: "error",
message: `<script> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 120)}`,
fixHint:
"This script URL is not accessible. Remove it or replace with a valid URL. The HyperFrames runtime is injected automatically — do not load it manually.",
snippet,
});
}
} catch (err) {
const reason = err instanceof Error ? err.name : "unknown";
findings.push({
code: "inaccessible_script_url",
severity: "error",
message: `<script> references an unreachable URL (${reason}): ${url.slice(0, 120)}`,
fixHint: "This script URL is not accessible. Remove it or replace with a valid URL.",
snippet,
});
}
});
await Promise.all(checks);
return findings;
}