@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.
- package/dist/commerce/google-fonts.d.ts +54 -0
- package/dist/commerce/google-fonts.js +141 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce/index.js +1 -0
- package/dist/react/engine/CssVariables.d.ts +9 -1
- package/dist/react/engine/CssVariables.js +17 -17
- package/dist/react/engine/fonts.d.ts +4 -13
- package/dist/react/engine/fonts.js +4 -36
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/package.json +1 -1
- package/src/commerce/google-fonts.ts +188 -0
- package/src/commerce/index.ts +1 -0
- package/src/react/engine/CssVariables.tsx +47 -17
- package/src/react/engine/fonts.ts +8 -45
- package/src/react/index.ts +1 -1
|
@@ -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
|
+
}
|
package/dist/commerce/index.d.ts
CHANGED
package/dist/commerce/index.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
|
|
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 {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 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";
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/react/index.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/commerce/index.ts
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import {useMemo} from 'react';
|
|
11
11
|
import {colorSchemes, themeSettings} from './settings';
|
|
12
|
-
import {
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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({
|
|
135
|
+
export function CssVariables({
|
|
136
|
+
nonce,
|
|
137
|
+
fonts,
|
|
138
|
+
}: {nonce?: string; fonts?: ResolvedGoogleFontCss | null} = {}) {
|
|
138
139
|
const {css, links} = useMemo(
|
|
139
|
-
() => ({css: buildCss(), links:
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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';
|
package/src/react/index.ts
CHANGED
|
@@ -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,
|