@akinon/pz-theme 2.0.105-rc.0 → 2.0.105

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.
@@ -12,6 +12,7 @@ import {
12
12
  MOBILE_MEDIA_QUERY_COMPACT,
13
13
  TABLET_MEDIA_QUERY_COMPACT
14
14
  } from '../utils/breakpoints';
15
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
15
16
  import ThemeBlock, { Block } from '../theme-block';
16
17
  import { hasItemTemplate, ItemTemplateList } from './item-template';
17
18
  import { BlockRendererProps } from './block-renderer-registry';
@@ -945,43 +946,50 @@ const ProductGridBlock = ({
945
946
  {...partAttrs('list')}
946
947
  style={{ display: 'grid', gap: 'var(--product-grid-gap, 16px)' }}
947
948
  >
948
- <style>
949
- {`[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
950
- buildInsertionRules(gridId, placedInsertions, baseColumns, currentPage) +
951
- `${MOBILE_MEDIA_QUERY_COMPACT}{[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}` +
952
- // Inside the same query as the mobile template, so a tile is measured
953
- // against the column count it actually sits in.
954
- buildInsertionRules(
955
- gridId,
956
- placedInsertions,
957
- columnsMobile,
958
- currentPage
959
- ) +
960
- `}` +
961
- // After the mobile query and only when a tablet count was authored,
962
- // so a grid without one emits the two rules it always emitted.
963
- (columnsTablet !== null
964
- ? `${TABLET_MEDIA_QUERY_COMPACT}{[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsTablet},minmax(0,1fr));}` +
949
+ {/* Verbatim, not a text child: React 18's server renderer would escape
950
+ the selectors' quotes (see `css-safety.ts`). */}
951
+ <style
952
+ dangerouslySetInnerHTML={{
953
+ __html: neutralizeStyleTagBreakout(
954
+ `[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
955
+ buildInsertionRules(gridId, placedInsertions, baseColumns, currentPage) +
956
+ `${MOBILE_MEDIA_QUERY_COMPACT}{[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}` +
957
+ // Inside the same query as the mobile template, so a tile is
958
+ // measured against the column count it actually sits in.
965
959
  buildInsertionRules(
966
960
  gridId,
967
961
  placedInsertions,
968
- columnsTablet,
962
+ columnsMobile,
969
963
  currentPage
970
964
  ) +
971
- `}`
972
- : '') +
973
- // Last and unconditional, so an explicit choice outranks both the
974
- // authored count and the mobile override above it.
975
- (chosenColumns !== null
976
- ? `[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${chosenColumns},minmax(0,1fr));}` +
977
- buildInsertionRules(
978
- gridId,
979
- placedInsertions,
980
- chosenColumns,
981
- currentPage
982
- )
983
- : '')}
984
- </style>
965
+ `}` +
966
+ // After the mobile query and only when a tablet count was
967
+ // authored, so a grid without one emits the two rules it always
968
+ // emitted.
969
+ (columnsTablet !== null
970
+ ? `${TABLET_MEDIA_QUERY_COMPACT}{[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${columnsTablet},minmax(0,1fr));}` +
971
+ buildInsertionRules(
972
+ gridId,
973
+ placedInsertions,
974
+ columnsTablet,
975
+ currentPage
976
+ ) +
977
+ `}`
978
+ : '') +
979
+ // Last and unconditional, so an explicit choice outranks both the
980
+ // authored count and the mobile override above it.
981
+ (chosenColumns !== null
982
+ ? `[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${chosenColumns},minmax(0,1fr));}` +
983
+ buildInsertionRules(
984
+ gridId,
985
+ placedInsertions,
986
+ chosenColumns,
987
+ currentPage
988
+ )
989
+ : '')
990
+ )
991
+ }}
992
+ />
985
993
 
986
994
  {/* A designer who marked a child as the row layout gets their own card
987
995
  repeated per product; otherwise the built-in card above renders. */}
