fix(studio): font faces a host saves keep their own weight and style (#4753)

* fix(studio): a family's saved font faces keep their own weight and style

* fix(studio): keep the font face change within the comment ratchet

* fix(studio): a saved font face cannot break the page, and only weights a browser keeps are written
This commit is contained in:
Miguel Ángel
2026-09-30 08:57:58 +00:00
committed by GitHub
parent 84811c4329
commit be5beaee61
4 changed files with 141 additions and 5 deletions
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { expect, it } from "vitest";
import { ensureImportedFontFace, injectPreviewImportedFont } from "../../utils/studioFontHelpers";
import { importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
const face = (file: string, extra: Partial<ImportedFontAsset> = {}): ImportedFontAsset => ({
family: "Poppins",
path: `assets/fonts/Poppins/${file}`,
url: `/preview/assets/fonts/Poppins/${file}`,
...extra,
});
it("a face with no weight or style is written as before", () => {
expect(importedFontFaceCss(face("Poppins-Bold.ttf"))).toBe(
'@font-face { font-family: "Poppins"; src: url("/preview/assets/fonts/Poppins/Poppins-Bold.ttf"); font-display: swap; }',
);
});
it("a face says which weight and style it draws, a variable file its weight range", () => {
expect(
importedFontFaceCss(face("Poppins-BoldItalic.ttf", { weight: "700", style: "italic" })),
).toBe(
'@font-face { font-family: "Poppins"; src: url("/preview/assets/fonts/Poppins/Poppins-BoldItalic.ttf"); font-weight: 700; font-style: italic; font-display: swap; }',
);
expect(
importedFontFaceCss(face("Roboto-Variable.ttf", { weight: "100 900", style: "normal" })),
).toContain("font-weight: 100 900; font-style: normal;");
expect(importedFontFaceCss(face("x.ttf", { weight: "700; color: red" }))).not.toContain(
"font-weight",
);
});
it("two weights of one family are both kept in the file and both drawn in the preview", () => {
const bold = face("Poppins-Bold.ttf", { weight: "700", style: "normal" });
const regular = face("Poppins-Regular.ttf", { weight: "400", style: "normal" });
const html = "<html><head></head><body></body></html>";
const saved = ensureImportedFontFace(
ensureImportedFontFace(html, bold, "index.html"),
regular,
"index.html",
);
expect(saved).toContain("font-weight: 700;");
expect(saved).toContain("font-weight: 400;");
expect(ensureImportedFontFace(saved, bold, "index.html")).toBe(saved);
injectPreviewImportedFont(document, bold);
injectPreviewImportedFont(document, regular);
injectPreviewImportedFont(document, bold);
expect(document.head.querySelectorAll("style")).toHaveLength(2);
});
it("a weight the browser would drop is left out, and a family cannot close the style block", () => {
for (const weight of ["0", "5000", "bold", "700;}"])
expect(importedFontFaceCss(face("x.ttf", { weight }))).not.toContain("font-weight");
expect(importedFontFaceCss(face("x.ttf", { weight: "1000" }))).toContain("font-weight: 1000;");
expect(importedFontFaceCss({ ...face("x.ttf"), family: "</style><b>x" })).not.toContain(
"</style>",
);
});
it("a file name with a replacement pattern is saved as written, not expanded into the page", () => {
const html = "<html><head></head><body>rest</body></html>";
const odd = { family: "Cash", path: "assets/Cash$'Font.ttf", url: "/Cash$'Font.ttf" };
const saved = ensureImportedFontFace(html, odd, "index.html");
expect(saved).toContain("Cash$'Font.ttf");
expect(saved.match(/<body>/g)).toHaveLength(1);
const again = ensureImportedFontFace(saved, { ...odd, path: "assets/Cash$&.ttf" }, "index.html");
expect(again).toContain("Cash$&.ttf");
expect(again.match(/<body>/g)).toHaveLength(1);
});
@@ -2,6 +2,8 @@ export interface ImportedFontAsset {
family: string;
path: string;
url: string;
weight?: string;
style?: "normal" | "italic";
}
const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i;
@@ -9,7 +11,7 @@ const FONT_STYLE_SUFFIX_RE =
/\s+(thin|extralight|extra light|light|regular|roman|medium|semibold|semi bold|bold|extrabold|extra bold|black|italic|oblique|variable)$/i;
function cssString(value: string): string {
return JSON.stringify(value);
return JSON.stringify(value).replace(/</g, "\\3c ");
}
export function fontFamilyFromAssetPath(path: string): string {
@@ -28,5 +30,12 @@ export function fontFamilyFromAssetPath(path: string): string {
}
export function importedFontFaceCss(asset: ImportedFontAsset, url: string = asset.url): string {
return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)}); font-display: swap; }`;
const weight =
asset.weight && /^(?:[1-9]\d{0,2}|1000)(?: (?:[1-9]\d{0,2}|1000))?$/.test(asset.weight)
? ` font-weight: ${asset.weight};`
: "";
const style = asset.style
? ` font-style: ${asset.style === "italic" ? "italic" : "normal"};`
: "";
return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)});${weight}${style} font-display: swap; }`;
}
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { expect, it } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
const selection = (id: string): DomEditSelection =>
({
id,
selector: `#${id}`,
label: id,
tagName: "p",
sourceFile: "index.html",
compositionPath: "index.html",
textFields: [],
inlineStyles: {},
computedStyles: {},
dataAttributes: {},
capabilities: { canSelect: true, canEditStyles: true },
}) as unknown as DomEditSelection;
const poppins = (file: string, weight: string): ImportedFontAsset => ({
family: "Poppins",
path: `assets/fonts/Poppins/${file}`,
url: `/preview/assets/fonts/Poppins/${file}`,
weight,
style: "normal",
});
it("each font a host hands over is saved as its own face, with the weight and style it draws", async () => {
let html = '<html><head></head><body><h1 id="title"></h1><p id="subtitle"></p></body></html>';
let listed: ImportedFontAsset | null = null;
const context: DomStyleCommitContext = {
activeCompPath: "index.html",
previewIframeRef: { current: null },
showToast: () => undefined,
versions: new Map(),
resolveImportedFontAsset: () => listed,
persistDomEditOperations: (async (
_selection: DomEditSelection,
_operations: unknown,
options?: { prepareContent?: (html: string, sourceFile: string) => string },
) => {
html = options?.prepareContent?.(html, "index.html") ?? html;
}) as unknown as DomStyleCommitContext["persistDomEditOperations"],
};
listed = poppins("Poppins-SemiBold.ttf", "600");
await commitDomStyles(context, selection("title"), { "font-family": "Poppins" });
listed = poppins("Poppins-Regular.ttf", "400");
await commitDomStyles(context, selection("subtitle"), { "font-family": "Poppins" });
const faces = html.match(/@font-face[^}]*}/g) ?? [];
expect(faces).toHaveLength(2);
expect(faces[0]).toContain('Poppins-SemiBold.ttf"); font-weight: 600; font-style: normal;');
expect(faces[1]).toContain('Poppins-Regular.ttf"); font-weight: 400; font-style: normal;');
});
@@ -52,7 +52,7 @@ export function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string):
}
export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
const id = `studio-imported-font-${asset.path.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
if (doc.getElementById(id)) return;
const style = doc.createElement("style");
style.id = id;
@@ -72,12 +72,15 @@ export function ensureImportedFontFace(
const styleMatch = styleRe.exec(html);
if (styleMatch) {
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
return html.replace(
styleMatch[0],
() => `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`,
);
}
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
if (/<\/head>/i.test(html)) {
return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
return html.replace(/<\/head>/i, () => ` ${styleTag}\n </head>`);
}
return `${styleTag}\n${html}`;
}