@numueg/theme-sdk 0.2.3 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -4,7 +4,35 @@ All notable changes to `@numueg/theme-sdk` are documented here. The format is ba
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
- ## [0.1.0] - 2026-05-11
7
+ ## [0.3.1] - 2026-06-17
8
+
9
+ ### Added
10
+
11
+ - **Size charts** — `useProductSizeChart()` hook + `resolveSizeChart()` pure
12
+ resolver. Resolves the per-product chart (`product.attributes.size_chart`)
13
+ against the store-wide default (`store.settings.size_chart`) using the same
14
+ precedence as the merchant hub + backend validator (`mode`:
15
+ `custom` → `default` → `off`, with a legacy no-mode fallback). New types
16
+ `SizeChart` / `SizeChartMode`.
17
+ - `Product.attributes` and `Store.settings` are now typed (optional
18
+ `Record<string, unknown>`) — the storefront already forwards these JSONB
19
+ blobs (also used by `useFieldTranslation`); they were previously untyped.
20
+
21
+ ## [0.3.0] - 2026-06-10
22
+
23
+ ### Added
24
+
25
+ - **`defineThemeEntry`** — one-call theme entry that returns both `mount`
26
+ (client, hydration-aware via `hydrateRoot`) and `createApp` (server
27
+ `renderToString`), wiring `NuMuProvider` + page/product/collection context +
28
+ catalog forwarding + global style tokens + the customizer's live-preview
29
+ draft cycle. This is the SSR contract for federated themes.
30
+
31
+ ### Changed
32
+
33
+ - `mount()` adopts host-server-rendered HTML instead of re-rendering when the
34
+ host passes `hydrate: true`; pure, browser-free global-style-token compute so
35
+ the server render is deterministic.
8
36
 
