@akinon/pz-theme 2.0.98-rc.0 → 2.0.99-beta.0

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,52 @@
1
+ import 'server-only';
2
+
3
+ import { getThemeConfig } from './theme-config-source';
4
+ import { buildCustomFontFaceCss } from '../utils/custom-fonts';
5
+
6
+ /**
7
+ * The route body behind THEME_FONTS_CSS_PATH: the theme's uploaded faces as a
8
+ * real stylesheet.
9
+ *
10
+ * Mount it in the brand app as `app/api/theme-fonts/route.ts`:
11
+ *
12
+ * ```ts
13
+ * import { themeFontsCssResponse } from '@akinon/pz-theme/src/chrome/theme-fonts-css';
14
+ *
15
+ * export const GET = () => themeFontsCssResponse();
16
+ * ```
17
+ *
18
+ * Answered with a weak ETag over the CSS so a designer's upload invalidates it
19
+ * immediately while a shopper browsing the store revalidates in one round trip
20
+ * instead of re-downloading the faces per page.
21
+ */
22
+ export const themeFontsCssResponse = async (
23
+ request?: Request
24
+ ): Promise<Response> => {
25
+ const { settings } = await getThemeConfig();
26
+ const css = buildCustomFontFaceCss(settings);
27
+
28
+ // A cheap, order-stable digest — enough to tell one upload from the next.
29
+ let hash = 0;
30
+ for (let index = 0; index < css.length; index += 1) {
31
+ hash = (hash * 31 + css.charCodeAt(index)) | 0;
32
+ }
33
+ const etag = `W/"pz-fonts-${css.length.toString(36)}-${(hash >>> 0).toString(
34
+ 36
35
+ )}"`;
36
+
37
+ const headers = {
38
+ 'Content-Type': 'text/css; charset=utf-8',
39
+ // Revalidated on every navigation, served from cache when unchanged: the
40
+ // faces are large and change only when the designer uploads. An EMPTY
41
+ // answer is never cached — it means the theme has no uploads yet, or that
42
+ // the widget read failed, and neither should stick to the browser.
43
+ 'Cache-Control': css ? 'public, max-age=0, must-revalidate' : 'no-store',
44
+ ETag: etag
45
+ };
46
+
47
+ if (request?.headers.get('if-none-match') === etag) {
48
+ return new Response(null, { status: 304, headers });
49
+ }
50
+
51
+ return new Response(css, { status: 200, headers });
52
+ };
@@ -0,0 +1,44 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Where a brand mounts the theme's font stylesheet.
5
+ *
6
+ * The faces a designer uploads are base64 blobs in the theme settings, and they
7
+ * used to reach the browser only through an effect that appended `<style>` tags
8
+ * on mount. That made the brand's typography depend on hydration having run,
9
+ * and hydration does not always run: a history navigation can be served a
10
+ * cached copy of the document whose React tree never comes up, and the page
11
+ * then paints every word in the fallback face (ZERO-5004).
12
+ *
13
+ * A stylesheet has neither problem. It is part of the document, the browser
14
+ * fetches it before first paint, and it is cached across pages instead of being
15
+ * serialized into every response.
16
+ */
17
+ export const THEME_FONTS_CSS_PATH = '/api/theme-fonts';
18
+
19
+ /**
20
+ * Rendered by every placeholder; React keeps ONE of them, matched by `href`,
21
+ * and hoists it into the head.
22
+ */
23
+ export const ThemeFontsLink = ({
24
+ settings
25
+ }: {
26
+ settings: Record<string, unknown> | null;
27
+ }) => {
28
+ const fonts = settings?.customFonts;
29
+
30
+ // Nothing uploaded: no request to make. A theme that adds its first face
31
+ // later gets the link on that page's next render.
32
+ if (!Array.isArray(fonts) || fonts.length === 0) return null;
33
+
34
+ return (
35
+ <link
36
+ rel="stylesheet"
37
+ href={THEME_FONTS_CSS_PATH}
38
+ // Read by the client font effect, which stands down when the document
39
+ // already links the faces.
40
+ data-pz-theme-fonts="true"
41
+ precedence="pz-theme-fonts"
42
+ />
43
+ );
44
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * A back/forward navigation can be served the document out of the HTTP cache
3
+ * (Chrome does this even for `Cache-Control: no-cache`), and the copy it
4
+ * replays does not always finish hydrating: the markup paints, no effect ever
5
+ * runs, and everything the client owns is missing — the uploaded fonts, the
6
+ * theme colours, every interaction. A page whose body is client-rendered (the
7
+ * 404 shell, a data-driven composition) comes back blank (ZERO-5004).
8
+ *
9
+ * Nothing inside React can notice that, because React is exactly what did not
10
+ * start. So the check ships as plain markup: on a `back_forward` entry it waits
11
+ * for the mount below to announce itself and reloads once if that never
12
+ * happens. `sessionStorage` keeps it to one reload per history entry, and a
13
+ * normal navigation clears the mark so a later restore can still recover.
14
+ */
15
+ export const RESTORE_GUARD_TIMEOUT_MS = 1500;
16
+
17
+ /**
18
+ * Arms the check. Self-contained on purpose: it is also shipped as a string
19
+ * (see `buildRestoreGuardScript`), so it may not reference anything in this
20
+ * module's scope.
21
+ *
22
+ * The natural home is the brand's `instrumentation-client.ts`, which Next
23
+ * bundles into the framework scripts:
24
+ *
25
+ * ```ts
26
+ * import { armHistoryRestoreGuard } from '@akinon/pz-theme/src/chrome/theme-restore-guard';
27
+ *
28
+ * armHistoryRestoreGuard();
29
+ * ```
30
+ *
31
+ * A `<script>` rendered by a component of ours would not do. On an error or 404
32
+ * document React streams the whole body — head scripts included — through the
33
+ * RSC payload, so the tag exists only as payload text that the restored copy
34
+ * never executes. That is the document that came back blank, and the framework
35
+ * bundles are the only code the browser still runs there.
36
+ */
37
+ /**
38
+ * Whether the restored document has anything on screen to lose.
39
+ *
40
+ * Two very different restores, and waiting the same grace period for both is
41
+ * what made the fix visible: a page that came back with its server markup looks
42
+ * right while it waits, but the 404 and error shells have no markup at all —
43
+ * their body only exists once React runs — so the visitor watched a white
44
+ * screen for as long as the timer.
45
+ */
46
+ export const isEmptyRestoredDocument = (doc: Document = document): boolean => {
47
+ if (doc.getElementById('__next_error__')) return true;
48
+
49
+ const body = doc.body;
50
+ if (!body) return true;
51
+
52
+ // `innerText` is layout-aware in a browser and undefined in some test
53
+ // realms; the text content is the honest fallback.
54
+ const text = body.innerText ?? body.textContent ?? '';
55
+
56
+ return text.trim().length === 0;
57
+ };
58
+
59
+ export const armHistoryRestoreGuard = (timeoutMs: number = 1500) => {
60
+ try {
61
+ const w = window as unknown as Record<string, unknown>;
62
+ if (w.__pzRestoreGuardArmed) return;
63
+ w.__pzRestoreGuardArmed = 1;
64
+
65
+ // Read defensively: not every environment exposes the performance entry
66
+ // API on `window` (a jsdom test realm is one), and without the navigation
67
+ // type there is nothing to decide.
68
+ const perf =
69
+ (window as unknown as { performance?: Performance }).performance ??
70
+ (globalThis as unknown as { performance?: Performance }).performance;
71
+ const entry =
72
+ perf && typeof perf.getEntriesByType === 'function'
73
+ ? (perf.getEntriesByType('navigation')[0] as
74
+ | { type?: string }
75
+ | undefined)
76
+ : undefined;
77
+ const key = 'pz-restore-reload:' + window.location.href;
78
+
79
+ // Not a restore: clear the mark so a LATER restore of this entry can still
80
+ // recover.
81
+ if (!entry || entry.type !== 'back_forward') {
82
+ try {
83
+ window.sessionStorage.removeItem(key);
84
+ } catch {
85
+ /* private mode */
86
+ }
87
+ return;
88
+ }
89
+
90
+ try {
91
+ // Already reloaded once for this entry — never loop.
92
+ if (window.sessionStorage.getItem(key)) return;
93
+ } catch {
94
+ /* private mode */
95
+ }
96
+
97
+ const reload = () => {
98
+ if ((window as unknown as Record<string, unknown>).__pzThemeHydrated) {
99
+ return;
100
+ }
101
+
102
+ try {
103
+ window.sessionStorage.setItem(key, '1');
104
+ } catch {
105
+ /* private mode */
106
+ }
107
+
108
+ window.location.reload();
109
+ };
110
+
111
+ // Two very different restores, and waiting the same grace period for both
112
+ // is what made the fix visible: a page that came back with its server
113
+ // markup looks right while it waits, but the 404 and error shells have no
114
+ // markup at all — their body only exists once React runs — so the visitor
115
+ // watched a white screen for as long as the timer. When there is nothing on
116
+ // screen to lose, reload at once.
117
+ const armGrace = () => {
118
+ if (isEmptyRestoredDocument()) {
119
+ reload();
120
+ return;
121
+ }
122
+
123
+ window.setTimeout(reload, timeoutMs);
124
+ };
125
+
126
+ if (document.readyState === 'loading') {
127
+ document.addEventListener('DOMContentLoaded', armGrace, { once: true });
128
+ } else {
129
+ armGrace();
130
+ }
131
+ } catch {
132
+ // A browser without the performance entry API loses the recovery, not the
133
+ // page.
134
+ }
135
+ };
136
+
137
+ /**
138
+ * The same check as an inline script, for a brand that would rather put it in
139
+ * its layout than in `instrumentation-client.ts`. Prefer the latter: a script
140
+ * a component renders is payload text on the very documents that need this.
141
+ */
142
+ export const buildRestoreGuardScript = (
143
+ timeoutMs: number = RESTORE_GUARD_TIMEOUT_MS
144
+ ): string =>
145
+ `(${armHistoryRestoreGuard.toString()})(${Math.max(
146
+ 0,
147
+ Math.trunc(timeoutMs)
148
+ )});`;
package/src/index.ts CHANGED
@@ -4,8 +4,14 @@
4
4
  export { default as ThemePlaceholder } from './theme-placeholder';
5
5
  export { default as ThemeSection } from './theme-section';
6
6
  export { default as ThemeBlock } from './theme-block';
7
- export { ThemeSettingsProvider, useThemeSettingsContext } from './theme-settings-context';
8
- export { ThemePageContextProvider, useThemePageContext } from './theme-page-context';
7
+ export {
8
+ ThemeSettingsProvider,
9
+ useThemeSettingsContext
10
+ } from './theme-settings-context';
11
+ export {
12
+ ThemePageContextProvider,
13
+ useThemePageContext
14
+ } from './theme-page-context';
9
15
  export {
10
16
  registerPlaceholder,
11
17
  unregisterPlaceholder
@@ -25,6 +31,16 @@ export type { Section } from './theme-section';
25
31
  export type { Block } from './theme-block';
26
32
 
27
33
  export { default as ChromeSlot } from './chrome/chrome-slot';
34
+ export {
35
+ ThemeFontsLink,
36
+ THEME_FONTS_CSS_PATH
37
+ } from './chrome/theme-fonts-link';
38
+ export { buildCustomFontFaceCss } from './utils/custom-fonts';
39
+ export {
40
+ armHistoryRestoreGuard,
41
+ buildRestoreGuardScript,
42
+ RESTORE_GUARD_TIMEOUT_MS
43
+ } from './chrome/theme-restore-guard';
28
44
  export { default as ComposableRegion } from './chrome/composable-region';
29
45
  export { ChromeFallback } from './chrome/chrome-fallback';
30
46
  export { CodeOwnedRegion } from './chrome/code-owned-region';
@@ -28,6 +28,26 @@ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
28
28
  import { parseEditorMessage, postToEditor } from './editor-bridge';
29
29
  import { useBrowseMode } from './browse-mode';
30
30
  import { neutralizeStyleTagBreakout } from './utils/css-safety';
31
+ import { buildCustomFontFaceCss } from './utils/custom-fonts';
32
+
33
+ // Re-exported for the tests and for a brand that builds the faces itself; it
34
+ // lives in ./utils/custom-fonts because a server route has to call it too and
35
+ // this module is client-only.
36
+ export { buildCustomFontFaceCss };
37
+ export {
38
+ armHistoryRestoreGuard,
39
+ buildRestoreGuardScript,
40
+ RESTORE_GUARD_TIMEOUT_MS
41
+ } from './chrome/theme-restore-guard';
42
+
43
+ /**
44
+ * Whether this document already links the theme's font stylesheet. When it
45
+ * does, the faces are the browser's business and the client must not inline
46
+ * them a second time.
47
+ */
48
+ const themeFontsLinked = () =>
49
+ typeof document !== 'undefined' &&
50
+ Boolean(document.querySelector('link[data-pz-theme-fonts]'));
31
51
 
32
52
  // One pageshow listener per page, not per placeholder — see the effect below.
33
53
  let bfcacheReloadListenerArmed = false;
@@ -353,10 +373,17 @@ export default function ThemePlaceholderWrapper({
353
373
  // does not reliably survive the freeze, and the page comes back with its
354
374
  // data-driven sections blank until a manual refresh. Reloading once on a
355
375
  // persisted pageshow trades the bfcache speedup for a page that is correct.
376
+ // The editor used to be exempt, on the assumption that a designer would
377
+ // rather keep the frozen canvas than lose it to a reload. What they actually
378
+ // got was a blank canvas after every back/forward (ZERO-5004): the same
379
+ // client-fetched data goes missing, and the editor cannot repaint what the
380
+ // page never re-fetched. A reload recovers by itself — the storefront
381
+ // announces its placeholders on the new document and the editor answers with
382
+ // the theme it holds, so nothing unsaved is lost.
383
+ //
356
384
  // Guarded at module level — a page hosts several placeholders and one
357
385
  // listener is enough.
358
386
  useEffect(() => {
359
- if (isDesigner) return;
360
387
  if (bfcacheReloadListenerArmed) return;
361
388
  bfcacheReloadListenerArmed = true;
362
389
 
@@ -369,7 +396,14 @@ export default function ThemePlaceholderWrapper({
369
396
  window.removeEventListener('pageshow', onPageShow);
370
397
  bfcacheReloadListenerArmed = false;
371
398
  };
372
- }, [isDesigner]);
399
+ }, []);
400
+
401
+ // What the restore guard above waits for: proof that this document's React
402
+ // tree actually came up.
403
+ useEffect(() => {
404
+ (window as unknown as { __pzThemeHydrated?: boolean }).__pzThemeHydrated =
405
+ true;
406
+ }, []);
373
407
  const [dataSources, setDataSources] = useState<any[]>(initialDataSources);
374
408
  const [themeSettings, setThemeSettings] = useState<Record<
375
409
  string,
@@ -486,7 +520,9 @@ export default function ThemePlaceholderWrapper({
486
520
  context.order.items.length > 0
487
521
  ) {
488
522
  const order = context.order;
489
- const totalAmount = Number(String(order.totalAmount ?? '').replace(',', '.'));
523
+ const totalAmount = Number(
524
+ String(order.totalAmount ?? '').replace(',', '.')
525
+ );
490
526
  emitOnce(String(order.number ?? order.id ?? ''), 'purchase', {
491
527
  currency: readItemsCurrency(order),
492
528
  ...(Number.isFinite(totalAmount) ? { value: totalAmount } : null),
@@ -507,7 +543,14 @@ export default function ThemePlaceholderWrapper({
507
543
  }
508
544
  }, [themeSettings]);
509
545
 
510
- // Inject custom fonts from theme settings
546
+ // Inject custom fonts from theme settings.
547
+ //
548
+ // The faces normally arrive as the stylesheet the server linked
549
+ // (chrome/theme-fonts-css.ts), which is what makes them survive a document
550
+ // that never hydrates (ZERO-5004). This is the safety net for the two cases
551
+ // the stylesheet cannot cover: a brand that has not mounted the route, and a
552
+ // theme-config read that failed while answering it. Either way the family is
553
+ // then not declared anywhere, and the check below is what notices.
511
554
  useEffect(() => {
512
555
  if (!themeSettings?.customFonts) return;
513
556
  const customFonts = themeSettings.customFonts as {
@@ -515,9 +558,9 @@ export default function ThemePlaceholderWrapper({
515
558
  sources: { dataUrl: string; format: string }[];
516
559
  }[];
517
560
 
518
- customFonts.forEach((font) => {
561
+ const inject = (font: typeof customFonts[number]) => {
519
562
  if (
520
- !font.name ||
563
+ !font?.name ||
521
564
  !Array.isArray(font.sources) ||
522
565
  font.sources.length === 0
523
566
  )
@@ -526,18 +569,63 @@ export default function ThemePlaceholderWrapper({
526
569
  const styleId = `custom-font-${font.name
527
570
  .replace(/\s+/g, '-')
528
571
  .toLowerCase()}`;
529
- const existing = document.getElementById(styleId);
530
- if (existing) existing.remove(); // Re-inject in case sources changed
531
572
 
532
573
  const src = font.sources
533
574
  .map((s) => `url('${s.dataUrl}') format('${s.format}')`)
534
575
  .join(', ');
535
576
 
577
+ const existing = document.getElementById(styleId);
578
+ if (existing) existing.remove(); // Re-inject in case sources changed
579
+
536
580
  const style = document.createElement('style');
537
581
  style.id = styleId;
538
582
  style.textContent = `@font-face { font-family: '${font.name}'; src: ${src}; font-display: swap; }`;
539
583
  document.head.appendChild(style);
540
- });
584
+ };
585
+
586
+ if (!themeFontsLinked()) {
587
+ customFonts.forEach(inject);
588
+ return;
589
+ }
590
+
591
+ let cancelled = false;
592
+ const injectWhateverIsMissing = () => {
593
+ if (cancelled) return;
594
+
595
+ customFonts.forEach((font) => {
596
+ if (!font?.name) return;
597
+
598
+ // Declared, not loaded: a face the stylesheet carries stays
599
+ // `unloaded` until some text asks for it, and `document.fonts.check`
600
+ // answers false for exactly that state — asking it here re-inlined
601
+ // every face on every page.
602
+ try {
603
+ const declared = Array.from(
604
+ document.fonts as unknown as Iterable<FontFace>
605
+ ).some(
606
+ (face) => face.family?.replace(/^['"]|['"]$/g, '') === font.name
607
+ );
608
+
609
+ if (declared) return;
610
+ } catch {
611
+ // No font API to ask: fall through and inline, which is what this
612
+ // effect did before the stylesheet existed.
613
+ }
614
+
615
+ inject(font);
616
+ });
617
+ };
618
+
619
+ const ready = document.fonts?.ready;
620
+ if (ready && typeof ready.then === 'function') {
621
+ ready.then(injectWhateverIsMissing, injectWhateverIsMissing);
622
+ } else {
623
+ injectWhateverIsMissing();
624
+ }
625
+
626
+ return () => {
627
+ cancelled = true;
628
+ };
541
629
  }, [themeSettings]);
542
630
 
543
631
  useEffect(() => {
@@ -912,18 +1000,20 @@ export default function ThemePlaceholderWrapper({
912
1000
  // has to sit above the rendered sections or every data-driven block
913
1001
  // falls back to its empty state.
914
1002
  <ThemePageContextProvider value={resolvedPageContext}>
915
- <ThemeSettingsProvider value={themeSettings}>
916
- {/*
1003
+ <ThemeSettingsProvider value={themeSettings}>
1004
+ {/*
917
1005
  Pins are released while browsing — a button frozen in its hover state
918
1006
  is not what the live site looks like. The editor re-sends the pinned
919
1007
  state when the designer comes back, so the panel and the canvas agree.
920
1008
  */}
921
- <PartStatePreview isDesigner={isEditing} />
922
- {themeBackgroundCSS && (
923
- <style dangerouslySetInnerHTML={{ __html: themeBackgroundCSS }} />
924
- )}
925
- {dynamicCSS && <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />}
926
- {/*
1009
+ <PartStatePreview isDesigner={isEditing} />
1010
+ {themeBackgroundCSS && (
1011
+ <style dangerouslySetInnerHTML={{ __html: themeBackgroundCSS }} />
1012
+ )}
1013
+ {dynamicCSS && (
1014
+ <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />
1015
+ )}
1016
+ {/*
927
1017
  Deliberately unpainted. This wrapper sits behind every section of the
928
1018
  slot, so any colour on it showed through a section that asked for
929
1019
  `background-color: transparent` — the shipped default. That is why a
@@ -932,68 +1022,70 @@ export default function ThemePlaceholderWrapper({
932
1022
  (see buildThemeBackgroundCss), which paints the same pixels everywhere
933
1023
  the slot does not overlap something else.
934
1024
  */}
935
- <div data-placeholder={slug} className="theme-placeholder">
936
- {renderedSections
937
- .sort((a, b) => a.order - b.order)
938
- .filter(
939
- (section) =>
940
- !section.hidden &&
941
- (isEditing ||
942
- isPublishWindowVisible(section.properties?.['publish-window']))
943
- )
944
- .map((section) => (
945
- <SectionErrorBoundary key={section.id} sectionId={section.id}>
946
- <ThemeSection
947
- section={section}
948
- placeholderId={placeholderSlug}
949
- pageContext={resolvedPageContext}
950
- isDesigner={isEditing}
951
- isSelected={selectedSectionId === section.id}
952
- selectedBlockId={selectedBlockId}
953
- currentBreakpoint={currentBreakpoint}
954
- onSelect={setSelectedSectionId}
955
- onMoveUp={() =>
956
- sendAction('MOVE_SECTION_UP', {
957
- placeholderId: placeholderSlug,
958
- sectionId: section.id
959
- })
960
- }
961
- onMoveDown={() =>
962
- sendAction('MOVE_SECTION_DOWN', {
963
- placeholderId: placeholderSlug,
964
- sectionId: section.id
965
- })
966
- }
967
- onDuplicate={() =>
968
- sendAction('DUPLICATE_SECTION', {
969
- placeholderId: placeholderSlug,
970
- sectionId: section.id
971
- })
972
- }
973
- onToggleVisibility={() =>
974
- sendAction('TOGGLE_SECTION_VISIBILITY', {
975
- placeholderId: placeholderSlug,
976
- sectionId: section.id
977
- })
978
- }
979
- onDelete={() =>
980
- sendAction('DELETE_SECTION', {
981
- placeholderId: placeholderSlug,
982
- sectionId: section.id
983
- })
984
- }
985
- onRename={(newLabel) =>
986
- sendAction('RENAME_SECTION', {
987
- placeholderId: placeholderSlug,
988
- sectionId: section.id,
989
- label: newLabel
990
- })
991
- }
992
- />
993
- </SectionErrorBoundary>
994
- ))}
995
- </div>
996
- </ThemeSettingsProvider>
1025
+ <div data-placeholder={slug} className="theme-placeholder">
1026
+ {renderedSections
1027
+ .sort((a, b) => a.order - b.order)
1028
+ .filter(
1029
+ (section) =>
1030
+ !section.hidden &&
1031
+ (isEditing ||
1032
+ isPublishWindowVisible(
1033
+ section.properties?.['publish-window']
1034
+ ))
1035
+ )
1036
+ .map((section) => (
1037
+ <SectionErrorBoundary key={section.id} sectionId={section.id}>
1038
+ <ThemeSection
1039
+ section={section}
1040
+ placeholderId={placeholderSlug}
1041
+ pageContext={resolvedPageContext}
1042
+ isDesigner={isEditing}
1043
+ isSelected={selectedSectionId === section.id}
1044
+ selectedBlockId={selectedBlockId}
1045
+ currentBreakpoint={currentBreakpoint}
1046
+ onSelect={setSelectedSectionId}
1047
+ onMoveUp={() =>
1048
+ sendAction('MOVE_SECTION_UP', {
1049
+ placeholderId: placeholderSlug,
1050
+ sectionId: section.id
1051
+ })
1052
+ }
1053
+ onMoveDown={() =>
1054
+ sendAction('MOVE_SECTION_DOWN', {
1055
+ placeholderId: placeholderSlug,
1056
+ sectionId: section.id
1057
+ })
1058
+ }
1059
+ onDuplicate={() =>
1060
+ sendAction('DUPLICATE_SECTION', {
1061
+ placeholderId: placeholderSlug,
1062
+ sectionId: section.id
1063
+ })
1064
+ }
1065
+ onToggleVisibility={() =>
1066
+ sendAction('TOGGLE_SECTION_VISIBILITY', {
1067
+ placeholderId: placeholderSlug,
1068
+ sectionId: section.id
1069
+ })
1070
+ }
1071
+ onDelete={() =>
1072
+ sendAction('DELETE_SECTION', {
1073
+ placeholderId: placeholderSlug,
1074
+ sectionId: section.id
1075
+ })
1076
+ }
1077
+ onRename={(newLabel) =>
1078
+ sendAction('RENAME_SECTION', {
1079
+ placeholderId: placeholderSlug,
1080
+ sectionId: section.id,
1081
+ label: newLabel
1082
+ })
1083
+ }
1084
+ />
1085
+ </SectionErrorBoundary>
1086
+ ))}
1087
+ </div>
1088
+ </ThemeSettingsProvider>
997
1089
  </ThemePageContextProvider>
998
1090
  );
999
1091
  }
@@ -1,6 +1,7 @@
1
1
  import 'server-only';
2
2
 
3
3
  import ThemePlaceholderWrapper from './theme-placeholder-wrapper';
4
+ import { ThemeFontsLink } from './chrome/theme-fonts-link';
4
5
  import { loadPlaceholderSections } from './load-placeholder-sections';
5
6
  import { generateThemeCSS } from './utils';
6
7
 
@@ -24,6 +25,7 @@ export default async function ThemePlaceholder({
24
25
 
25
26
  return (
26
27
  <>
28
+ <ThemeFontsLink settings={themeSettings} />
27
29
  {themeCSS && <style dangerouslySetInnerHTML={{ __html: themeCSS }} />}
28
30
  <ThemePlaceholderWrapper
29
31
  slug={slug}