@zalify/storefront-kit 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Google Fonts resolution, including the server side of the next/font
3
+ * pattern: fetch Google's hosted CSS once per process, inline the
4
+ * @font-face rules into the HTML and preload the latin woff2 files so
5
+ * the brand font is downloading before first paint (no FOUT) instead
6
+ * of being discovered through an async stylesheet after paint.
7
+ *
8
+ * Lives in /commerce (not /react) because Next.js server components
9
+ * resolve react with the react-server condition and cannot import the
10
+ * react entries; layouts call resolveGoogleFontCss() from here and pass
11
+ * the result to <CssVariables fonts={…}/>.
12
+ */
13
+ export interface ResolvedFont {
14
+ family: string;
15
+ weight: number;
16
+ style: 'normal' | 'italic';
17
+ }
18
+ /** Parse a Shopify font handle: "work_sans_n4" → Work Sans / 400 / normal. */
19
+ export declare function parseFontHandle(handle: string | undefined): ResolvedFont;
20
+ export declare function googleFontsHref(family: string, axes?: string): string;
21
+ /**
22
+ * The stylesheet URLs a storefront needs for its font settings
23
+ * (resolved theme settings: type_body_font/_google etc.).
24
+ */
25
+ export declare function googleFontsHrefsFromSettings(settings: Record<string, unknown>): string[];
26
+ interface FontSchemaSource {
27
+ settingsSchema: Array<{
28
+ settings?: Array<Record<string, unknown>>;
29
+ }>;
30
+ settingsData: {
31
+ current?: string | Record<string, unknown>;
32
+ presets?: Record<string, Record<string, unknown>>;
33
+ };
34
+ }
35
+ /**
36
+ * Resolve theme settings (schema defaults + current preset + app
37
+ * override) outside the react engine — for server components that
38
+ * cannot use installTheme()'s store. Same overlay rules as the engine.
39
+ */
40
+ export declare function fontSettingsFromSchema(schema: FontSchemaSource, override?: Record<string, unknown>): Record<string, unknown>;
41
+ export interface ResolvedGoogleFontCss {
42
+ /** The @font-face rules, ready to inline in a <style>. */
43
+ css: string;
44
+ /** Latin-subset woff2 URLs worth preloading (capped). */
45
+ preloadUrls: string[];
46
+ }
47
+ /**
48
+ * Fetch + cache the Google Fonts CSS for the given stylesheet hrefs.
49
+ * Returns null when any fetch fails, so callers fall back to
50
+ * <CssVariables/>' async stylesheet loader. Failures are not cached;
51
+ * the next request retries.
52
+ */
53
+ export declare function resolveGoogleFontCss(hrefs: string[]): Promise<ResolvedGoogleFontCss | null>;
54
+ export {};
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Google Fonts resolution, including the server side of the next/font
3
+ * pattern: fetch Google's hosted CSS once per process, inline the
4
+ * @font-face rules into the HTML and preload the latin woff2 files so
5
+ * the brand font is downloading before first paint (no FOUT) instead
6
+ * of being discovered through an async stylesheet after paint.
7
+ *
8
+ * Lives in /commerce (not /react) because Next.js server components
9
+ * resolve react with the react-server condition and cannot import the
10
+ * react entries; layouts call resolveGoogleFontCss() from here and pass
11
+ * the result to <CssVariables fonts={…}/>.
12
+ */
13
+ /** Parse a Shopify font handle: "work_sans_n4" → Work Sans / 400 / normal. */
14
+ export function parseFontHandle(handle) {
15
+ const fallback = {
16
+ family: 'Work Sans',
17
+ weight: 400,
18
+ style: 'normal',
19
+ };
20
+ if (!handle)
21
+ return fallback;
22
+ const match = /^(.*)_([nib])(\d)$/.exec(handle);
23
+ if (!match)
24
+ return fallback;
25
+ const family = match[1]
26
+ .split('_')
27
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
28
+ .join(' ');
29
+ return {
30
+ family,
31
+ weight: Number(match[3]) * 100,
32
+ style: match[2] === 'i' ? 'italic' : 'normal',
33
+ };
34
+ }
35
+ export function googleFontsHref(family, axes = 'ital,wght@0,400;0,500;0,700;1,400') {
36
+ const param = family.trim().replace(/ /g, '+');
37
+ // display=swap so the brand font always applies once it arrives, even
38
+ // after first paint (optional was tried and skipped cold loads
39
+ // entirely). With resolveGoogleFontCss() inlining the @font-face
40
+ // rules and preloading the woff2 files, the font usually beats first
41
+ // paint and swap never becomes visible.
42
+ return `https://fonts.googleapis.com/css2?family=${param}:${axes}&display=swap`;
43
+ }
44
+ /**
45
+ * The stylesheet URLs a storefront needs for its font settings
46
+ * (resolved theme settings: type_body_font/_google etc.).
47
+ */
48
+ export function googleFontsHrefsFromSettings(settings) {
49
+ const str = (key) => typeof settings[key] === 'string' ? settings[key].trim() : '';
50
+ const hrefs = [];
51
+ const bodyGoogle = str('type_body_google');
52
+ const headingGoogle = str('type_heading_google');
53
+ const monoGoogle = str('type_mono_google');
54
+ hrefs.push(googleFontsHref(bodyGoogle || parseFontHandle(str('type_body_font') || undefined).family));
55
+ const heading = headingGoogle || parseFontHandle(str('type_heading_font') || undefined).family;
56
+ if (!hrefs[0].includes(heading.replace(/ /g, '+'))) {
57
+ hrefs.push(googleFontsHref(heading, 'wght@400;500;600;700'));
58
+ }
59
+ if (monoGoogle)
60
+ hrefs.push(googleFontsHref(monoGoogle, 'wght@400;500'));
61
+ return hrefs;
62
+ }
63
+ /**
64
+ * Resolve theme settings (schema defaults + current preset + app
65
+ * override) outside the react engine — for server components that
66
+ * cannot use installTheme()'s store. Same overlay rules as the engine.
67
+ */
68
+ export function fontSettingsFromSchema(schema, override) {
69
+ const defaults = {};
70
+ for (const group of schema.settingsSchema) {
71
+ for (const setting of group.settings ?? []) {
72
+ const id = setting.id;
73
+ if (!id || setting.type === 'color_scheme_group')
74
+ continue;
75
+ if ('default' in setting)
76
+ defaults[id] = setting.default;
77
+ }
78
+ }
79
+ const current = typeof schema.settingsData.current === 'string'
80
+ ? (schema.settingsData.presets?.[schema.settingsData.current] ?? {})
81
+ : (schema.settingsData.current ?? {});
82
+ return { ...defaults, ...current, ...override };
83
+ }
84
+ // A woff2-capable UA so Google serves unicode-range-subset woff2 CSS
85
+ // (the default CI/node UA gets legacy TTF without subsets).
86
+ const WOFF2_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
87
+ const cssCache = new Map();
88
+ async function fetchGoogleCss(href) {
89
+ try {
90
+ let signal;
91
+ try {
92
+ signal = AbortSignal.timeout(4000);
93
+ }
94
+ catch {
95
+ signal = undefined;
96
+ }
97
+ const response = await fetch(href, {
98
+ headers: { 'user-agent': WOFF2_UA, accept: 'text/css,*/*;q=0.1' },
99
+ signal,
100
+ });
101
+ if (!response.ok)
102
+ return null;
103
+ const css = await response.text();
104
+ // Preload only the latin subset — above-the-fold text is latin, and
105
+ // extra font preloads compete with the LCP image for bandwidth.
106
+ const latinUrls = [];
107
+ const facePattern = /\/\*\s*latin\s*\*\/\s*@font-face\s*\{[^}]*?url\((https:[^)\s]+\.woff2)\)/g;
108
+ let match;
109
+ while ((match = facePattern.exec(css)))
110
+ latinUrls.push(match[1]);
111
+ return { css, latinUrls };
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ /**
118
+ * Fetch + cache the Google Fonts CSS for the given stylesheet hrefs.
119
+ * Returns null when any fetch fails, so callers fall back to
120
+ * <CssVariables/>' async stylesheet loader. Failures are not cached;
121
+ * the next request retries.
122
+ */
123
+ export async function resolveGoogleFontCss(hrefs) {
124
+ const parts = await Promise.all(hrefs.map((href) => {
125
+ let pending = cssCache.get(href);
126
+ if (!pending) {
127
+ pending = fetchGoogleCss(href);
128
+ cssCache.set(href, pending);
129
+ void pending.then((value) => {
130
+ if (value === null)
131
+ cssCache.delete(href);
132
+ });
133
+ }
134
+ return pending;
135
+ }));
136
+ if (parts.some((part) => part === null))
137
+ return null;
138
+ const css = parts.map((part) => part.css).join('\n');
139
+ const preloadUrls = [...new Set(parts.flatMap((part) => part.latinUrls))].slice(0, 4);
140
+ return { css, preloadUrls };
141
+ }
@@ -9,3 +9,4 @@ export * from './events';
9
9
  export * from './countdown';
10
10
  export * from './facets';
11
11
  export * from './product';
12
+ export * from './google-fonts';
@@ -9,3 +9,4 @@ export * from './events';
9
9
  export * from './countdown';
10
10
  export * from './facets';
11
11
  export * from './product';
12
+ export * from './google-fonts';
@@ -1,3 +1,11 @@
1
- export declare function CssVariables({ nonce }?: {
1
+ import { type ResolvedGoogleFontCss } from '../../commerce/google-fonts.ts';
2
+ /**
3
+ * The Google Fonts stylesheet URLs for the installed theme's settings.
4
+ * Server code passes these to resolveGoogleFontCss() (commerce) to
5
+ * inline the @font-face rules and preload the woff2 files.
6
+ */
7
+ export declare function googleFontsHrefs(): string[];
8
+ export declare function CssVariables({ nonce, fonts, }?: {
2
9
  nonce?: string;
10
+ fonts?: ResolvedGoogleFontCss | null;
3
11
  }): import("react").JSX.Element;
@@ -10,7 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
10
10
  */
11
11
  import { useMemo } from 'react';
12
12
  import { colorSchemes, themeSettings } from './settings';
13
- import { googleFontsHref, parseFontHandle } from './fonts';
13
+ import { googleFontsHrefsFromSettings, parseFontHandle, } from "../../commerce/google-fonts.js";
14
14
  function buildCss() {
15
15
  const s = themeSettings;
16
16
  const bodyGoogle = (s.type_body_google ?? '').trim();
@@ -84,20 +84,13 @@ function buildCss() {
84
84
  lines.push('}');
85
85
  return lines.join('\n');
86
86
  }
87
- function fontLinks() {
88
- const s = themeSettings;
89
- const hrefs = [];
90
- const bodyGoogle = (s.type_body_google ?? '').trim();
91
- const headingGoogle = (s.type_heading_google ?? '').trim();
92
- const monoGoogle = (s.type_mono_google ?? '').trim();
93
- hrefs.push(googleFontsHref(bodyGoogle || parseFontHandle(s.type_body_font).family));
94
- const heading = headingGoogle || parseFontHandle(s.type_heading_font).family;
95
- if (!hrefs[0].includes(heading.replace(/ /g, '+'))) {
96
- hrefs.push(googleFontsHref(heading, 'wght@400;500;600;700'));
97
- }
98
- if (monoGoogle)
99
- hrefs.push(googleFontsHref(monoGoogle, 'wght@400;500'));
100
- return hrefs;
87
+ /**
88
+ * The Google Fonts stylesheet URLs for the installed theme's settings.
89
+ * Server code passes these to resolveGoogleFontCss() (commerce) to
90
+ * inline the @font-face rules and preload the woff2 files.
91
+ */
92
+ export function googleFontsHrefs() {
93
+ return googleFontsHrefsFromSettings(themeSettings);
101
94
  }
102
95
  // Promotes each preloaded font stylesheet to a real stylesheet by
103
96
  // flipping rel once its fetch completes (the Filament preload trick).
@@ -112,7 +105,14 @@ const FONT_CSS_LOADER = [
112
105
  "x.addEventListener('load',function(){x.rel='stylesheet'},{once:true})",
113
106
  '})(l[i])}',
114
107
  ].join('');
115
- export function CssVariables({ nonce } = {}) {
116
- const { css, links } = useMemo(() => ({ css: buildCss(), links: fontLinks() }), []);
108
+ export function CssVariables({ nonce, fonts, } = {}) {
109
+ const { css, links } = useMemo(() => ({ css: buildCss(), links: googleFontsHrefs() }), []);
110
+ // Server-resolved fonts (the next/font pattern): @font-face rules are
111
+ // inlined and the latin woff2 files preloaded, so the font downloads
112
+ // with the HTML and usually beats first paint — no async stylesheet,
113
+ // no FOUT, no request to fonts.googleapis.com at all.
114
+ if (fonts) {
115
+ return (_jsxs(_Fragment, { children: [_jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), fonts.preloadUrls.map((href) => (_jsx("link", { rel: "preload", as: "font", type: "font/woff2", href: href, crossOrigin: "anonymous" }, href))), _jsx("style", { dangerouslySetInnerHTML: { __html: fonts.css } }), _jsx("style", { dangerouslySetInnerHTML: { __html: css } })] }));
116
+ }
117
117
  return (_jsxs(_Fragment, { children: [_jsx("link", { rel: "preconnect", href: "https://fonts.googleapis.com" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), links.map((href) => (_jsx("link", { rel: "preload", as: "style", href: href, "data-zfy-font-css": "" }, href))), _jsx("script", { nonce: nonce, dangerouslySetInnerHTML: { __html: FONT_CSS_LOADER } }), _jsx("noscript", { children: links.map((href) => (_jsx("link", { rel: "stylesheet", href: href }, href))) }), _jsx("style", { dangerouslySetInnerHTML: { __html: css } })] }));
118
118
  }
@@ -1,15 +1,6 @@
1
1
  /**
2
- * Font resolution for the mirror. The Liquid theme uses Shopify's
3
- * font_picker (handles like "work_sans_n4") plus optional Google-font
4
- * override text settings. Outside Shopify's font CDN we resolve picker
5
- * handles to the same family served from Google Fonts, keeping the
6
- * css-variables contract (--font-body--family etc.) identical.
2
+ * Font resolution moved to commerce/google-fonts.ts so server
3
+ * components (which cannot import the react entries) can share it.
4
+ * Re-exported here to keep the react API stable.
7
5
  */
8
- export interface ResolvedFont {
9
- family: string;
10
- weight: number;
11
- style: 'normal' | 'italic';
12
- }
13
- /** Parse a Shopify font handle: "work_sans_n4" → Work Sans / 400 / normal. */
14
- export declare function parseFontHandle(handle: string | undefined): ResolvedFont;
15
- export declare function googleFontsHref(family: string, axes?: string): string;
6
+ export { googleFontsHref, parseFontHandle, type ResolvedFont, } from '../../commerce/google-fonts.ts';
@@ -1,38 +1,6 @@
1
1
  /**
2
- * Font resolution for the mirror. The Liquid theme uses Shopify's
3
- * font_picker (handles like "work_sans_n4") plus optional Google-font
4
- * override text settings. Outside Shopify's font CDN we resolve picker
5
- * handles to the same family served from Google Fonts, keeping the
6
- * css-variables contract (--font-body--family etc.) identical.
2
+ * Font resolution moved to commerce/google-fonts.ts so server
3
+ * components (which cannot import the react entries) can share it.
4
+ * Re-exported here to keep the react API stable.
7
5
  */
8
- /** Parse a Shopify font handle: "work_sans_n4" → Work Sans / 400 / normal. */
9
- export function parseFontHandle(handle) {
10
- const fallback = {
11
- family: 'Work Sans',
12
- weight: 400,
13
- style: 'normal',
14
- };
15
- if (!handle)
16
- return fallback;
17
- const match = /^(.*)_([nib])(\d)$/.exec(handle);
18
- if (!match)
19
- return fallback;
20
- const family = match[1]
21
- .split('_')
22
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
23
- .join(' ');
24
- return {
25
- family,
26
- weight: Number(match[3]) * 100,
27
- style: match[2] === 'i' ? 'italic' : 'normal',
28
- };
29
- }
30
- export function googleFontsHref(family, axes = 'ital,wght@0,400;0,500;0,700;1,400') {
31
- const param = family.trim().replace(/ /g, '+');
32
- // display=optional: the font never swaps in after first paint, so a
33
- // late-arriving font file can't reflow text (zero font CLS). With the
34
- // stylesheet loaded async (CssVariables), swap would otherwise shift
35
- // layout on every cold load; optional shows the fallback first visit
36
- // and the brand font from the cache on later views.
37
- return `https://fonts.googleapis.com/css2?family=${param}:${axes}&display=optional`;
38
- }
6
+ export { googleFontsHref, parseFontHandle, } from "../../commerce/google-fonts.js";
@@ -19,7 +19,7 @@ export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
19
19
  export { t } from './engine/translate';
20
20
  export { ThemeTemplate, SectionGroup, RenderSection, RenderSections, } from './engine/render';
21
21
  export { ThemeDataProvider, TemplateProvider, useThemeGlobals, useResource, useSectionData, useTemplateScope, } from './engine/context';
22
- export { CssVariables } from './engine/CssVariables';
22
+ export { CssVariables, googleFontsHrefs } from './engine/CssVariables';
23
23
  export { parseFontHandle, googleFontsHref } from './engine/fonts';
24
24
  export { settingImageUrl, imageUrl, imageSrcSet, PlaceholderSvg, } from './engine/images';
25
25
  export { AdapterProvider, useAdapter } from './adapter';
@@ -19,7 +19,7 @@ export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
19
19
  export { t } from './engine/translate';
20
20
  export { ThemeTemplate, SectionGroup, RenderSection, RenderSections, } from './engine/render';
21
21
  export { ThemeDataProvider, TemplateProvider, useThemeGlobals, useResource, useSectionData, useTemplateScope, } from './engine/context';
22
- export { CssVariables } from './engine/CssVariables';
22
+ export { CssVariables, googleFontsHrefs } from './engine/CssVariables';
23
23
  export { parseFontHandle, googleFontsHref } from './engine/fonts';
24
24
  export { settingImageUrl, imageUrl, imageSrcSet, PlaceholderSvg, } from './engine/images';
25
25
  // Adapter seam
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Google Fonts resolution, including the server side of the next/font
3
+ * pattern: fetch Google's hosted CSS once per process, inline the
4
+ * @font-face rules into the HTML and preload the latin woff2 files so
5
+ * the brand font is downloading before first paint (no FOUT) instead
6
+ * of being discovered through an async stylesheet after paint.
7
+ *
8
+ * Lives in /commerce (not /react) because Next.js server components
9
+ * resolve react with the react-server condition and cannot import the
10
+ * react entries; layouts call resolveGoogleFontCss() from here and pass
11
+ * the result to <CssVariables fonts={…}/>.
12
+ */
13
+
14
+ export interface ResolvedFont {
15
+ family: string;
16
+ weight: number;
17
+ style: 'normal' | 'italic';
18
+ }
19
+
20
+ /** Parse a Shopify font handle: "work_sans_n4" → Work Sans / 400 / normal. */
21
+ export function parseFontHandle(handle: string | undefined): ResolvedFont {
22
+ const fallback: ResolvedFont = {
23
+ family: 'Work Sans',
24
+ weight: 400,
25
+ style: 'normal',
26
+ };
27
+ if (!handle) return fallback;
28
+ const match = /^(.*)_([nib])(\d)$/.exec(handle);
29
+ if (!match) return fallback;
30
+ const family = match[1]
31
+ .split('_')
32
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
33
+ .join(' ');
34
+ return {
35
+ family,
36
+ weight: Number(match[3]) * 100,
37
+ style: match[2] === 'i' ? 'italic' : 'normal',
38
+ };
39
+ }
40
+
41
+ export function googleFontsHref(
42
+ family: string,
43
+ axes = 'ital,wght@0,400;0,500;0,700;1,400',
44
+ ): string {
45
+ const param = family.trim().replace(/ /g, '+');
46
+ // display=swap so the brand font always applies once it arrives, even
47
+ // after first paint (optional was tried and skipped cold loads
48
+ // entirely). With resolveGoogleFontCss() inlining the @font-face
49
+ // rules and preloading the woff2 files, the font usually beats first
50
+ // paint and swap never becomes visible.
51
+ return `https://fonts.googleapis.com/css2?family=${param}:${axes}&display=swap`;
52
+ }
53
+
54
+ /**
55
+ * The stylesheet URLs a storefront needs for its font settings
56
+ * (resolved theme settings: type_body_font/_google etc.).
57
+ */
58
+ export function googleFontsHrefsFromSettings(
59
+ settings: Record<string, unknown>,
60
+ ): string[] {
61
+ const str = (key: string) =>
62
+ typeof settings[key] === 'string' ? (settings[key] as string).trim() : '';
63
+ const hrefs: string[] = [];
64
+ const bodyGoogle = str('type_body_google');
65
+ const headingGoogle = str('type_heading_google');
66
+ const monoGoogle = str('type_mono_google');
67
+ hrefs.push(
68
+ googleFontsHref(
69
+ bodyGoogle || parseFontHandle(str('type_body_font') || undefined).family,
70
+ ),
71
+ );
72
+ const heading =
73
+ headingGoogle || parseFontHandle(str('type_heading_font') || undefined).family;
74
+ if (!hrefs[0].includes(heading.replace(/ /g, '+'))) {
75
+ hrefs.push(googleFontsHref(heading, 'wght@400;500;600;700'));
76
+ }
77
+ if (monoGoogle) hrefs.push(googleFontsHref(monoGoogle, 'wght@400;500'));
78
+ return hrefs;
79
+ }
80
+
81
+ interface FontSchemaSource {
82
+ settingsSchema: Array<{settings?: Array<Record<string, unknown>>}>;
83
+ settingsData: {
84
+ current?: string | Record<string, unknown>;
85
+ presets?: Record<string, Record<string, unknown>>;
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Resolve theme settings (schema defaults + current preset + app
91
+ * override) outside the react engine — for server components that
92
+ * cannot use installTheme()'s store. Same overlay rules as the engine.
93
+ */
94
+ export function fontSettingsFromSchema(
95
+ schema: FontSchemaSource,
96
+ override?: Record<string, unknown>,
97
+ ): Record<string, unknown> {
98
+ const defaults: Record<string, unknown> = {};
99
+ for (const group of schema.settingsSchema) {
100
+ for (const setting of group.settings ?? []) {
101
+ const id = setting.id as string | undefined;
102
+ if (!id || setting.type === 'color_scheme_group') continue;
103
+ if ('default' in setting) defaults[id] = setting.default;
104
+ }
105
+ }
106
+ const current =
107
+ typeof schema.settingsData.current === 'string'
108
+ ? (schema.settingsData.presets?.[schema.settingsData.current] ?? {})
109
+ : (schema.settingsData.current ?? {});
110
+ return {...defaults, ...current, ...override};
111
+ }
112
+
113
+ export interface ResolvedGoogleFontCss {
114
+ /** The @font-face rules, ready to inline in a <style>. */
115
+ css: string;
116
+ /** Latin-subset woff2 URLs worth preloading (capped). */
117
+ preloadUrls: string[];
118
+ }
119
+
120
+ // A woff2-capable UA so Google serves unicode-range-subset woff2 CSS
121
+ // (the default CI/node UA gets legacy TTF without subsets).
122
+ const WOFF2_UA =
123
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
124
+
125
+ interface FetchedCss {
126
+ css: string;
127
+ latinUrls: string[];
128
+ }
129
+
130
+ const cssCache = new Map<string, Promise<FetchedCss | null>>();
131
+
132
+ async function fetchGoogleCss(href: string): Promise<FetchedCss | null> {
133
+ try {
134
+ let signal: AbortSignal | undefined;
135
+ try {
136
+ signal = AbortSignal.timeout(4000);
137
+ } catch {
138
+ signal = undefined;
139
+ }
140
+ const response = await fetch(href, {
141
+ headers: {'user-agent': WOFF2_UA, accept: 'text/css,*/*;q=0.1'},
142
+ signal,
143
+ });
144
+ if (!response.ok) return null;
145
+ const css = await response.text();
146
+ // Preload only the latin subset — above-the-fold text is latin, and
147
+ // extra font preloads compete with the LCP image for bandwidth.
148
+ const latinUrls: string[] = [];
149
+ const facePattern =
150
+ /\/\*\s*latin\s*\*\/\s*@font-face\s*\{[^}]*?url\((https:[^)\s]+\.woff2)\)/g;
151
+ let match: RegExpExecArray | null;
152
+ while ((match = facePattern.exec(css))) latinUrls.push(match[1]);
153
+ return {css, latinUrls};
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Fetch + cache the Google Fonts CSS for the given stylesheet hrefs.
161
+ * Returns null when any fetch fails, so callers fall back to
162
+ * <CssVariables/>' async stylesheet loader. Failures are not cached;
163
+ * the next request retries.
164
+ */
165
+ export async function resolveGoogleFontCss(
166
+ hrefs: string[],
167
+ ): Promise<ResolvedGoogleFontCss | null> {
168
+ const parts = await Promise.all(
169
+ hrefs.map((href) => {
170
+ let pending = cssCache.get(href);
171
+ if (!pending) {
172
+ pending = fetchGoogleCss(href);
173
+ cssCache.set(href, pending);
174
+ void pending.then((value) => {
175
+ if (value === null) cssCache.delete(href);
176
+ });
177
+ }
178
+ return pending;
179
+ }),
180
+ );
181
+ if (parts.some((part) => part === null)) return null;
182
+ const css = parts.map((part) => part!.css).join('\n');
183
+ const preloadUrls = [...new Set(parts.flatMap((part) => part!.latinUrls))].slice(
184
+ 0,
185
+ 4,
186
+ );
187
+ return {css, preloadUrls};
188
+ }
@@ -9,3 +9,4 @@ export * from './events';
9
9
  export * from './countdown';
10
10
  export * from './facets';
11
11
  export * from './product';
12
+ export * from './google-fonts';
@@ -9,7 +9,11 @@
9
9
  */
10
10
  import {useMemo} from 'react';
11
11
  import {colorSchemes, themeSettings} from './settings';
12
- import {googleFontsHref, parseFontHandle} from './fonts';
12
+ import {
13
+ googleFontsHrefsFromSettings,
14
+ parseFontHandle,
15
+ type ResolvedGoogleFontCss,
16
+ } from '../../commerce/google-fonts.ts';
13
17
 
14
18
  function buildCss(): string {
15
19
  const s = themeSettings;
@@ -103,21 +107,15 @@ function buildCss(): string {
103
107
  return lines.join('\n');
104
108
  }
105
109
 
106
- function fontLinks(): string[] {
107
- const s = themeSettings;
108
- const hrefs: string[] = [];
109
- const bodyGoogle = (s.type_body_google ?? '').trim();
110
- const headingGoogle = (s.type_heading_google ?? '').trim();
111
- const monoGoogle = (s.type_mono_google ?? '').trim();
112
- hrefs.push(
113
- googleFontsHref(bodyGoogle || parseFontHandle(s.type_body_font).family),
110
+ /**
111
+ * The Google Fonts stylesheet URLs for the installed theme's settings.
112
+ * Server code passes these to resolveGoogleFontCss() (commerce) to
113
+ * inline the @font-face rules and preload the woff2 files.
114
+ */
115
+ export function googleFontsHrefs(): string[] {
116
+ return googleFontsHrefsFromSettings(
117
+ themeSettings as unknown as Record<string, unknown>,
114
118
  );
115
- const heading = headingGoogle || parseFontHandle(s.type_heading_font).family;
116
- if (!hrefs[0].includes(heading.replace(/ /g, '+'))) {
117
- hrefs.push(googleFontsHref(heading, 'wght@400;500;600;700'));
118
- }
119
- if (monoGoogle) hrefs.push(googleFontsHref(monoGoogle, 'wght@400;500'));
120
- return hrefs;
121
119
  }
122
120
 
123
121
  // Promotes each preloaded font stylesheet to a real stylesheet by
@@ -134,11 +132,43 @@ const FONT_CSS_LOADER = [
134
132
  '})(l[i])}',
135
133
  ].join('');
136
134
 
137
- export function CssVariables({nonce}: {nonce?: string} = {}) {
135
+ export function CssVariables({
136
+ nonce,
137
+ fonts,
138
+ }: {nonce?: string; fonts?: ResolvedGoogleFontCss | null} = {}) {
138
139
  const {css, links} = useMemo(
139
- () => ({css: buildCss(), links: fontLinks()}),
140
+ () => ({css: buildCss(), links: googleFontsHrefs()}),
140
141
  [],
141
142
  );
143
+
144
+ // Server-resolved fonts (the next/font pattern): @font-face rules are
145
+ // inlined and the latin woff2 files preloaded, so the font downloads
146
+ // with the HTML and usually beats first paint — no async stylesheet,
147
+ // no FOUT, no request to fonts.googleapis.com at all.
148
+ if (fonts) {
149
+ return (
150
+ <>
151
+ <link
152
+ rel="preconnect"
153
+ href="https://fonts.gstatic.com"
154
+ crossOrigin="anonymous"
155
+ />
156
+ {fonts.preloadUrls.map((href) => (
157
+ <link
158
+ key={href}
159
+ rel="preload"
160
+ as="font"
161
+ type="font/woff2"
162
+ href={href}
163
+ crossOrigin="anonymous"
164
+ />
165
+ ))}
166
+ <style dangerouslySetInnerHTML={{__html: fonts.css}} />
167
+ <style dangerouslySetInnerHTML={{__html: css}} />
168
+ </>
169
+ );
170
+ }
171
+
142
172
  return (
143
173
  <>
144
174
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -1,47 +1,10 @@
1
1
  /**
2
- * Font resolution for the mirror. The Liquid theme uses Shopify's
3
- * font_picker (handles like "work_sans_n4") plus optional Google-font
4
- * override text settings. Outside Shopify's font CDN we resolve picker
5
- * handles to the same family served from Google Fonts, keeping the
6
- * css-variables contract (--font-body--family etc.) identical.
2
+ * Font resolution moved to commerce/google-fonts.ts so server
3
+ * components (which cannot import the react entries) can share it.
4
+ * Re-exported here to keep the react API stable.
7
5
  */
8
-
9
- export interface ResolvedFont {
10
- family: string;
11
- weight: number;
12
- style: 'normal' | 'italic';
13
- }
14
-
15
- /** Parse a Shopify font handle: "work_sans_n4" → Work Sans / 400 / normal. */
16
- export function parseFontHandle(handle: string | undefined): ResolvedFont {
17
- const fallback: ResolvedFont = {
18
- family: 'Work Sans',
19
- weight: 400,
20
- style: 'normal',
21
- };
22
- if (!handle) return fallback;
23
- const match = /^(.*)_([nib])(\d)$/.exec(handle);
24
- if (!match) return fallback;
25
- const family = match[1]
26
- .split('_')
27
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
28
- .join(' ');
29
- return {
30
- family,
31
- weight: Number(match[3]) * 100,
32
- style: match[2] === 'i' ? 'italic' : 'normal',
33
- };
34
- }
35
-
36
- export function googleFontsHref(
37
- family: string,
38
- axes = 'ital,wght@0,400;0,500;0,700;1,400',
39
- ): string {
40
- const param = family.trim().replace(/ /g, '+');
41
- // display=optional: the font never swaps in after first paint, so a
42
- // late-arriving font file can't reflow text (zero font CLS). With the
43
- // stylesheet loaded async (CssVariables), swap would otherwise shift
44
- // layout on every cold load; optional shows the fallback first visit
45
- // and the brand font from the cache on later views.
46
- return `https://fonts.googleapis.com/css2?family=${param}:${axes}&display=optional`;
47
- }
6
+ export {
7
+ googleFontsHref,
8
+ parseFontHandle,
9
+ type ResolvedFont,
10
+ } from '../../commerce/google-fonts.ts';
@@ -38,7 +38,7 @@ export {
38
38
  useSectionData,
39
39
  useTemplateScope,
40
40
  } from './engine/context';
41
- export {CssVariables} from './engine/CssVariables';
41
+ export {CssVariables, googleFontsHrefs} from './engine/CssVariables';
42
42
  export {parseFontHandle, googleFontsHref} from './engine/fonts';
43
43
  export {
44
44
  settingImageUrl,