@@ -17,6 +17,7 @@ import {
17
17
  buildCommerceItem,
18
18
  emitCommerceEvent
19
19
  } from '../utils/analytics';
20
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
20
21
  import { BlockRendererProps } from './block-renderer-registry';
21
22
  import { hasItemTemplate, ItemTemplateList } from './item-template';
22
23
  import { DESIGNER_SAMPLE_PRODUCT } from './item-template-utils';
@@ -1160,14 +1161,20 @@ const ProductRecommendationsBlock = ({
1160
1161
  // overlay exists at all, so a strip without quick view is untouched.
1161
1162
  tabIndex={showQuickView ? -1 : undefined}
1162
1163
  >
1163
- <style>
1164
- {`[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
1165
- columnsDesktop
1166
- )};}` +
1167
- `@media (max-width:1023px){[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
1168
- columnsMobile
1169
- )};}}`}
1170
- </style>
1164
+ {/* Verbatim, not a text child: React 18's server renderer would escape
1165
+ the selector's quotes (see `css-safety.ts`). */}
1166
+ <style
1167
+ dangerouslySetInnerHTML={{
1168
+ __html: neutralizeStyleTagBreakout(
1169
+ `[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
1170
+ columnsDesktop
1171
+ )};}` +
1172
+ `@media (max-width:1023px){[data-product-recommendations-id="${trackId}"] [data-product-recommendations-track]{grid-auto-columns:${cardWidth(
1173
+ columnsMobile
1174
+ )};}}`
1175
+ )
1176
+ }}
1177
+ />
1171
1178
 
1172
1179
  {(() => {
1173
1180
  const inlineHeader = headerLayout === 'inline';
@@ -14,6 +14,7 @@ import {
14
14
  import { useGetRetailStoreStockMutation } from '@akinon/next/data/client/product';
15
15
  import { useLocalization } from '@akinon/next/hooks';
16
16
  import { getResponsiveValue, partAttrs } from '../utils';
17
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
17
18
  import { useOverlay } from '../hooks/use-overlay';
18
19
  import { formatDecimal } from './commerce-format';
19
20
  import {
@@ -1060,10 +1061,16 @@ const StoreLocatorBlock = ({
1060
1061
  )}
1061
1062
 
1062
1063
  {isGrid && (
1063
- <style>
1064
- {`[data-store-locator-id="${listId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
1065
- `@media (max-width:1023px){[data-store-locator-id="${listId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}}`}
1066
- </style>
1064
+ // Verbatim, not a text child: React 18's server renderer would escape
1065
+ // the selector's quotes (see `css-safety.ts`).
1066
+ <style
1067
+ dangerouslySetInnerHTML={{
1068
+ __html: neutralizeStyleTagBreakout(
1069
+ `[data-store-locator-id="${listId}"]{grid-template-columns:repeat(${baseColumns},minmax(0,1fr));}` +
1070
+ `@media (max-width:1023px){[data-store-locator-id="${listId}"]{grid-template-columns:repeat(${columnsMobile},minmax(0,1fr));}}`
1071
+ )
1072
+ }}
1073
+ />
1067
1074
  )}
1068
1075
 
1069
1076
  <ListTag
@@ -0,0 +1,85 @@
1
+ import * as React from 'react';
2
+ import * as ReactDOM from 'react-dom';
3
+
4
+ import {
5
+ buildGoogleFontLinkId,
6
+ buildGoogleFontStylesheetUrl,
7
+ collectGoogleFontFamilies,
8
+ GOOGLE_FONTS_CSS_ORIGIN,
9
+ GOOGLE_FONTS_FILES_ORIGIN
10
+ } from '../utils/google-fonts';
11
+
12
+ /**
13
+ * The Google families a composition uses, linked from the server render.
14
+ *
15
+ * Section CSS names these families with no fallback stack, and their
16
+ * stylesheets used to be appended only by client effects — so until hydration
17
+ * the page painted in the browser's default serif and swapped the real faces
18
+ * in seconds later. Linked here, the stylesheets are part of the document
19
+ * before first paint: the move ZERO-5004 made for uploaded faces
20
+ * (theme-fonts-link.tsx), applied to Google's.
21
+ *
22
+ * Rendered by every placeholder. React hoists a `precedence` stylesheet into
23
+ * the head and keeps one per href, so a family the header, body and footer all
24
+ * use is still requested once.
25
+ */
26
+ export const THEME_GOOGLE_FONTS_PRECEDENCE = 'pz-theme-google-fonts';
27
+
28
+ type PreconnectFn = (href: string, options?: { crossOrigin?: string }) => void;
29
+
30
+ /**
31
+ * Spread onto the link rather than written as a JSX attribute. Brands
32
+ * transpile this file from `src` and type-check it against their own
33
+ * @types/react; 18.x does not declare `precedence`, so `next build` would fail
34
+ * on a prop the renderer itself accepts. The rendered element is identical.
35
+ */
36
+ const HOIST_AS_RESOURCE: Record<string, string> = {
37
+ precedence: THEME_GOOGLE_FONTS_PRECEDENCE
38
+ };
39
+
40
+ export const ThemeGoogleFontsLinks = ({
41
+ sections,
42
+ settings
43
+ }: {
44
+ sections: ReadonlyArray<{ styles?: unknown; blocks?: unknown }> | null;
45
+ settings: Record<string, unknown> | null;
46
+ }) => {
47
+ const families = collectGoogleFontFamilies(sections, settings);
48
+
49
+ if (families.length === 0) return null;
50
+
51
+ // Hints go through React's resource API rather than as <link> elements:
52
+ // React dedupes them across placeholders and emits them ahead of the
53
+ // stylesheets, where plain preconnect elements repeat once per placeholder.
54
+ // Face files are CORS fetches, so the gstatic connection must be anonymous
55
+ // or the browser cannot reuse it. Every React that hoists `precedence`
56
+ // (Next's vendored canary, React 19) has this API.
57
+ const preconnect = (ReactDOM as unknown as { preconnect?: PreconnectFn })
58
+ .preconnect;
59
+
60
+ if (typeof preconnect === 'function') {
61
+ preconnect(GOOGLE_FONTS_CSS_ORIGIN);
62
+ preconnect(GOOGLE_FONTS_FILES_ORIGIN, { crossOrigin: 'anonymous' });
63
+ }
64
+
65
+ return (
66
+ <>
67
+ {families.map((family) => {
68
+ const id = buildGoogleFontLinkId(family);
69
+
70
+ return (
71
+ <link
72
+ key={id}
73
+ rel="stylesheet"
74
+ href={buildGoogleFontStylesheetUrl(family)}
75
+ id={id}
76
+ {...HOIST_AS_RESOURCE}
77
+ // Marks the links the server owns, the way `data-pz-theme-fonts`
78
+ // marks the uploaded-face stylesheet.
79
+ data-pz-google-font="true"
80
+ />
81
+ );
82
+ })}
83
+ </>
84
+ );
85
+ };
package/src/index.ts CHANGED
@@ -36,6 +36,17 @@ export {
36
36
  THEME_FONTS_CSS_PATH
37
37
  } from './chrome/theme-fonts-link';
38
38
  export { buildCustomFontFaceCss } from './utils/custom-fonts';
39
+ export {
40
+ ThemeGoogleFontsLinks,
41
+ THEME_GOOGLE_FONTS_PRECEDENCE
42
+ } from './chrome/theme-google-fonts-links';
43
+ export {
44
+ buildGoogleFontLinkId,
45
+ buildGoogleFontStylesheetUrl,
46
+ collectGoogleFontFamilies,
47
+ loadGoogleFontStylesheet,
48
+ resolveGoogleFontFamily
49
+ } from './utils/google-fonts';
39
50
  export {
40
51
  armHistoryRestoreGuard,
41
52
  buildRestoreGuardScript,
@@ -33,6 +33,10 @@ import { parseEditorMessage, postToEditor } from './editor-bridge';
33
33
  import { useBrowseMode } from './browse-mode';
34
34
  import { neutralizeStyleTagBreakout } from './utils/css-safety';
35
35
  import { buildCustomFontFaceCss } from './utils/custom-fonts';
36
+ import {
37
+ collectGoogleFontFamilies,
38
+ loadGoogleFontStylesheet
39
+ } from './utils/google-fonts';
36
40
 
37
41
  // Re-exported for the tests and for a brand that builds the faces itself; it
38
42
  // lives in ./utils/custom-fonts because a server route has to call it too and
@@ -200,41 +204,6 @@ export const buildThemeTypographyCss = (
200
204
  return declarations.length ? `:root{${declarations.join(';')}}` : '';
201
205
  };
202
206
 
203
- // Helper function to load Google Font
204
- const loadGoogleFont = (fontFamily: string, fontWeight?: string) => {
205
- if (!fontFamily || fontFamily === 'inherit') return;
206
-
207
- const cleanFontFamily = fontFamily.replace(/['"]/g, '').trim();
208
-
209
- // Skip system fonts
210
- const systemFonts = [
211
- 'Arial',
212
- 'Helvetica',
213
- 'Times New Roman',
214
- 'Georgia',
215
- 'Courier New',
216
- 'Verdana'
217
- ];
218
- if (systemFonts.includes(cleanFontFamily)) return;
219
-
220
- const weights = fontWeight || '100;300;400;500;600;700;800;900';
221
- const fontParam = `${cleanFontFamily.replace(/\s+/g, '+')}:wght@${weights}`;
222
- const fontUrl = `https://fonts.googleapis.com/css2?family=${fontParam}&display=swap`;
223
-
224
- const linkId = `google-font-${cleanFontFamily
225
- .replace(/\s+/g, '-')
226
- .toLowerCase()}`;
227
-
228
- // Check if already loaded
229
- if (document.getElementById(linkId)) return;
230
-
231
- const link = document.createElement('link');
232
- link.id = linkId;
233
- link.href = fontUrl;
234
- link.rel = 'stylesheet';
235
- document.head.appendChild(link);
236
- };
237
-
238
207
  interface ThemePlaceholderWrapperProps {
239
208
  slug: string;
240
209
  initialSections: Section[];
@@ -693,34 +662,17 @@ export default function ThemePlaceholderWrapper({
693
662
  });
694
663
  }, [resolvedPageContext, isDesigner]);
695
664
 
696
- // Load fonts from initial sections on mount (for production site, not iframe)
665
+ // The composition's Google fonts. ThemePlaceholder already links them from
666
+ // the server (chrome/theme-google-fonts-links.tsx) and the loader stands down
667
+ // for those; this covers a wrapper mounted some other way. Same collector as
668
+ // the server, so the two cannot disagree about which families a page needs.
697
669
  useEffect(() => {
698
670
  if (typeof window === 'undefined') return;
699
671
 
700
- // Helper to extract and load fonts from blocks
701
- const loadFontsFromBlocks = (blocks: Section['blocks']) => {
702
- if (!blocks) return;
703
-
704
- blocks.forEach((block) => {
705
- const fontFamily =
706
- block.styles?.['font-family']?.desktop ||
707
- block.styles?.['font-family']?.mobile;
708
- if (fontFamily && fontFamily !== 'inherit') {
709
- loadGoogleFont(fontFamily);
710
- }
711
-
712
- // Recursively check nested blocks
713
- if (block.blocks) {
714
- loadFontsFromBlocks(block.blocks as Section['blocks']);
715
- }
716
- });
717
- };
718
-
719
- // Load fonts from all initial sections
720
- initialSections.forEach((section) => {
721
- loadFontsFromBlocks(section.blocks);
722
- });
723
- }, [initialSections]);
672
+ collectGoogleFontFamilies(initialSections, initialThemeSettings).forEach(
673
+ (family) => loadGoogleFontStylesheet(family)
674
+ );
675
+ }, [initialSections, initialThemeSettings]);
724
676
 
725
677
  // Handle responsive breakpoint detection when not in iframe (e.g. production site)
726
678
  useEffect(() => {
@@ -836,51 +788,19 @@ export default function ThemePlaceholderWrapper({
836
788
  setThemeSettings(theme.settings);
837
789
  }
838
790
 
839
- // Load fonts from typography settings
840
- if (
841
- theme?.settings?.typography &&
842
- Array.isArray(theme.settings.typography)
843
- ) {
844
- const uniqueFonts = new Set<string>();
845
- theme.settings.typography.forEach(
846
- (variant: { fontFamily?: string }) => {
847
- if (variant.fontFamily && variant.fontFamily !== 'inherit') {
848
- uniqueFonts.add(variant.fontFamily);
849
- }
850
- }
851
- );
852
- uniqueFonts.forEach((font) => loadGoogleFont(font));
853
- }
854
-
855
- // Load fonts from block styles (for text blocks with custom fonts)
856
- if (theme?.placeholders) {
857
- // Recursive function to load fonts from nested blocks
858
- const loadFontsFromBlocks = (blocks: any[]) => {
859
- blocks?.forEach((block) => {
860
- const fontFamily =
861
- block.styles?.['font-family']?.desktop ||
862
- block.styles?.['font-family']?.mobile;
863
- if (fontFamily && fontFamily !== 'inherit') {
864
- loadGoogleFont(fontFamily);
865
- }
866
- // Recursively check nested blocks
867
- if (block.blocks && block.blocks.length > 0) {
868
- loadFontsFromBlocks(block.blocks);
869
- }
870
- });
871
- };
872
-
873
- theme.placeholders.forEach(
874
- (p: {
875
- sections?: Array<{
876
- blocks?: Array<any>;
877
- }>;
878
- }) => {
879
- p.sections?.forEach((section) => {
880
- loadFontsFromBlocks(section.blocks || []);
881
- });
882
- }
883
- );
791
+ // Google fonts the incoming theme uses: its typography variants and
792
+ // every placeholder's section and block styles.
793
+ if (theme?.settings || theme?.placeholders) {
794
+ const incomingSections = Array.isArray(theme?.placeholders)
795
+ ? theme.placeholders.flatMap(
796
+ (p: { sections?: unknown[] }) => p?.sections ?? []
797
+ )
798
+ : [];
799
+
800
+ collectGoogleFontFamilies(
801
+ incomingSections,
802
+ theme?.settings
803
+ ).forEach((family) => loadGoogleFontStylesheet(family));
884
804
  }
885
805
 
886
806
  if (theme?.placeholders) {
@@ -966,7 +886,7 @@ export default function ThemePlaceholderWrapper({
966
886
  case 'LOAD_FONT': {
967
887
  const { fontFamily, fontWeight } = messageData || {};
968
888
  if (fontFamily) {
969
- loadGoogleFont(fontFamily, fontWeight);
889
+ loadGoogleFontStylesheet(fontFamily, fontWeight);
970
890
  }
971
891
  break;
972
892
  }
@@ -2,6 +2,7 @@ import 'server-only';
2
2
 
3
3
  import ThemePlaceholderWrapper from './theme-placeholder-wrapper';
4
4
  import { ThemeFontsLink } from './chrome/theme-fonts-link';
5
+ import { ThemeGoogleFontsLinks } from './chrome/theme-google-fonts-links';
5
6
  import { loadPlaceholderSections } from './load-placeholder-sections';
6
7
  import { generateThemeCSS } from './utils';
7
8
 
@@ -26,6 +27,7 @@ export default async function ThemePlaceholder({
26
27
  return (
27
28
  <>
28
29
  <ThemeFontsLink settings={themeSettings} />
30
+ <ThemeGoogleFontsLinks sections={sections} settings={themeSettings} />
29
31
  {themeCSS && <style dangerouslySetInnerHTML={{ __html: themeCSS }} />}
30
32
  <ThemePlaceholderWrapper
31
33
  slug={slug}