Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/studio/src/components/editor/fontAssets.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 11 additions & 2 deletions packages/studio/src/components/editor/fontAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ export interface ImportedFontAsset {
family: string;
path: string;
url: string;
weight?: string;
style?: "normal" | "italic";
}

const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i;
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 {
Expand All @@ -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; }`;
}
55 changes: 55 additions & 0 deletions packages/studio/src/hooks/domStyleCommit.fonts.test.ts
Original file line number Diff line number Diff line change
@@ -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;');
});
9 changes: 6 additions & 3 deletions packages/studio/src/utils/studioFontHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`;
}
Loading