9
37
  First public release. Full surface documented at [numueg.app/docs/sdk/overview](https://numueg.app/docs/sdk/overview).
10
38
 
@@ -11,6 +11,14 @@ interface Store {
11
11
  default_language: string;
12
12
  use_nextjs_storefront: boolean;
13
13
  social_links?: Record<string, string>;
14
+ /**
15
+ * Store-level JSONB settings blob. Holds merchant-wide configuration the
16
+ * storefront forwards to themes — e.g. the store-wide default
17
+ * `size_chart` (see {@link SizeChart}) used when a product opts into
18
+ * `mode: "default"`. Untyped here because the shape grows independently
19
+ * of the SDK; narrow it at the read site.
20
+ */
21
+ settings?: Record<string, unknown>;
14
22
  }
15
23
  /** Product entity */
16
24
  interface Product {
@@ -30,6 +38,42 @@ interface Product {
30
38
  in_stock: boolean;
31
39
  seo_title?: string;
32
40
  seo_description?: string;
41
+ /**
42
+ * Per-product JSONB attribute blob the storefront forwards verbatim.
43
+ * Holds translated fields (`name_ar`, … — see `useFieldTranslation`) and
44
+ * the per-product `size_chart` ({@link SizeChart}). Untyped because the
45
+ * shape is open-ended; `useProductSizeChart` narrows the size-chart slot.
46
+ */
47
+ attributes?: Record<string, unknown>;
48
+ }
49
+ /**
50
+ * Size-chart resolution mode (mirrors the merchant hub's editor).
51
+ *
52
+ * "custom" → use the product's own chart
53
+ * "default" → fall back to the store-wide chart (`store.settings.size_chart`)
54
+ * "off" → never show, even if a store default exists
55
+ */
56
+ type SizeChartMode = "default" | "custom" | "off";
57
+ /**
58
+ * A size / measurement chart, stored per-product at
59
+ * `product.attributes.size_chart` and store-wide at
60
+ * `store.settings.size_chart`. Resolve the two with {@link useProductSizeChart}
61
+ * instead of reading the raw blobs.
62
+ */
63
+ interface SizeChart {
64
+ /** Legacy boolean kept for charts written before `mode` existed. */
65
+ enabled?: boolean;
66
+ mode?: SizeChartMode;
67
+ /** Column labels, e.g. ["Chest", "Waist", "Hip"]. */
68
+ column_headers: string[];
69
+ /** One row per size; `values` aligns to `column_headers`. */
70
+ rows: Array<{
71
+ size: string;
72
+ values: string[];
73
+ }>;
74
+ unit?: "cm" | "in" | "kg";
75
+ notes?: string;
76
+ image_url?: string;
33
77
  }
34
78
  interface ProductImage {
35
79
  id: string;
@@ -181,4 +225,4 @@ interface Page {
181
225
  data?: Record<string, any>;
182
226
  }
183
227
 
184
- export type { Address as A, Cart as C, Order as O, Page as P, Store as S, CartItem as a, Collection as b, Customer as c, OrderItem as d, Product as e, ProductImage as f, ProductVariant as g, ProductOption as h };
228
+ export type { Address as A, Cart as C, Order as O, Page as P, SizeChart as S, CartItem as a, Collection as b, Customer as c, OrderItem as d, Product as e, ProductImage as f, ProductVariant as g, SizeChartMode as h, Store as i, ProductOption as j };
@@ -11,6 +11,14 @@ interface Store {
11
11
  default_language: string;
12
12
  use_nextjs_storefront: boolean;
13
13
  social_links?: Record<string, string>;
14
+ /**
15
+ * Store-level JSONB settings blob. Holds merchant-wide configuration the
16
+ * storefront forwards to themes — e.g. the store-wide default
17
+ * `size_chart` (see {@link SizeChart}) used when a product opts into
18
+ * `mode: "default"`. Untyped here because the shape grows independently
19
+ * of the SDK; narrow it at the read site.
20
+ */
21
+ settings?: Record<string, unknown>;
14
22
  }
15
23
  /** Product entity */
16
24
  interface Product {
@@ -30,6 +38,42 @@ interface Product {
30
38
  in_stock: boolean;
31
39
  seo_title?: string;
32
40
  seo_description?: string;
41
+ /**
42
+ * Per-product JSONB attribute blob the storefront forwards verbatim.
43
+ * Holds translated fields (`name_ar`, … — see `useFieldTranslation`) and
44
+ * the per-product `size_chart` ({@link SizeChart}). Untyped because the
45
+ * shape is open-ended; `useProductSizeChart` narrows the size-chart slot.
46
+ */
47
+ attributes?: Record<string, unknown>;
48
+ }
49
+ /**
50
+ * Size-chart resolution mode (mirrors the merchant hub's editor).
51
+ *
52
+ * "custom" → use the product's own chart
53
+ * "default" → fall back to the store-wide chart (`store.settings.size_chart`)
54
+ * "off" → never show, even if a store default exists
55
+ */
56
+ type SizeChartMode = "default" | "custom" | "off";
57
+ /**
58
+ * A size / measurement chart, stored per-product at
59
+ * `product.attributes.size_chart` and store-wide at
60
+ * `store.settings.size_chart`. Resolve the two with {@link useProductSizeChart}
61
+ * instead of reading the raw blobs.
62
+ */
63
+ interface SizeChart {
64
+ /** Legacy boolean kept for charts written before `mode` existed. */
65
+ enabled?: boolean;
66
+ mode?: SizeChartMode;
67
+ /** Column labels, e.g. ["Chest", "Waist", "Hip"]. */
68
+ column_headers: string[];
69
+ /** One row per size; `values` aligns to `column_headers`. */
70
+ rows: Array<{
71
+ size: string;
72
+ values: string[];
73
+ }>;
74
+ unit?: "cm" | "in" | "kg";
75
+ notes?: string;
76
+ image_url?: string;
33
77
  }
34
78
  interface ProductImage {
35
79
  id: string;
@@ -181,4 +225,4 @@ interface Page {
181
225
  data?: Record<string, any>;
182
226
  }
183
227
 
184
- export type { Address as A, Cart as C, Order as O, Page as P, Store as S, CartItem as a, Collection as b, Customer as c, OrderItem as d, Product as e, ProductImage as f, ProductVariant as g, ProductOption as h };
228
+ export type { Address as A, Cart as C, Order as O, Page as P, SizeChart as S, CartItem as a, Collection as b, Customer as c, OrderItem as d, Product as e, ProductImage as f, ProductVariant as g, SizeChartMode as h, Store as i, ProductOption as j };
package/dist/index.cjs CHANGED
@@ -528,9 +528,8 @@ function toNavigationItem(raw, locale) {
528
528
  url: raw.url || "/",
529
529
  resource_type: mapResourceType(raw.type),
530
530
  resource_handle: raw.resource_id ?? null,
531
- children: (raw.children ?? []).map(
532
- (child) => toNavigationItem(child, locale)
533
- )
531
+ target_visible: raw.target_visible !== false,
532
+ children: (raw.children ?? []).map((child) => toNavigationItem(child, locale)).filter((child) => child.target_visible)
534
533
  };
535
534
  }
536
535
  function useNavigation(handle, options) {
@@ -540,7 +539,8 @@ function useNavigation(handle, options) {
540
539
  const hostProvidedAny = !!navMap && Object.keys(navMap).length > 0;
541
540
  const rawItems = handle ? navMap?.[handle] : void 0;
542
541
  const hostItems = react.useMemo(() => {
543
- if (rawItems) return rawItems.map((it) => toNavigationItem(it, locale));
542
+ if (rawItems)
543
+ return rawItems.map((it) => toNavigationItem(it, locale)).filter((it) => it.target_visible);
544
544
  if (hostProvidedAny) return [];
545
545
  return null;
546
546
  }, [rawItems, hostProvidedAny, locale]);
@@ -942,6 +942,29 @@ function useRelatedProducts(productId, options = {}) {
942
942
  }, [productId, limit]);
943
943
  return { items, loading, error };
944
944
  }
945
+ function useProductSizeChart(productOverride) {
946
+ const ctxProduct = useProductOptional();
947
+ const product = productOverride ?? ctxProduct;
948
+ const shop = useShop();
949
+ const storeSettings = shop?.settings;
950
+ return react.useMemo(
951
+ () => resolveSizeChart(product?.attributes, storeSettings),
952
+ [product?.attributes, storeSettings]
953
+ );
954
+ }
955
+ function hasRows(c) {
956
+ return !!c && typeof c === "object" && Array.isArray(c.rows) && c.rows.length > 0;
957
+ }
958
+ function resolveSizeChart(productAttributes, storeSettings) {
959
+ const product = productAttributes?.size_chart;
960
+ const storeDefault = storeSettings?.size_chart;
961
+ if (product?.mode === "off") return null;
962
+ if (product?.mode === "custom") return hasRows(product) ? product : null;
963
+ if (product?.mode === "default") return hasRows(storeDefault) ? storeDefault : null;
964
+ if (hasRows(product)) return product;
965
+ if (hasRows(storeDefault)) return storeDefault;
966
+ return null;
967
+ }
945
968
  var COOKIE_NAME = "numu_currency";
946
969
  var COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
947
970
  function readCookie(name) {
@@ -1454,7 +1477,15 @@ function normalizeCartFromServer(cart) {
1454
1477
  subtotal: toMajor(cart.subtotal),
1455
1478
  total: toMajor(cart.total),
1456
1479
  ...cart.discount_amount != null ? { discount_amount: toMajor(cart.discount_amount) } : {},
1457
- items: Array.isArray(cart.items) ? cart.items.map((it) => ({ ...it, price: toMajor(it.price) })) : []
1480
+ items: Array.isArray(cart.items) ? cart.items.map((it) => {
1481
+ const raw = it;
1482
+ return {
1483
+ ...it,
1484
+ name: raw.name || raw.product_name || "",
1485
+ price: toMajor(raw.price ?? raw.unit_price),
1486
+ variant_name: raw.variant_name ?? void 0
1487
+ };
1488
+ }) : []
1458
1489
  };
1459
1490
  }
1460
1491
  function unwrapCart(json) {
@@ -1991,6 +2022,9 @@ function injectFontLink(href) {
1991
2022
  link.setAttribute("data-numu-font", "");
1992
2023
  document.head.appendChild(link);
1993
2024
  }
2025
+ function lookupFontStack(value) {
2026
+ return FONT_REGISTRY[value]?.stack ?? value;
2027
+ }
1994
2028
  function resolveFontStack(value) {
1995
2029
  const entry = FONT_REGISTRY[value];
1996
2030
  if (entry) {
@@ -1999,39 +2033,69 @@ function resolveFontStack(value) {
1999
2033
  }
2000
2034
  return value;
2001
2035
  }
2002
- function applyGlobalStyleTokens(globalSettings, el) {
2003
- if (!el || !globalSettings || typeof globalSettings !== "object") return;
2004
- const style = el.style;
2036
+ function computeGlobalStyleTokens(globalSettings) {
2037
+ const cssVars = {};
2038
+ const fontHrefs = [];
2039
+ if (!globalSettings || typeof globalSettings !== "object") {
2040
+ return { cssVars, fontHrefs };
2041
+ }
2042
+ const pushHref = (href) => {
2043
+ if (href && !fontHrefs.includes(href)) fontHrefs.push(href);
2044
+ };
2005
2045
  for (const [key, value] of Object.entries(globalSettings)) {
2006
2046
  if (!key || key.startsWith("__")) continue;
2007
2047
  if (value && typeof value === "object" && !Array.isArray(value)) {
2008
2048
  for (const [role, c] of Object.entries(value)) {
2009
- if (isColorValue(c)) style.setProperty(`--scheme-${key}-${role}`, c);
2049
+ if (isColorValue(c)) cssVars[`--scheme-${key}-${role}`] = c;
2010
2050
  }
2011
2051
  continue;
2012
2052
  }
2013
2053
  if (isColorValue(value)) {
2014
- style.setProperty(`--theme-${key}`, value.trim());
2054
+ cssVars[`--theme-${key}`] = value.trim();
2015
2055
  const role = COLOR_ROLE_ALIASES[key];
2016
- if (role) style.setProperty(`--theme-color-${role}`, value.trim());
2056
+ if (role) cssVars[`--theme-color-${role}`] = value.trim();
2017
2057
  continue;
2018
2058
  }
2019
2059
  if (isFontToken(value)) {
2020
- const stack = resolveFontStack(value);
2021
- style.setProperty(`--theme-${key}`, stack);
2060
+ const entry = FONT_REGISTRY[value];
2061
+ cssVars[`--theme-${key}`] = entry.stack;
2022
2062
  const role = FONT_ROLE_ALIASES[key];
2023
- if (role) style.setProperty(`--theme-font-${role}`, stack);
2063
+ if (role) cssVars[`--theme-font-${role}`] = entry.stack;
2064
+ pushHref(entry.href);
2024
2065
  continue;
2025
2066
  }
2026
2067
  if (typeof value === "string" || typeof value === "number") {
2027
2068
  const v = String(value).trim();
2028
- if (v) style.setProperty(`--theme-${key}`, v);
2069
+ if (v) cssVars[`--theme-${key}`] = v;
2070
+ }
2071
+ }
2072
+ for (const id of ["heading_font", "body_font"]) {
2073
+ const value = globalSettings[id];
2074
+ if (typeof value === "string" && value.trim()) {
2075
+ cssVars[`--theme-${id}`] = lookupFontStack(value);
2076
+ pushHref(FONT_REGISTRY[value]?.href);
2029
2077
  }
2030
2078
  }
2079
+ return { cssVars, fontHrefs };
2080
+ }
2081
+ function applyGlobalStyleTokens(globalSettings, el) {
2082
+ if (!el || !globalSettings || typeof globalSettings !== "object") return;
2083
+ const { cssVars, fontHrefs } = computeGlobalStyleTokens(globalSettings);
2084
+ const style = el.style;
2085
+ for (const [prop, value] of Object.entries(cssVars)) {
2086
+ style.setProperty(prop, value);
2087
+ }
2088
+ for (const href of fontHrefs) injectFontLink(href);
2031
2089
  }
2032
2090
  function pickStore(ctx) {
2033
2091
  const s = ctx.storeData ?? ctx.store;
2034
- if (s) return s;
2092
+ if (s) {
2093
+ const raw = s;
2094
+ if (!raw.currency && raw.default_currency) {
2095
+ return { ...raw, currency: raw.default_currency };
2096
+ }
2097
+ return s;
2098
+ }
2035
2099
  return {
2036
2100
  id: "unknown",
2037
2101
  name: "Store",
@@ -2066,19 +2130,9 @@ var ThemeMountBridge = react.forwardRef(function ThemeMountBridge2({ ctx, mountE
2066
2130
  []
2067
2131
  );
2068
2132
  react.useEffect(() => {
2133
+ if (!mountEl) return;
2069
2134
  const gs = themeSettings.global_settings ?? {};
2070
2135
  applyGlobalStyleTokens(gs, mountEl);
2071
- const headingFont = gs.heading_font;
2072
- if (typeof headingFont === "string" && headingFont.trim()) {
2073
- mountEl.style.setProperty(
2074
- "--theme-heading_font",
2075
- resolveFontStack(headingFont)
2076
- );
2077
- }
2078
- const bodyFont = gs.body_font;
2079
- if (typeof bodyFont === "string" && bodyFont.trim()) {
2080
- mountEl.style.setProperty("--theme-body_font", resolveFontStack(bodyFont));
2081
- }
2082
2136
  }, [themeSettings, mountEl]);
2083
2137
  const store = pickStore(ctx);
2084
2138
  const template = pickTemplate(ctx);
@@ -2108,22 +2162,22 @@ var ThemeMountBridge = react.forwardRef(function ThemeMountBridge2({ ctx, mountE
2108
2162
  }
2109
2163
  );
2110
2164
  });
2165
+ function buildThemeElement(ctx, mountEl, renderApp, ref) {
2166
+ return /* @__PURE__ */ jsxRuntime.jsx(react.StrictMode, { children: /* @__PURE__ */ jsxRuntime.jsx(ThemeMountBridge, { ctx, mountEl, renderApp, ref }) });
2167
+ }
2111
2168
  function mountTheme(el, ctx, renderApp) {
2112
- const root = client.createRoot(el);
2113
2169
  const handleRef = { current: null };
2114
- root.render(
2115
- /* @__PURE__ */ jsxRuntime.jsx(react.StrictMode, { children: /* @__PURE__ */ jsxRuntime.jsx(
2116
- ThemeMountBridge,
2117
- {
2118
- ctx,
2119
- mountEl: el,
2120
- renderApp,
2121
- ref: (h) => {
2122
- handleRef.current = h;
2123
- }
2124
- }
2125
- ) })
2126
- );
2170
+ const element = buildThemeElement(ctx, el, renderApp, (h) => {
2171
+ handleRef.current = h;
2172
+ });
2173
+ const shouldHydrate = ctx.hydrate === true && el.firstElementChild !== null;
2174
+ let root;
2175
+ if (shouldHydrate) {
2176
+ root = client.hydrateRoot(el, element);
2177
+ } else {
2178
+ root = client.createRoot(el);
2179
+ root.render(element);
2180
+ }
2127
2181
  return {
2128
2182
  applyDraft: (next) => handleRef.current?.applyDraft(next),
2129
2183
  cleanup: () => {
@@ -2132,6 +2186,14 @@ function mountTheme(el, ctx, renderApp) {
2132
2186
  }
2133
2187
  };
2134
2188
  }
2189
+
2190
+ // src/entry.tsx
2191
+ function defineThemeEntry(renderApp) {
2192
+ return {
2193
+ mount: (el, ctx) => mountTheme(el, ctx, renderApp),
2194
+ createApp: (ctx) => buildThemeElement(ctx, null, renderApp)
2195
+ };
2196
+ }
2135
2197
  function CollectionProvider({ collection, children }) {
2136
2198
  return /* @__PURE__ */ jsxRuntime.jsx(CollectionContext.Provider, { value: collection, children });
2137
2199
  }
@@ -2755,7 +2817,14 @@ function sanitizeHtmlServer(input) {
2755
2817
  return s;
2756
2818
  }
2757
2819
  function RichText({ html, className, as = "div" }) {
2758
- const safe = react.useMemo(() => sanitizeHtml(html || ""), [html]);
2820
+ const [domReady, setDomReady] = react.useState(false);
2821
+ react.useEffect(() => {
2822
+ setDomReady(true);
2823
+ }, []);
2824
+ const safe = react.useMemo(
2825
+ () => domReady ? sanitizeHtml(html || "") : sanitizeHtmlServer(html || ""),
2826
+ [html, domReady]
2827
+ );
2759
2828
  if (!safe) return null;
2760
2829
  const Tag = as;
2761
2830
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -3421,8 +3490,13 @@ function collectBlocks(modules) {
3421
3490
 
3422
3491
  // src/utils/assetUrl.ts
3423
3492
  function getRuntime() {
3424
- if (typeof window === "undefined") return {};
3425
- return window;
3493
+ if (typeof window !== "undefined") {
3494
+ return window;
3495
+ }
3496
+ if (typeof globalThis !== "undefined") {
3497
+ return globalThis;
3498
+ }
3499
+ return {};
3426
3500
  }
3427
3501
  function assetUrl(name) {
3428
3502
  if (!name) return "";
@@ -3520,12 +3594,15 @@ exports.asImageTransform = asImageTransform;
3520
3594
  exports.assetUrl = assetUrl;
3521
3595
  exports.availableValues = availableValues;
3522
3596
  exports.buildLocaleBundle = buildLocaleBundle;
3597
+ exports.buildThemeElement = buildThemeElement;
3523
3598
  exports.clearSdkSingleton = clearSdkSingleton;
3524
3599
  exports.collectBlocks = collectBlocks;
3525
3600
  exports.collectSections = collectSections;
3601
+ exports.computeGlobalStyleTokens = computeGlobalStyleTokens;
3526
3602
  exports.defaultVariant = defaultVariant;
3527
3603
  exports.defineBlock = defineBlock;
3528
3604
  exports.defineSection = defineSection;
3605
+ exports.defineThemeEntry = defineThemeEntry;
3529
3606
  exports.dynamicSource = dynamicSource;
3530
3607
  exports.findVariantByOptions = findVariantByOptions;
3531
3608
  exports.flattenMessages = flattenMessages;
@@ -3543,6 +3620,7 @@ exports.registerSdkSingleton = registerSdkSingleton;
3543
3620
  exports.resolveDynamicValue = resolveDynamicValue;
3544
3621
  exports.resolveFontStack = resolveFontStack;
3545
3622
  exports.resolveSettingsMap = resolveSettingsMap;
3623
+ exports.resolveSizeChart = resolveSizeChart;
3546
3624
  exports.resolveSourcePath = resolveSourcePath;
3547
3625
  exports.resolveThemeSettings = resolveThemeSettings;
3548
3626
  exports.sanitizeHtml = sanitizeHtml;
@@ -3572,6 +3650,7 @@ exports.useOrders = useOrders;
3572
3650
  exports.usePage = usePage;
3573
3651
  exports.useProduct = useProduct;
3574
3652
  exports.useProductOptional = useProductOptional;
3653
+ exports.useProductSizeChart = useProductSizeChart;
3575
3654
  exports.useProducts = useProducts;
3576
3655
  exports.useRelatedProducts = useRelatedProducts;
3577
3656
  exports.useReorder = useReorder;