mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
* 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.
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import type { LintContext, HyperframeLintFinding } from "../context";
|
|
|
|
/** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */
|
|
// fallow-ignore-next-line complexity
|
|
function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null {
|
|
const openIdx = varMatch.index + varMatch[0].length - 1;
|
|
let depth = 0;
|
|
let inStr = false;
|
|
let strChar = "";
|
|
for (let i = openIdx; i < src.length; i++) {
|
|
const c = src[i]!;
|
|
if (inStr) {
|
|
if (c === "\\") {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (c === strChar) inStr = false;
|
|
} else if (c === '"' || c === "'") {
|
|
inStr = true;
|
|
strChar = c;
|
|
} else if (c === "[") {
|
|
depth++;
|
|
} else if (c === "]") {
|
|
depth--;
|
|
if (depth === 0) return src.slice(openIdx, i + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
|
// caption_exit_missing_hard_kill
|
|
({ scripts, styles, options, rootCompositionId }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
// Only the ACTUAL captions composition. A content frame that merely mentions
|
|
// "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0
|
|
// screen-swap) is NOT captions — gating here prevents the false positive that fired
|
|
// on a content frame whose only caption signal was a descriptive comment.
|
|
const isCaptionComposition =
|
|
Boolean(options.filePath && /caption/i.test(options.filePath)) ||
|
|
rootCompositionId === "captions" ||
|
|
styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content));
|
|
if (!isCaptionComposition) return findings;
|
|
for (const script of scripts) {
|
|
const content = script.content;
|
|
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
|
|
const hasHardKill =
|
|
/\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(
|
|
content,
|
|
);
|
|
const hasCaptionLoop =
|
|
/forEach|\.forEach\s*\(/.test(content) &&
|
|
/karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
|
|
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
|
|
findings.push({
|
|
code: "caption_exit_missing_hard_kill",
|
|
severity: "error",
|
|
message:
|
|
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
|
|
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
|
|
fixHint:
|
|
'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' +
|
|
"exit tl.to animation as a deterministic kill.",
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// caption_text_overflow_risk
|
|
({ styles }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const style of styles) {
|
|
const captionBlocks = style.content.matchAll(
|
|
/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
|
);
|
|
for (const [, selector, body] of captionBlocks) {
|
|
if (!body) continue;
|
|
const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
|
|
const hasMaxWidth = /max-width/i.test(body);
|
|
if (hasNowrap && !hasMaxWidth) {
|
|
findings.push({
|
|
code: "caption_text_overflow_risk",
|
|
severity: "warning",
|
|
selector: (selector ?? "").trim(),
|
|
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
|
fixHint:
|
|
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// caption_transcript_not_inline
|
|
// fallow-ignore-next-line complexity
|
|
({ scripts, styles, options }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
// Only check files that look like caption compositions
|
|
const isCaptionFile =
|
|
(options.filePath && /caption/i.test(options.filePath)) ||
|
|
styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
|
if (!isCaptionFile) return findings;
|
|
|
|
const allScript = scripts.map((s) => s.content).join("\n");
|
|
const hasInlineTranscript = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.test(
|
|
allScript,
|
|
);
|
|
const hasFetchTranscript = /fetch\s*\(\s*["'][^"']*transcript/i.test(allScript);
|
|
|
|
if (!hasInlineTranscript && hasFetchTranscript) {
|
|
findings.push({
|
|
code: "caption_transcript_not_inline",
|
|
severity: "error",
|
|
message:
|
|
"Captions composition loads transcript via fetch(). The studio caption editor " +
|
|
"requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.",
|
|
fixHint:
|
|
'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` ' +
|
|
"with JSON-quoted property keys. See the captions skill for details.",
|
|
});
|
|
}
|
|
|
|
if (hasInlineTranscript) {
|
|
// Verify the inline transcript can be parsed.
|
|
// Use a balanced-bracket scan instead of a regex to correctly handle
|
|
// nested arrays (e.g. word-level timing arrays inside each entry).
|
|
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
|
|
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
|
|
if (transcriptJson) {
|
|
try {
|
|
JSON.parse(transcriptJson);
|
|
} catch {
|
|
findings.push({
|
|
code: "caption_transcript_parse_error",
|
|
severity: "error",
|
|
message:
|
|
"Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " +
|
|
"to parse it. Common cause: unquoted property keys with apostrophes in text.",
|
|
fixHint:
|
|
'Use JSON-quoted keys: { "text": "don\'t", "start": 0, "end": 1 } instead of ' +
|
|
'{ text: "don\'t", start: 0, end: 1 }.',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
},
|
|
|
|
// caption_container_relative_position
|
|
({ styles }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const style of styles) {
|
|
const captionBlocks = style.content.matchAll(
|
|
/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
|
);
|
|
for (const [, selector, body] of captionBlocks) {
|
|
if (!body) continue;
|
|
if (/position\s*:\s*relative/i.test(body)) {
|
|
findings.push({
|
|
code: "caption_container_relative_position",
|
|
severity: "error",
|
|
selector: (selector ?? "").trim(),
|
|
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
|
|
fixHint: "Use position: absolute for all caption elements.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// caption_overflow_clips_scaled_words
|
|
({ styles, scripts }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
const hasScaledWords = scripts.some(
|
|
(s) => /scale\s*:\s*1\.[2-9]/.test(s.content) && /caption|word|cg-/.test(s.content),
|
|
);
|
|
if (!hasScaledWords) return findings;
|
|
|
|
for (const style of styles) {
|
|
const captionBlocks = style.content.matchAll(
|
|
/(\.caption[-_]?(?:group|container)|#caption[-_]?(?:layer|container))\s*\{([^}]+)\}/gi,
|
|
);
|
|
for (const [, selector, body] of captionBlocks) {
|
|
if (!body) continue;
|
|
if (/overflow\s*:\s*hidden/i.test(body)) {
|
|
findings.push({
|
|
code: "caption_overflow_clips_scaled_words",
|
|
severity: "error",
|
|
selector: (selector ?? "").trim(),
|
|
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
|
|
fixHint:
|
|
"Use overflow: visible on caption containers. Rely on fitTextFontSize with reduced maxWidth to prevent overflow instead.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// caption_textshadow_on_group_container
|
|
({ scripts, styles }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
|
if (!isCaptionFile) return findings;
|
|
|
|
for (const script of scripts) {
|
|
// Detect textShadow tweened on a group container (div with child word spans)
|
|
const groupShadowPattern =
|
|
/\.to\s*\(\s*(?:div|groupEl|el|captionEl|document\.getElementById\s*\(\s*["']cg-)\s*[^,]*,\s*\{[^}]*textShadow/g;
|
|
// Also catch selector-based targeting of group containers
|
|
const selectorShadowPattern =
|
|
/\.to\s*\(\s*["'](?:#cg-\d+|\.caption[-_]?group)["']\s*,\s*\{[^}]*textShadow/g;
|
|
if (groupShadowPattern.test(script.content) || selectorShadowPattern.test(script.content)) {
|
|
findings.push({
|
|
code: "caption_textshadow_on_group_container",
|
|
severity: "warning",
|
|
message:
|
|
"textShadow is tweened on a caption group container. When children have semi-transparent " +
|
|
"color (e.g., inactive karaoke words at rgba opacity), the glow renders as a visible " +
|
|
"rectangle behind the entire group.",
|
|
fixHint:
|
|
"Apply textShadow to individual active word elements instead of the group container. " +
|
|
"Use scale on the group for bass-reactive pulsing.",
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
|
|
// caption_fittext_scale_mismatch
|
|
// fallow-ignore-next-line complexity
|
|
({ scripts }) => {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
for (const script of scripts) {
|
|
const content = script.content;
|
|
const fitTextMatch = content.match(/fitTextFontSize\s*\([^)]*maxWidth\s*:\s*(\d+)/);
|
|
if (!fitTextMatch) continue;
|
|
const maxWidth = parseInt(fitTextMatch[1] ?? "0", 10);
|
|
if (!maxWidth) continue;
|
|
|
|
// Find max scale on caption words
|
|
const scaleMatches = [...content.matchAll(/scale\s*:\s*(1\.\d+)/g)];
|
|
const captionContext = /caption|word|cg-|karaoke/i.test(content);
|
|
if (!captionContext || scaleMatches.length === 0) continue;
|
|
|
|
let maxScale = 1;
|
|
for (const m of scaleMatches) {
|
|
const val = parseFloat(m[1] ?? "1");
|
|
if (val > maxScale) maxScale = val;
|
|
}
|
|
|
|
// Check if maxWidth * maxScale exceeds safe bounds (1920 - reasonable margins)
|
|
const effectiveWidth = maxWidth * maxScale;
|
|
if (effectiveWidth > 1760) {
|
|
findings.push({
|
|
code: "caption_fittext_scale_mismatch",
|
|
severity: "warning",
|
|
message:
|
|
`fitTextFontSize uses maxWidth: ${maxWidth}px but emphasis words scale up to ${maxScale}x. ` +
|
|
`Effective width ${Math.round(effectiveWidth)}px may overflow the composition (1920px minus margins).`,
|
|
fixHint: `Reduce maxWidth to ${Math.floor(1700 / maxScale)}px to leave headroom for scaled emphasis words.`,
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
];
|