mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-06 06:37:29 +00:00
* fix(producer): a misspelled font-family resolves to the family the document declares "Saira ExtraCondensed" in CSS next to an @import of family=Saira+Extra+Condensed was unresolved (Google Fonts answers the typo with HTTP 400), so fail-closed compiles threw FontFetchError. Once every existing path fails, a family whose spelling (case, spaces, -, _, +) uniquely matches a family the document declares in a Google Fonts @import/<link> URL or its own @font-face resolves to it. Faces are emitted under the authored name, so every authored usage matches them. Zero or ambiguous matches keep today's behavior. FontFetchError gains a structured unresolvedFamilies list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): font localization failures name the unresolved font families The failure line keeps its prefix and appends the sanitized names from FontFetchError.unresolvedFamilies, e.g. font localization failed (FontFetchError): unresolved font families: "Foo Bar" The error message, URLs and HTML are still never printed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(producer): keep conditional font declarations out of alias fallback * refactor(producer): bound declared font collection complexity * test(producer): model multi-family Google font responses * test(producer): preserve optional font policy through alias fallback --------- Co-authored-by: terence.cho_tai2 <terence.cho_tai2@heygen.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
export interface FontLocalizeIo {
|
|
readInput(): Promise<string>;
|
|
writeOutput(value: string): void;
|
|
writeError(value: string): void;
|
|
}
|
|
|
|
export interface FontVersions {
|
|
producer: string;
|
|
localizer: string;
|
|
}
|
|
|
|
function safeVersion(version: string): string {
|
|
return version.replace(/[^A-Za-z0-9.+-]/g, "") || "unknown";
|
|
}
|
|
|
|
/**
|
|
* Add post-hoc diagnostics for the producer resolver and the CLI wrapper that ran it.
|
|
* These stamps are traceability metadata, not an enforcement mechanism.
|
|
*/
|
|
export function stampFontVersions(html: string, versions: FontVersions): string {
|
|
const tags =
|
|
`<meta name="hyperframes-font-compiler-version" content="${safeVersion(versions.producer)}">` +
|
|
`<meta name="hyperframes-font-localizer-version" content="${safeVersion(versions.localizer)}">`;
|
|
const headClose = html.search(/<\/head\s*>/i);
|
|
if (headClose >= 0) return `${html.slice(0, headClose)}${tags}${html.slice(headClose)}`;
|
|
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
|
|
if (!doctype) return `${tags}${html}`;
|
|
const insertAt = doctype.index + doctype[0].length;
|
|
return `${html.slice(0, insertAt)}${tags}${html.slice(insertAt)}`;
|
|
}
|
|
|
|
function safeErrorName(error: unknown): string {
|
|
const name = error instanceof Error ? error.name : "UnknownError";
|
|
return /^[A-Za-z][A-Za-z0-9]*$/.test(name) ? name : "Error";
|
|
}
|
|
|
|
const MAX_REPORTED_FAMILIES = 8;
|
|
const MAX_FAMILY_NAME_LENGTH = 64;
|
|
|
|
function unresolvedFamilyList(error: unknown): unknown[] {
|
|
if (!(error instanceof Error) || !("unresolvedFamilies" in error)) return [];
|
|
const families = error.unresolvedFamilies;
|
|
return Array.isArray(families) ? families : [];
|
|
}
|
|
|
|
// URL-shaped names are dropped whole: stripping punctuation would still print the token.
|
|
function safeFamilyName(family: unknown): string | null {
|
|
if (typeof family !== "string" || /[:/?#&=%@\\]/.test(family)) return null;
|
|
const safe = family
|
|
.replace(/[^A-Za-z0-9 _-]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, MAX_FAMILY_NAME_LENGTH)
|
|
.trim();
|
|
return safe || null;
|
|
}
|
|
|
|
function unresolvedFamiliesSuffix(error: unknown): string {
|
|
const families = unresolvedFamilyList(error);
|
|
if (families.length === 0) return "";
|
|
const printable = families.map(safeFamilyName).filter((name): name is string => name !== null);
|
|
const distinct = [...new Set(printable)];
|
|
const shown = distinct.slice(0, MAX_REPORTED_FAMILIES);
|
|
const hidden = families.length - printable.length + (distinct.length - shown.length);
|
|
const listed = shown.map((name) => `"${name}"`).join(", ");
|
|
const more = hidden > 0 ? `${listed ? " " : ""}(+${hidden} more)` : "";
|
|
return `: unresolved font families: ${listed}${more}`;
|
|
}
|
|
|
|
/**
|
|
* Machine-only stdin/stdout boundary for deterministic font localization.
|
|
* Source HTML and resolver messages can contain signed URLs, so failures emit
|
|
* only a fixed category, a sanitized error class and sanitized family names.
|
|
*/
|
|
export async function runFontLocalize(
|
|
io: FontLocalizeIo,
|
|
localize: (html: string) => Promise<string>,
|
|
): Promise<number> {
|
|
const html = await io.readInput();
|
|
if (!html.trim()) {
|
|
io.writeError("font localization input is empty\n");
|
|
return 2;
|
|
}
|
|
|
|
try {
|
|
const localized = await localize(html);
|
|
if (!localized.trim()) {
|
|
io.writeError("font localization failed (Error): empty output\n");
|
|
return 1;
|
|
}
|
|
io.writeOutput(localized);
|
|
return 0;
|
|
} catch (error) {
|
|
io.writeError(
|
|
`font localization failed (${safeErrorName(error)})${unresolvedFamiliesSuffix(error)}\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
}
|