From 5b672e428ac0438eb369603d33777e8d42f6dbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 01:35:50 -0400 Subject: [PATCH 1/3] fix(studio): a family's saved font faces keep their own weight and style --- .../src/components/editor/fontAssets.test.ts | 49 +++++++++++++++++++ .../src/components/editor/fontAssets.ts | 13 ++++- .../studio/src/utils/studioFontHelpers.ts | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/studio/src/components/editor/fontAssets.test.ts diff --git a/packages/studio/src/components/editor/fontAssets.test.ts b/packages/studio/src/components/editor/fontAssets.test.ts new file mode 100644 index 0000000000..2ca01fff7b --- /dev/null +++ b/packages/studio/src/components/editor/fontAssets.test.ts @@ -0,0 +1,49 @@ +// @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 => ({ + 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 = ""; + 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); +}); diff --git a/packages/studio/src/components/editor/fontAssets.ts b/packages/studio/src/components/editor/fontAssets.ts index df785f6558..54d50b989d 100644 --- a/packages/studio/src/components/editor/fontAssets.ts +++ b/packages/studio/src/components/editor/fontAssets.ts @@ -2,6 +2,9 @@ export interface ImportedFontAsset { family: string; path: string; url: string; + /** The weights this file draws: "700" for a static file, "100 900" for a variable one. */ + weight?: string; + style?: "normal" | "italic"; } const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i; @@ -27,6 +30,14 @@ export function fontFamilyFromAssetPath(path: string): string { return family || fileName; } +// A face with a weight or style lets several files of one family sit side by side, each drawing its own texts. 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 && /^\d{1,4}( \d{1,4})?$/.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; }`; } diff --git a/packages/studio/src/utils/studioFontHelpers.ts b/packages/studio/src/utils/studioFontHelpers.ts index 0fb3701412..5be16985a6 100644 --- a/packages/studio/src/utils/studioFontHelpers.ts +++ b/packages/studio/src/utils/studioFontHelpers.ts @@ -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; From 0b9a093df0cfb15573521078a39cddeed8eeb04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 03:21:50 -0400 Subject: [PATCH 2/3] fix(studio): keep the font face change within the comment ratchet --- packages/studio/src/components/editor/fontAssets.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/studio/src/components/editor/fontAssets.ts b/packages/studio/src/components/editor/fontAssets.ts index 54d50b989d..1bcf210a80 100644 --- a/packages/studio/src/components/editor/fontAssets.ts +++ b/packages/studio/src/components/editor/fontAssets.ts @@ -2,7 +2,6 @@ export interface ImportedFontAsset { family: string; path: string; url: string; - /** The weights this file draws: "700" for a static file, "100 900" for a variable one. */ weight?: string; style?: "normal" | "italic"; } @@ -30,7 +29,6 @@ export function fontFamilyFromAssetPath(path: string): string { return family || fileName; } -// A face with a weight or style lets several files of one family sit side by side, each drawing its own texts. export function importedFontFaceCss(asset: ImportedFontAsset, url: string = asset.url): string { const weight = asset.weight && /^\d{1,4}( \d{1,4})?$/.test(asset.weight) From 975e19bb81ddd023048ce91f8d2c1b38b1deebd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 04:12:45 -0400 Subject: [PATCH 3/3] fix(studio): a saved font face cannot break the page, and only weights a browser keeps are written --- .../src/components/editor/fontAssets.test.ts | 20 +++++++ .../src/components/editor/fontAssets.ts | 4 +- .../src/hooks/domStyleCommit.fonts.test.ts | 55 +++++++++++++++++++ .../studio/src/utils/studioFontHelpers.ts | 7 ++- 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/hooks/domStyleCommit.fonts.test.ts diff --git a/packages/studio/src/components/editor/fontAssets.test.ts b/packages/studio/src/components/editor/fontAssets.test.ts index 2ca01fff7b..52e5660fad 100644 --- a/packages/studio/src/components/editor/fontAssets.test.ts +++ b/packages/studio/src/components/editor/fontAssets.test.ts @@ -47,3 +47,23 @@ it("two weights of one family are both kept in the file and both drawn in the pr 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: "x" })).not.toContain( + "", + ); +}); + +it("a file name with a replacement pattern is saved as written, not expanded into the page", () => { + const html = "rest"; + 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(//g)).toHaveLength(1); + const again = ensureImportedFontFace(saved, { ...odd, path: "assets/Cash$&.ttf" }, "index.html"); + expect(again).toContain("Cash$&.ttf"); + expect(again.match(//g)).toHaveLength(1); +}); diff --git a/packages/studio/src/components/editor/fontAssets.ts b/packages/studio/src/components/editor/fontAssets.ts index 1bcf210a80..5e75c19839 100644 --- a/packages/studio/src/components/editor/fontAssets.ts +++ b/packages/studio/src/components/editor/fontAssets.ts @@ -11,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(/ + ({ + 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 = '

'; + 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;'); +}); diff --git a/packages/studio/src/utils/studioFontHelpers.ts b/packages/studio/src/utils/studioFontHelpers.ts index 5be16985a6..dac634c4f2 100644 --- a/packages/studio/src/utils/studioFontHelpers.ts +++ b/packages/studio/src/utils/studioFontHelpers.ts @@ -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], ``); + return html.replace( + styleMatch[0], + () => ``, + ); } const styleTag = ``; if (/<\/head>/i.test(html)) { - return html.replace(/<\/head>/i, ` ${styleTag}\n `); + return html.replace(/<\/head>/i, () => ` ${styleTag}\n `); } return `${styleTag}\n${html}`; }