Files
hyperframes/packages/lint/src/rules/captions.ts
T
Miguel Ángel 98d0bdd73c 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.
2026-06-27 01:16:40 -04:00

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;
},
];