mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
refactor: extract @hyperframes/lint from core (#1756)
* refactor: extract @hyperframes/lint package from core Moves all lint rules, hyperframeLinter, lintProject, and related types from packages/core/src/lint/ into a new standalone packages/lint package. Core keeps a thin re-export stub at @hyperframes/core/lint for backward compatibility. Consumer imports (cli lint command, producer hyperframeLint) are updated to import from @hyperframes/lint directly. Depends on @hyperframes/parsers (PR #1755). * fix: restore postcss-selector-parser in core (sourceMutation.ts still uses it) * fix(ci): add parsers+lint to Dockerfile and build before preview tests * chore: update bun.lock after restoring postcss-selector-parser dep * test(cli): update lintProject test for string-dir signature from @hyperframes/lint * refactor(core): single-source the lint engine in @hyperframes/lint Delete core's byte-identical copy of the lint rule engine and re-point staticGuard at @hyperframes/lint, so the render-time render-gate and the studio preview share one rule engine instead of two copies that could silently diverge. Back-compat preserved via the @hyperframes/core/lint stub. Addresses review feedback on the dual-copy footgun.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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;
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user