Files
hyperframes/packages/lint/src/rules/fonts.ts
T
Miguel Ángel 6aaab32ccb refactor: make @hyperframes/lint depend only on parsers (#1773)
* refactor: make @hyperframes/lint depend only on parsers, not core

Relocates the leaf utilities lint pulled from core — URL/asset-path helpers,
font aliases, and the slideshow manifest parser — into the standalone
@hyperframes/parsers base, and drops @hyperframes/core from lint's
dependencies. Core keeps back-compat re-export stubs at the old paths, so
producer/studio/cli are unchanged.

Why: lint was the lightweight validator from #1749, but depending on core
transitively pulled studio-server (hono) and bpm-detective — irrelevant to
linting. Now installing @hyperframes/lint pulls only parsers + postcss, and
the core<->lint dependency cycle is gone.

- parsers main entry stays browser-safe (pure utils only); the node:path
  asset helpers live behind the new @hyperframes/parsers/asset-paths subpath
- slideshow parser exposed via @hyperframes/parsers/slideshow

* feat(lint): add browser entry; harden CSS url() regex (ReDoS)

@hyperframes/lint/browser — a fully client-side rule engine (lintHyperframeHtml,
lintMediaUrls, shouldBlockRender) with zero node: builtins, so browser-only
editors can validate compositions with no Node.js and no server round-trip.
Closes the browser-validation ask on #1749.

- shouldBlockRender extracted from the fs-bound project.ts into its own pure
  module so the browser entry stays node-free
- pure composition primitives (data types, font aliases, URL helper) exposed via
  a new recast-free @hyperframes/parsers/composition subpath, so the browser
  bundle tree-shakes out the GSAP/recast machinery (verified: esbuild
  platform=browser bundles with 0 node builtins)
- lint built with a platform:browser tsup pass — compile-time guarantee the
  browser entry never pulls a node builtin
- harden CSS_URL_RE against polynomial ReDoS (CodeQL js/polynomial-redos);
  behavior-preserving, verified against existing tests + an old/new parity check
- parsers/lint marked sideEffects:false
2026-06-27 13:51:21 -04:00

223 lines
8.1 KiB
TypeScript

import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
import type { LintContext, HyperframeLintFinding } from "../context";
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
const GENERIC_FAMILIES = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"math",
"emoji",
"fangsong",
"inherit",
"initial",
"unset",
"revert",
]);
// A CSS comment can contain a `}` (e.g. `@font-face { /* 400 } regular */
// font-family: 'X'; ... }`), which truncates the naive `@font-face\s*\{[^}]*\}`
// block match at the comment's brace — so the rule never sees the real
// `font-family` and reports a false-positive font_family_without_font_face.
// Large/"framework" stylesheets hit this far more often than minimal ones,
// which is why a simple <style> passes while a complex one fails. Strip
// comments before scanning so a brace inside one cannot split a block. See #1534.
function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
}
function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string> {
const families = new Set<string>();
const fontFaceRe = /@font-face\s*\{[^}]*\}/gi;
const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
for (const style of styles) {
const content = stripCssComments(style.content);
let match: RegExpExecArray | null;
while ((match = fontFaceRe.exec(content)) !== null) {
const familyMatch = match[0].match(familyRe);
if (familyMatch?.[2]) {
families.add(familyMatch[2].trim().toLowerCase());
}
}
}
return families;
}
function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
const used: string[] = [];
const seen = new Set<string>();
const propRe = /font-family\s*:\s*([^;}{]+)/gi;
for (const style of styles) {
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
let match: RegExpExecArray | null;
while ((match = propRe.exec(withoutFontFace)) !== null) {
const stack = match[1]!;
for (const part of stack.split(",")) {
const name = part
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
seen.add(name);
used.push(name);
}
}
}
}
return used;
}
function collectAliasedFonts(used: string[], declared: Set<string>): string[] {
const aliased: string[] = [];
for (const name of used) {
if (declared.has(name)) continue;
const displayName = resolveAliasDisplayName(name);
if (!displayName) continue;
if (displayName.toLowerCase() === name) continue;
aliased.push(`'${name}' → ${displayName}`);
}
return aliased;
}
function normalizeFontFamily(name: string): string | null {
const decoded = name.replace(/\+/g, " ").trim();
if (!decoded) return null;
try {
return decodeURIComponent(decoded).trim().toLowerCase() || null;
} catch {
return decoded.toLowerCase();
}
}
function extractGoogleFontFamiliesFromUrl(rawUrl: string): string[] {
const url = rawUrl.replace(/&amp;/gi, "&");
let parsed: URL;
try {
parsed = new URL(url, "https://fonts.googleapis.com");
} catch {
return [];
}
if (parsed.hostname.toLowerCase() !== "fonts.googleapis.com") return [];
const families: string[] = [];
for (const value of parsed.searchParams.getAll("family")) {
for (const familySpec of value.split("|")) {
const family = normalizeFontFamily(familySpec.split(":")[0] || "");
if (family) families.push(family);
}
}
return families;
}
function collectGoogleFontFamilies(
source: string,
styles: Array<{ content: string }>,
): Set<string> {
const families = new Set<string>();
const addUrl = (url: string) => {
for (const family of extractGoogleFontFamiliesFromUrl(url)) families.add(family);
};
const linkHrefRe =
/<link\b[^>]*\bhref\s*=\s*(?:(["'])([^"']*fonts\.googleapis\.com[^"']*)\1|([^\s>]*fonts\.googleapis\.com[^\s>]*))[^>]*>/gi;
for (const match of source.matchAll(linkHrefRe)) {
const href = match[2] || match[3];
if (href) addUrl(href);
}
const importUrlRe =
/@import\s+(?:url\(\s*)?(["']?)([^"')\s]*fonts\.googleapis\.com[^"')\s]*)\1\s*\)?/gi;
for (const style of styles) {
for (const match of style.content.matchAll(importUrlRe)) {
if (match[2]) addUrl(match[2]);
}
}
return families;
}
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// google_fonts_import
({ styles, source, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
const googleFontsInImport = styles.some((s) =>
/@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content),
);
if (googleFontsInLink || googleFontsInImport) {
findings.push({
code: "google_fonts_import",
severity: "warning",
message:
"Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts " +
"during compile/render, but raw external font requests add latency and can fail before " +
"canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
fixHint:
"For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family " +
"declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }.",
});
}
return findings;
},
// system_font_will_alias — inform when a font will be silently substituted
({ styles, options }) => {
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
const aliased = collectAliasedFonts(used, declared);
if (aliased.length === 0) return [];
// In distributed / Lambda renders system-font capture is disabled, so
// the alias substitution does NOT happen — elevate to a warning.
const severity = options.distributed ? ("warning" as const) : ("info" as const);
return [
{
code: "system_font_will_alias",
severity,
message:
`Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. ` +
(options.distributed
? "In distributed/Lambda rendering system-font capture is disabled — these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead."
: "The renderer maps these to bundled fonts for cross-platform consistency. " +
"Use the target font name directly for consistent preview and render results."),
},
];
},
// font_family_without_font_face
({ styles, source, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
const googleFonts = collectGoogleFontFamilies(source, styles);
const undeclared = used.filter(
(name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name),
);
if (undeclared.length === 0) return findings;
findings.push({
code: "font_family_without_font_face",
severity: "error",
message:
`Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` +
"These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " +
"Text will fall back to a generic font, producing incorrect typography in the video.",
fixHint:
"Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); } " +
"for each font family, pointing to the captured .woff2 files.",
});
return findings;
},
];