@numueg/theme-sdk 0.2.3 → 0.3.1

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
@@ -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) {
@@ -1991,6 +2014,9 @@ function injectFontLink(href) {
1991
2014
  link.setAttribute("data-numu-font", "");
1992
2015
  document.head.appendChild(link);
1993
2016
  }
2017
+ function lookupFontStack(value) {
2018
+ return FONT_REGISTRY[value]?.stack ?? value;
2019
+ }
1994
2020
  function resolveFontStack(value) {
1995
2021
  const entry = FONT_REGISTRY[value];
1996
2022
  if (entry) {
@@ -1999,39 +2025,69 @@ function resolveFontStack(value) {
1999
2025
  }
2000
2026
  return value;
2001
2027
  }
2002
- function applyGlobalStyleTokens(globalSettings, el) {
2003
- if (!el || !globalSettings || typeof globalSettings !== "object") return;
2004
- const style = el.style;
2028
+ function computeGlobalStyleTokens(globalSettings) {
2029
+ const cssVars = {};
2030
+ const fontHrefs = [];
2031
+ if (!globalSettings || typeof globalSettings !== "object") {
2032
+ return { cssVars, fontHrefs };
2033
+ }
2034
+ const pushHref = (href) => {
2035
+ if (href && !fontHrefs.includes(href)) fontHrefs.push(href);
2036
+ };
2005
2037
  for (const [key, value] of Object.entries(globalSettings)) {
2006
2038
  if (!key || key.startsWith("__")) continue;
2007
2039
  if (value && typeof value === "object" && !Array.isArray(value)) {
2008
2040
  for (const [role, c] of Object.entries(value)) {
2009
- if (isColorValue(c)) style.setProperty(`--scheme-${key}-${role}`, c);
2041
+ if (isColorValue(c)) cssVars[`--scheme-${key}-${role}`] = c;
2010
2042
  }
2011
2043
  continue;
2012
2044
  }
2013
2045
  if (isColorValue(value)) {
2014
- style.setProperty(`--theme-${key}`, value.trim());
2046
+ cssVars[`--theme-${key}`] = value.trim();
2015
2047
  const role = COLOR_ROLE_ALIASES[key];
2016
- if (role) style.setProperty(`--theme-color-${role}`, value.trim());
2048
+ if (role) cssVars[`--theme-color-${role}`] = value.trim();
2017
2049
  continue;
2018
2050
  }
2019
2051
  if (isFontToken(value)) {
2020
- const stack = resolveFontStack(value);
2021
- style.setProperty(`--theme-${key}`, stack);
2052
+ const entry = FONT_REGISTRY[value];
2053
+ cssVars[`--theme-${key}`] = entry.stack;
2022
2054
  const role = FONT_ROLE_ALIASES[key];
2023
- if (role) style.setProperty(`--theme-font-${role}`, stack);
2055
+ if (role) cssVars[`--theme-font-${role}`] = entry.stack;
2056
+ pushHref(entry.href);
2024
2057
  continue;
2025
2058
  }
2026
2059
  if (typeof value === "string" || typeof value === "number") {
2027
2060
  const v = String(value).trim();
2028
- if (v) style.setProperty(`--theme-${key}`, v);
2061
+ if (v) cssVars[`--theme-${key}`] = v;
2062
+ }
2063
+ }
2064
+ for (const id of ["heading_font", "body_font"]) {
2065
+ const value = globalSettings[id];
2066
+ if (typeof value === "string" && value.trim()) {
2067
+ cssVars[`--theme-${id}`] = lookupFontStack(value);
2068
+ pushHref(FONT_REGISTRY[value]?.href);
2029
2069
  }
2030
2070
  }
2071
+ return { cssVars, fontHrefs };
2072
+ }
2073
+ function applyGlobalStyleTokens(globalSettings, el) {
2074
+ if (!el || !globalSettings || typeof globalSettings !== "object") return;
2075
+ const { cssVars, fontHrefs } = computeGlobalStyleTokens(globalSettings);
2076
+ const style = el.style;
2077
+ for (const [prop, value] of Object.entries(cssVars)) {
2078
+ style.setProperty(prop, value);
2079
+ }
2080
+ for (const href of fontHrefs) injectFontLink(href);
2031
2081
  }
2032
2082
  function pickStore(ctx) {
2033
2083
  const s = ctx.storeData ?? ctx.store;
2034
- if (s) return s;
2084
+ if (s) {
2085
+ const raw = s;
2086
+ if (!raw.currency && raw.default_currency) {
2087
+ return { ...raw, currency: raw.default_currency };
2088
+ }
2089
+ return s;
2090
+ }
2035
2091
  return {
2036
2092
  id: "unknown",
2037
2093
  name: "Store",
@@ -2066,19 +2122,9 @@ var ThemeMountBridge = react.forwardRef(function ThemeMountBridge2({ ctx, mountE
2066
2122
  []
2067
2123
  );
2068
2124
  react.useEffect(() => {
2125
+ if (!mountEl) return;
2069
2126
  const gs = themeSettings.global_settings ?? {};
2070
2127
  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
2128
  }, [themeSettings, mountEl]);
2083
2129
  const store = pickStore(ctx);
2084
2130
  const template = pickTemplate(ctx);
@@ -2108,22 +2154,22 @@ var ThemeMountBridge = react.forwardRef(function ThemeMountBridge2({ ctx, mountE
2108
2154
  }
2109
2155
  );
2110
2156
  });
2157
+ function buildThemeElement(ctx, mountEl, renderApp, ref) {
2158
+ return /* @__PURE__ */ jsxRuntime.jsx(react.StrictMode, { children: /* @__PURE__ */ jsxRuntime.jsx(ThemeMountBridge, { ctx, mountEl, renderApp, ref }) });
2159
+ }
2111
2160
  function mountTheme(el, ctx, renderApp) {
2112
- const root = client.createRoot(el);
2113
2161
  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
- );
2162
+ const element = buildThemeElement(ctx, el, renderApp, (h) => {
2163
+ handleRef.current = h;
2164
+ });
2165
+ const shouldHydrate = ctx.hydrate === true && el.firstElementChild !== null;
2166
+ let root;
2167
+ if (shouldHydrate) {
2168
+ root = client.hydrateRoot(el, element);
2169
+ } else {
2170
+ root = client.createRoot(el);
2171
+ root.render(element);
2172
+ }
2127
2173
  return {
2128
2174
  applyDraft: (next) => handleRef.current?.applyDraft(next),
2129
2175
  cleanup: () => {
@@ -2132,6 +2178,14 @@ function mountTheme(el, ctx, renderApp) {
2132
2178
  }
2133
2179
  };
2134
2180
  }
2181
+
2182
+ // src/entry.tsx
2183
+ function defineThemeEntry(renderApp) {
2184
+ return {
2185
+ mount: (el, ctx) => mountTheme(el, ctx, renderApp),
2186
+ createApp: (ctx) => buildThemeElement(ctx, null, renderApp)
2187
+ };
2188
+ }
2135
2189
  function CollectionProvider({ collection, children }) {
2136
2190
  return /* @__PURE__ */ jsxRuntime.jsx(CollectionContext.Provider, { value: collection, children });
2137
2191
  }
@@ -2755,7 +2809,14 @@ function sanitizeHtmlServer(input) {
2755
2809
  return s;
2756
2810
  }
2757
2811
  function RichText({ html, className, as = "div" }) {
2758
- const safe = react.useMemo(() => sanitizeHtml(html || ""), [html]);
2812
+ const [domReady, setDomReady] = react.useState(false);
2813
+ react.useEffect(() => {
2814
+ setDomReady(true);
2815
+ }, []);
2816
+ const safe = react.useMemo(
2817
+ () => domReady ? sanitizeHtml(html || "") : sanitizeHtmlServer(html || ""),
2818
+ [html, domReady]
2819
+ );
2759
2820
  if (!safe) return null;
2760
2821
  const Tag = as;
2761
2822
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -3421,8 +3482,13 @@ function collectBlocks(modules) {
3421
3482
 
3422
3483
  // src/utils/assetUrl.ts
3423
3484
  function getRuntime() {
3424
- if (typeof window === "undefined") return {};
3425
- return window;
3485
+ if (typeof window !== "undefined") {
3486
+ return window;
3487
+ }
3488
+ if (typeof globalThis !== "undefined") {
3489
+ return globalThis;
3490
+ }
3491
+ return {};
3426
3492
  }
3427
3493
  function assetUrl(name) {
3428
3494
  if (!name) return "";
@@ -3520,12 +3586,15 @@ exports.asImageTransform = asImageTransform;
3520
3586
  exports.assetUrl = assetUrl;
3521
3587
  exports.availableValues = availableValues;
3522
3588
  exports.buildLocaleBundle = buildLocaleBundle;
3589
+ exports.buildThemeElement = buildThemeElement;
3523
3590
  exports.clearSdkSingleton = clearSdkSingleton;
3524
3591
  exports.collectBlocks = collectBlocks;
3525
3592
  exports.collectSections = collectSections;
3593
+ exports.computeGlobalStyleTokens = computeGlobalStyleTokens;
3526
3594
  exports.defaultVariant = defaultVariant;
3527
3595
  exports.defineBlock = defineBlock;
3528
3596
  exports.defineSection = defineSection;
3597
+ exports.defineThemeEntry = defineThemeEntry;
3529
3598
  exports.dynamicSource = dynamicSource;
3530
3599
  exports.findVariantByOptions = findVariantByOptions;
3531
3600
  exports.flattenMessages = flattenMessages;
@@ -3543,6 +3612,7 @@ exports.registerSdkSingleton = registerSdkSingleton;
3543
3612
  exports.resolveDynamicValue = resolveDynamicValue;
3544
3613
  exports.resolveFontStack = resolveFontStack;
3545
3614
  exports.resolveSettingsMap = resolveSettingsMap;
3615
+ exports.resolveSizeChart = resolveSizeChart;
3546
3616
  exports.resolveSourcePath = resolveSourcePath;
3547
3617
  exports.resolveThemeSettings = resolveThemeSettings;
3548
3618
  exports.sanitizeHtml = sanitizeHtml;
@@ -3572,6 +3642,7 @@ exports.useOrders = useOrders;
3572
3642
  exports.usePage = usePage;
3573
3643
  exports.useProduct = useProduct;
3574
3644
  exports.useProductOptional = useProductOptional;
3645
+ exports.useProductSizeChart = useProductSizeChart;
3575
3646
  exports.useProducts = useProducts;
3576
3647
  exports.useRelatedProducts = useRelatedProducts;
3577
3648
  exports.useReorder = useReorder;