diff --git a/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] latest version default props.png b/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] latest version default props.png index e2c663eec6..854151abc0 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] latest version default props.png and b/packages/visual-editor/src/components/testing/screenshots/Locator/[desktop] latest version default props.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[desktop] default props with coordinate - with api key.png b/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[desktop] default props with coordinate - with api key.png index e3e4dfc03e..cd05f019ad 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[desktop] default props with coordinate - with api key.png and b/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[desktop] default props with coordinate - with api key.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[mobile] default props with coordinate - with api key.png b/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[mobile] default props with coordinate - with api key.png index 0dfdf9f9f0..b45f74f546 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[mobile] default props with coordinate - with api key.png and b/packages/visual-editor/src/components/testing/screenshots/StaticMapSection/[mobile] default props with coordinate - with api key.png differ diff --git a/packages/visual-editor/src/internal/components/InternalThemeEditor.tsx b/packages/visual-editor/src/internal/components/InternalThemeEditor.tsx index 932e234924..c656304fc8 100644 --- a/packages/visual-editor/src/internal/components/InternalThemeEditor.tsx +++ b/packages/visual-editor/src/internal/components/InternalThemeEditor.tsx @@ -159,6 +159,7 @@ export const InternalThemeEditor = ({ themeHistoriesRef={themeHistoriesRef} themeConfig={themeConfig} onThemeChange={handleThemeChange} + customFonts={templateMetadata.customFonts} /> ); }, []); diff --git a/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeEditorRightSidebar.tsx b/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeEditorRightSidebar.tsx index 46161b37af..c56510b8b2 100644 --- a/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeEditorRightSidebar.tsx +++ b/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeEditorRightSidebar.tsx @@ -5,17 +5,19 @@ import { OnThemeChangeFunc, ThemeHistories } from "../../../types/themeData.ts"; import "@puckeditor/core/dist/index.css"; import { ThemeFieldsSidebar } from "./ThemeFieldsSidebar.tsx"; import { pt } from "../../../../utils/i18n/platform.ts"; +import { FontRegistry } from "../../../../utils/fonts/visualEditorFonts.ts"; type ThemeEditorRightSidebarProps = { themeHistoriesRef: React.MutableRefObject; themeConfig?: ThemeConfig; onThemeChange: OnThemeChangeFunc; + customFonts?: FontRegistry; }; export const ThemeEditorRightSidebar = ( props: ThemeEditorRightSidebarProps ) => { - const { themeConfig, themeHistoriesRef, onThemeChange } = props; + const { themeConfig, themeHistoriesRef, onThemeChange, customFonts } = props; if (!themeHistoriesRef.current) { return; @@ -54,6 +56,7 @@ export const ThemeEditorRightSidebar = ( themeConfig={themeConfig} themeData={themeData} onThemeChange={onThemeChange} + customFonts={customFonts} /> ); diff --git a/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeFieldsSidebar.tsx b/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeFieldsSidebar.tsx index e7368a7704..425e7cbe4f 100644 --- a/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeFieldsSidebar.tsx +++ b/packages/visual-editor/src/internal/puck/components/theme-editor-sidebars/ThemeFieldsSidebar.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { AutoField } from "@puckeditor/core"; import { ChevronDown, ChevronUp } from "lucide-react"; import { @@ -12,24 +12,62 @@ import { } from "../../../utils/constructThemePuckFields.ts"; import { generateCssVariablesFromPuckFields } from "../../../utils/internalThemeResolver.ts"; import { pt } from "../../../../utils/i18n/platform.ts"; +import { + buildCustomFontPreloads, + CUSTOM_FONT_PRELOADS_KEY, + loadCustomFontCssIndex, + removeCustomFontPreloads, + type CustomFontCssIndex, +} from "../../../utils/customFontPreloads.ts"; +import { FontRegistry } from "../../../../utils/fonts/visualEditorFonts.ts"; type ThemeFieldsSidebarProps = { themeConfig: ThemeConfig; themeData: ThemeData; onThemeChange: OnThemeChangeFunc; + customFonts?: FontRegistry; }; export const ThemeFieldsSidebar = ({ themeConfig, themeData, onThemeChange, + customFonts, }: ThemeFieldsSidebarProps) => { + const [customFontCssIndex, setCustomFontCssIndex] = + useState(null); + + useEffect(() => { + let isActive = true; + + const loadIndex = async () => { + // Build a cached index of @font-face rules for custom fonts to map weights -> files. + if (!customFonts || Object.keys(customFonts).length === 0) { + if (isActive) { + setCustomFontCssIndex(null); + } + return; + } + + const index = await loadCustomFontCssIndex(customFonts); + if (isActive) { + setCustomFontCssIndex(index); + } + }; + + loadIndex(); + + return () => { + isActive = false; + }; + }, [customFonts]); + const handleThemeChange = ( themeSectionKey: string, themeSection: ThemeConfigSection, newValue: Record ) => { - const newThemeValues = { + let newThemeValues: ThemeData = { ...themeData, ...generateCssVariablesFromPuckFields( newValue, @@ -37,6 +75,26 @@ export const ThemeFieldsSidebar = ({ themeSection ), }; + + if (customFonts && customFontCssIndex) { + const preloads = buildCustomFontPreloads({ + themeConfig, + themeValues: newThemeValues, + customFonts, + customFontCssIndex, + }); + if (preloads.length > 0) { + newThemeValues = { + ...newThemeValues, + [CUSTOM_FONT_PRELOADS_KEY]: preloads, + }; + } else { + newThemeValues = removeCustomFontPreloads(newThemeValues); + } + } else if (!customFonts && CUSTOM_FONT_PRELOADS_KEY in newThemeValues) { + newThemeValues = removeCustomFontPreloads(newThemeValues); + } + onThemeChange(newThemeValues); }; const [collapsedSections, setCollapsedSections] = React.useState<{ diff --git a/packages/visual-editor/src/internal/utils/customFontPreloads.test.ts b/packages/visual-editor/src/internal/utils/customFontPreloads.test.ts new file mode 100644 index 0000000000..6bc3421d5e --- /dev/null +++ b/packages/visual-editor/src/internal/utils/customFontPreloads.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest"; +import { buildCustomFontPreloads } from "./customFontPreloads.ts"; +import { ThemeConfig } from "../../utils/themeResolver.ts"; +import { FontRegistry } from "../../utils/fonts/visualEditorFonts.ts"; + +describe("buildCustomFontPreloads", () => { + it("returns all matching custom font files for multiple fonts", () => { + const themeConfig: ThemeConfig = { + h1: { + label: "H1", + styles: { + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: [], + default: "'Alpha', sans-serif", + }, + fontWeight: { + label: "Weight", + type: "select", + plugin: "fontWeight", + options: [], + default: "700", + }, + }, + }, + body: { + label: "Body", + styles: { + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: [], + default: "'Beta', serif", + }, + fontWeight: { + label: "Weight", + type: "select", + plugin: "fontWeight", + options: [], + default: "400", + }, + }, + }, + }; + + const customFonts: FontRegistry = { + Alpha: { + italics: false, + weights: [400, 700], + fallback: "sans-serif", + }, + Beta: { + italics: false, + weights: [300, 400], + fallback: "serif", + }, + }; + + const customFontCssIndex = { + Alpha: { + staticSrcByWeight: { + 700: "/y-fonts/alpha-bold.woff2", + }, + }, + Beta: { + staticSrcByWeight: { + 400: "/y-fonts/beta-regular.woff2", + }, + }, + }; + + const themeValues = { + "--fontFamily-h1-fontFamily": "'Alpha', sans-serif", + "--fontWeight-h1-fontWeight": "700", + "--fontFamily-body-fontFamily": "'Beta', serif", + "--fontWeight-body-fontWeight": "400", + }; + + const preloads = buildCustomFontPreloads({ + themeConfig, + themeValues, + customFonts, + customFontCssIndex, + }); + + expect(preloads).toHaveLength(2); + expect(preloads).toEqual( + expect.arrayContaining([ + "/y-fonts/alpha-bold.woff2", + "/y-fonts/beta-regular.woff2", + ]) + ); + }); + + it("prefers variable font files regardless of selected weight", () => { + const themeConfig: ThemeConfig = { + body: { + label: "Body", + styles: { + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: [], + default: "'Gamma', sans-serif", + }, + fontWeight: { + label: "Weight", + type: "select", + plugin: "fontWeight", + options: [], + default: "500", + }, + }, + }, + }; + + const customFonts: FontRegistry = { + Gamma: { + italics: false, + weights: [300, 400, 500, 600], + fallback: "sans-serif", + }, + }; + + const customFontCssIndex = { + Gamma: { + variableSrc: "/y-fonts/gamma-variable.woff2", + staticSrcByWeight: { + 500: "/y-fonts/gamma-500.woff2", + }, + }, + }; + + const themeValues = { + "--fontFamily-body-fontFamily": "'Gamma', sans-serif", + "--fontWeight-body-fontWeight": "500", + }; + + const preloads = buildCustomFontPreloads({ + themeConfig, + themeValues, + customFonts, + customFontCssIndex, + }); + + expect(preloads).toEqual(["/y-fonts/gamma-variable.woff2"]); + }); + + it("omits a preload when no matching static weight exists", () => { + const themeConfig: ThemeConfig = { + body: { + label: "Body", + styles: { + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: [], + default: "'Delta', sans-serif", + }, + fontWeight: { + label: "Weight", + type: "select", + plugin: "fontWeight", + options: [], + default: "600", + }, + }, + }, + }; + + const customFonts: FontRegistry = { + Delta: { + italics: false, + weights: [400, 700], + fallback: "sans-serif", + }, + }; + + const customFontCssIndex = { + Delta: { + staticSrcByWeight: { + 400: "/y-fonts/delta-regular.woff2", + 700: "/y-fonts/delta-bold.woff2", + }, + }, + }; + + const themeValues = { + "--fontFamily-body-fontFamily": "'Delta', sans-serif", + "--fontWeight-body-fontWeight": "600", + }; + + const preloads = buildCustomFontPreloads({ + themeConfig, + themeValues, + customFonts, + customFontCssIndex, + }); + + expect(preloads).toEqual([]); + }); +}); diff --git a/packages/visual-editor/src/internal/utils/customFontPreloads.ts b/packages/visual-editor/src/internal/utils/customFontPreloads.ts new file mode 100644 index 0000000000..5c2a81bae1 --- /dev/null +++ b/packages/visual-editor/src/internal/utils/customFontPreloads.ts @@ -0,0 +1,298 @@ +/** + * Custom font preloads + * + * High-level flow: + * 1. Load custom font CSS from /y-fonts/*.css and index for @font-face rules. + * 2. Use theme values (font family + weight) to choose the correct file per font. + * 3. Store the resulting file list in themeData for head preloading. + */ + +import { + FontRegistry, + generateCustomFontLinkData, +} from "../../utils/fonts/visualEditorFonts.ts"; +import { ThemeConfig } from "../../utils/themeResolver.ts"; +import { ThemeData } from "../types/themeData.ts"; +import { generateCssVariablesFromThemeConfig } from "./internalThemeResolver.ts"; + +export const CUSTOM_FONT_PRELOADS_KEY = "__customFontPreloads"; + +type CustomFontFaceIndex = { + variableSrc?: string; + staticSrcByWeight: Record; +}; + +export type CustomFontCssIndex = Record; + +/** + * Removes wrapping single or double quotes from a string. + */ +const stripQuotes = (value: string) => value.trim().replace(/^['"]|['"]$/g, ""); + +/** + * Extracts the first font-family name from a font-family declaration value. + * For example, given "'Alpha', sans-serif", it returns "Alpha". + */ +const extractFontFamilyName = (value: string) => { + const firstFont = value.split(",")[0]; + return stripQuotes(firstFont); +}; + +/** + * Returns all @font-face blocks found in the css text. + * For example: + * @font-face { + * font-family: 'Alpha'; + * font-weight: 400; + * src: url('/y-fonts/alpha-400.woff2'); + * } + */ +const extractFontFaceBlocks = (cssText: string) => { + // Matches each "@font-face { ... }" block up to the first closing brace. + return cssText.match(/@font-face\s*{[^}]*}/gi) ?? []; +}; + +/** + * Extracts the first URL from a css src declaration. + */ +const extractFirstUrl = (srcValue: string) => { + // Captures the first url(...) occurrence in srcValue + const match = srcValue.match(/url\(([^)]+)\)/i); + if (!match) { + return undefined; + } + return stripQuotes(match[1]); +}; + +/** + * Parses a font-weight value that can be a single number or a range. + */ +const parseFontWeight = (value: string) => { + const normalized = value.trim().replace(/\s+/g, " "); + if (normalized.includes(" ")) { + const [min, max] = normalized.split(" "); + const minWeight = Number.parseInt(min, 10); + const maxWeight = Number.parseInt(max, 10); + if (!Number.isNaN(minWeight) && !Number.isNaN(maxWeight)) { + return { type: "range" as const, minWeight, maxWeight }; + } + } + + const weight = Number.parseInt(normalized, 10); + if (!Number.isNaN(weight)) { + return { type: "single" as const, weight }; + } + return undefined; +}; + +/** + * Parses a single @font-face block into its family, weight, and file source. + * For example, given the block: + * @font-face { + * font-family: 'Alpha'; + * font-weight: 400; + * font-style: normal; + * src: url('/y-fonts/alpha-400.woff2'); + * } + * It returns: + * { + * fontFamily: "Alpha", + * weight: { type: "single", weight: 400 }, + * src: "/y-fonts/alpha-400.woff2" + * } + * It returns undefined if required properties are missing or if the style is not normal. + */ +const parseFontFaceBlock = (block: string) => { + // Capture property values up to the semicolon (case-insensitive). + const familyMatch = block.match(/font-family\s*:\s*([^;]+);/i); + const weightMatch = block.match(/font-weight\s*:\s*([^;]+);/i); + const styleMatch = block.match(/font-style\s*:\s*([^;]+);/i); + const srcMatch = block.match(/src\s*:\s*([^;]+);/i); + + const fontFamilyRaw = familyMatch ? familyMatch[1] : undefined; + const fontWeightRaw = weightMatch ? weightMatch[1] : undefined; + const fontStyleRaw = styleMatch ? styleMatch[1] : "normal"; + const srcRaw = srcMatch ? srcMatch[1] : undefined; + + if (!fontFamilyRaw || !fontWeightRaw || !srcRaw) { + return undefined; + } + + const fontStyle = fontStyleRaw.trim().toLowerCase(); + // Theme data does not track italic styles; only preload normal font styles. + if (fontStyle !== "normal") { + return undefined; + } + + const fontFamily = stripQuotes(fontFamilyRaw); + const weight = parseFontWeight(fontWeightRaw); + const src = extractFirstUrl(srcRaw); + + if (!fontFamily || !weight || !src) { + return undefined; + } + + return { fontFamily, weight, src }; +}; + +/** + * Builds an index of @font-face rules for custom fonts by fetching their css. + */ +export const loadCustomFontCssIndex = async ( + customFonts: FontRegistry +): Promise => { + const index: CustomFontCssIndex = {}; + const customFontNames = Object.keys(customFonts); + if (customFontNames.length === 0 || typeof window === "undefined") { + return index; + } + + const linkData = generateCustomFontLinkData(customFontNames, "./"); + const cssTexts = await Promise.all( + linkData.map(async (link) => { + try { + const response = await fetch(link.href); + if (!response.ok) { + return ""; + } + return await response.text(); + } catch { + return ""; + } + }) + ); + + cssTexts.forEach((cssText) => { + extractFontFaceBlocks(cssText).forEach((block) => { + const parsed = parseFontFaceBlock(block); + if (!parsed) { + return; + } + + const { fontFamily, weight, src } = parsed; + if (!index[fontFamily]) { + index[fontFamily] = { staticSrcByWeight: {} }; + } + if (weight.type === "range") { + index[fontFamily].variableSrc = src; + } else { + index[fontFamily].staticSrcByWeight[weight.weight] = src; + } + }); + }); + + return index; +}; + +/** + * Computes the list of custom font files to preload based on theme values. + */ +export const buildCustomFontPreloads = ({ + themeConfig, + themeValues, + customFonts, + customFontCssIndex, +}: { + themeConfig: ThemeConfig; + themeValues: ThemeData; + customFonts: FontRegistry; + customFontCssIndex: CustomFontCssIndex; +}) => { + const defaultThemeValues = generateCssVariablesFromThemeConfig(themeConfig); + const mergedThemeValues = { ...defaultThemeValues, ...themeValues }; + const preloads: string[] = []; + const seen = new Set(); + + Object.keys(mergedThemeValues).forEach((key) => { + if (!key.startsWith("--fontFamily-") || !key.endsWith("-fontFamily")) { + return; + } + + const sectionKey = key + .replace("--fontFamily-", "") + .replace("-fontFamily", ""); + const fontFamilyValue = mergedThemeValues[key]; + if (typeof fontFamilyValue !== "string") { + return; + } + + const fontFamily = extractFontFamilyName(fontFamilyValue); + if (!customFonts[fontFamily]) { + return; + } + + const weightValue = + mergedThemeValues[`--fontWeight-${sectionKey}-fontWeight`]; + const weight = Number.parseInt(String(weightValue), 10); + if (Number.isNaN(weight)) { + return; + } + + const index = customFontCssIndex[fontFamily]; + if (!index) { + return; + } + + const src = index.variableSrc ?? index.staticSrcByWeight[weight]; + if (!src || seen.has(src)) { + return; + } + + seen.add(src); + preloads.push(src); + }); + + return preloads; +}; + +/** + * Removes the custom font preloads key from theme data if present. + */ +export const removeCustomFontPreloads = (themeValues: ThemeData) => { + if (!(CUSTOM_FONT_PRELOADS_KEY in themeValues)) { + return themeValues; + } + // oxlint-disable-next-line no-unused-vars: ignore unused _ + const { [CUSTOM_FONT_PRELOADS_KEY]: _, ...rest } = themeValues; + return rest; +}; + +/** + * Returns the custom font preload list from theme data, if available. + */ +export const getCustomFontPreloads = (themeValues: ThemeData | undefined) => { + if (!themeValues) { + return []; + } + const preloads = themeValues[CUSTOM_FONT_PRELOADS_KEY]; + return Array.isArray(preloads) ? preloads.filter(Boolean) : []; +}; + +/** + * Builds the HTML link tags for preloading custom fonts. + */ +export const buildFontPreloadTags = ( + preloads: string[], + relativePrefixToRoot: string +) => { + if (preloads.length === 0) { + return ""; + } + + return ( + preloads + .map((href) => { + const normalizedHref = + href.startsWith("http://") || + href.startsWith("https://") || + href.startsWith("/") || + href.startsWith("./") || + href.startsWith("../") + ? href + : `${relativePrefixToRoot}${href}`; + + return ``; + }) + .join("\n") + "\n" + ); +}; diff --git a/packages/visual-editor/src/utils/applyTheme.test.ts b/packages/visual-editor/src/utils/applyTheme.test.ts index 7b3afbe513..b2a18a07e2 100644 --- a/packages/visual-editor/src/utils/applyTheme.test.ts +++ b/packages/visual-editor/src/utils/applyTheme.test.ts @@ -91,6 +91,7 @@ describe("buildCssOverridesStyle", () => { "'Adamina', 'Adamina Fallback', serif", "--fontFamily-h2-fontFamily": "'Yext Custom', 'Yext Custom Fallback', serif", + __customFontPreloads: ["/y-fonts/yextcustom-bold.woff2"], }), }, }; @@ -98,7 +99,8 @@ describe("buildCssOverridesStyle", () => { const result = applyTheme(streamDocument, "./", themeConfig); expect(result).toBe( - '\n' + + '\n' + + '\n' + '\n' + '\n' + '' + @@ -116,6 +118,25 @@ describe("buildCssOverridesStyle", () => { ); }); + it("should not include non-css keys in the theme style tag", () => { + const streamDocument: StreamDocument = { + siteId: 123, + __: { + theme: JSON.stringify({ + "--fontFamily-h1-fontFamily": "'Roboto', sans-serif", + __customFontPreloads: ["./y-fonts/roboto-regular.woff2"], + }), + }, + }; + + const result = applyTheme(streamDocument, "./", themeConfig); + + expect(result).toContain( + '' + ); + expect(result).not.toContain("__customFontPreloads"); + }); + it("should return the base string unmodified when themeConfig is empty", () => { const base = ""; const result = applyTheme({} as StreamDocument, "./", {}, base); diff --git a/packages/visual-editor/src/utils/applyTheme.ts b/packages/visual-editor/src/utils/applyTheme.ts index c01f05c6d7..66c69cda05 100644 --- a/packages/visual-editor/src/utils/applyTheme.ts +++ b/packages/visual-editor/src/utils/applyTheme.ts @@ -14,6 +14,10 @@ import { type FontLinkData, generateCustomFontLinkData, } from "./fonts/visualEditorFonts.ts"; +import { + buildFontPreloadTags, + getCustomFontPreloads, +} from "../internal/utils/customFontPreloads.ts"; import { ThemeConfig } from "./themeResolver.ts"; import { getContrastingColor } from "./colors.ts"; import fontFallbackTransformations from "./fonts/fontFallbackTransformations.json" with { type: "json" }; @@ -91,7 +95,13 @@ export const applyTheme = ( const fontLinkTags = fontLinkDataToHTML(fontLinkData); if (Object.keys(themeConfig).length > 0) { - return `${base ?? ""}${fontLinkTags}`; + const customFontPreloads = getCustomFontPreloads(overrides); + const preloadTags = buildFontPreloadTags( + customFontPreloads, + relativePrefixToRoot + ); + + return `${base ?? ""}${preloadTags}${fontLinkTags}`; } return base ?? ""; }; @@ -121,6 +131,7 @@ const internalApplyTheme = ( return ( `.components{` + Object.entries(themeValuesToApply) + .filter(([key]) => key.startsWith("--")) .map(([key, value]) => `${key}:${value} !important`) .join(";") + "}"