@usequeek/theme-kit 0.1.5 → 0.1.7

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,43 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { request } from '../sdk/client';
5
+
6
+ /**
7
+ * The storefront must ONLY ever call the `/v1/client/*` backend scope. That scope's
8
+ * ResolveClientContext middleware resolves the vendor AND forces the platform
9
+ * (storefront/instore_qr) — so prices are always the storefront's, never the
10
+ * marketplace commission-inclusive price. Legacy v1 routes (/products, /vendors/*,
11
+ * /categories, ...) bypass that middleware → wrong platform → wrong price.
12
+ *
13
+ * This guard prevents that whole class of bug from ever returning.
14
+ */
15
+ describe('storefront API scope guard', () => {
16
+ afterEach(() => vi.unstubAllGlobals());
17
+
18
+ it('refuses any backend route outside /v1/client', async () => {
19
+ for (const badPath of ['/products/abc', '/vendors/foo/products', '/categories', '/vendors/foo/collections/x/products']) {
20
+ await expect(request('http://api/api/v1', badPath)).rejects.toThrow(/only call \/v1\/client/);
21
+ }
22
+ });
23
+
24
+ it('allows /client/* routes through', async () => {
25
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [] }) });
26
+ vi.stubGlobal('fetch', fetchMock);
27
+
28
+ await expect(request('http://api/api/v1', '/client/store/products')).resolves.toEqual({ data: [] });
29
+ expect(fetchMock).toHaveBeenCalledOnce();
30
+ });
31
+
32
+ it('does not reintroduce a raw v1-route client (createServerClient)', () => {
33
+ const root = process.cwd();
34
+ const sdk = readFileSync(join(root, 'sdk/client.ts'), 'utf8');
35
+ expect(sdk).not.toContain('export function createServerClient');
36
+
37
+ const apiDir = join(root, 'api');
38
+ for (const file of readdirSync(apiDir).filter((name) => name.endsWith('.ts'))) {
39
+ const src = readFileSync(join(apiDir, file), 'utf8');
40
+ expect(src, `${file} must not use the removed createServerClient (v1 routes)`).not.toContain('createServerClient');
41
+ }
42
+ });
43
+ });
package/types/block.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DesignTokens } from './vendor';
2
- import type { Product } from './product';
2
+ import type { MetaobjectEntry, Product } from './product';
3
3
 
4
4
  export type BlockType =
5
5
  | 'content'
@@ -18,7 +18,8 @@ export type BlockType =
18
18
  | 'reviews'
19
19
  | 'faq'
20
20
  | 'product_qa'
21
- | 'blog';
21
+ | 'blog'
22
+ | 'metaobjects';
22
23
 
23
24
  export interface ContentBlockData {
24
25
  markdown: string;
@@ -245,6 +246,25 @@ export interface BlogBlockData {
245
246
  limit?: number;
246
247
  }
247
248
 
249
+ export interface MetaobjectsBlockData {
250
+ title?: string | null;
251
+ /** Metaobject definition handle (`designer`, `ingredient`, …). */
252
+ type: string;
253
+ limit?: number;
254
+ layout?: 'grid' | 'list';
255
+ }
256
+
257
+ /**
258
+ * Server-seeded resolution for a `metaobjects` block — the entries of
259
+ * `data.type` plus the definition meta needed to decide whether each card
260
+ * links out (`definition.has_pages`). Mirrors `MetaobjectsResolved` shape
261
+ * used by `hydrateProductBlocks` in the storefront.
262
+ */
263
+ export interface MetaobjectsResolved {
264
+ entries: MetaobjectEntry[];
265
+ definition: { type: string; name: string; has_pages: boolean };
266
+ }
267
+
248
268
  export type BlockDataMap = {
249
269
  content: ContentBlockData;
250
270
  image: ImageBlockData;
@@ -263,6 +283,7 @@ export type BlockDataMap = {
263
283
  faq: FaqBlockData;
264
284
  product_qa: ProductQaBlockData;
265
285
  blog: BlogBlockData;
286
+ metaobjects: MetaobjectsBlockData;
266
287
  };
267
288
 
268
289
  export type Block<T extends BlockType = BlockType> = {
@@ -289,4 +310,12 @@ export type Block<T extends BlockType = BlockType> = {
289
310
  * block type and for any route that hasn't been wired to hydrate.
290
311
  */
291
312
  productsPromise?: Promise<Product[]>;
313
+ /**
314
+ * Server-seeded entries + definition meta for `metaobjects` blocks — set
315
+ * by `hydrateProductBlocks` alongside `productsPromise`. Same contract:
316
+ * absent for every other block type and for any route that hasn't been
317
+ * wired to hydrate; the renderer falls back to rendering nothing (themes
318
+ * never fetch metaobjects client-side).
319
+ */
320
+ metaobjectsPromise?: Promise<MetaobjectsResolved>;
292
321
  };
package/types/product.ts CHANGED
@@ -157,6 +157,32 @@ export interface ProductFlags {
157
157
  is_new: boolean;
158
158
  }
159
159
 
160
+ /** A scalar metafield value (single_line_text, boolean, date, url, …). */
161
+ export type MetafieldScalar = string | number | boolean;
162
+
163
+ /**
164
+ * One metaobject entry linked through a `metaobject_reference` /
165
+ * `list.metaobject_reference` metafield. Presented by the backend
166
+ * (MetafieldPresenter) as the entry resource minus timestamps — a `draft`
167
+ * entry, or one whose definition is not storefront-visible, is already
168
+ * omitted on client surfaces, so the storefront renders whatever arrives.
169
+ */
170
+ export interface MetaobjectEntry {
171
+ p_id: number;
172
+ type: string;
173
+ handle: string;
174
+ display_name: string;
175
+ /** Flat entry values (definitions never nest references). */
176
+ fields: Record<string, MetafieldScalar | MetafieldScalar[]>;
177
+ }
178
+
179
+ /** A presented product metafield value: scalar, scalar list, or linked entry/entries. */
180
+ export type MetafieldValue =
181
+ | MetafieldScalar
182
+ | MetafieldScalar[]
183
+ | MetaobjectEntry
184
+ | MetaobjectEntry[];
185
+
160
186
  export interface Product {
161
187
  shop_id: string;
162
188
  id: string;
@@ -183,6 +209,9 @@ export interface Product {
183
209
  variants: ProductVariant[];
184
210
  addons: ProductAddon[];
185
211
  reviews: ProductReview[];
212
+ /** Presented product metafields keyed by definition key (incl. linked
213
+ * metaobject entries). Absent when the backend sends none. */
214
+ metafields?: Record<string, MetafieldValue>;
186
215
  created_at: string | null;
187
216
  updated_at: string | null;
188
217
  }
@@ -271,6 +300,8 @@ export interface LegacyProductLike {
271
300
  >;
272
301
  addons?: ProductAddon[];
273
302
  reviews?: ProductReview[];
303
+ /** Raw presented metafields — passed through verbatim by the normalizer. */
304
+ metafields?: Record<string, unknown>;
274
305
  review_summary?: Partial<ProductReviewSummary>;
275
306
  created_at?: string | null;
276
307
  updated_at?: string | null;
package/types/theme.ts CHANGED
@@ -4,7 +4,7 @@ import type { CartItem as CartLineItem } from './cart';
4
4
  import type { ImageVariants } from './media';
5
5
  import type { MenuItem } from './menu';
6
6
  import type { BlogCategory, Collection, Page, Pagination, Post } from './page';
7
- import type { Product } from './product';
7
+ import type { MetaobjectEntry, Product } from './product';
8
8
  import type { DesignTokenAxisPath, DesignTokens, StorefrontConfig, VendorProfile } from './vendor';
9
9
 
10
10
  export interface HomePageProps {
@@ -39,6 +39,19 @@ export interface PoliciesPageProps {
39
39
  policies: Page[];
40
40
  }
41
41
 
42
+ /** Field label/type metadata for a metaobject entry page — the same shape
43
+ * `GET store/metaobject-definitions` sends, scoped to one definition. */
44
+ export interface MetaobjectPageDefinition {
45
+ type: string;
46
+ name: string;
47
+ fields: Array<{ key: string; name: string; type: string }>;
48
+ }
49
+
50
+ export interface MetaobjectPageProps {
51
+ entry: MetaobjectEntry;
52
+ definition: MetaobjectPageDefinition;
53
+ }
54
+
42
55
  export interface ShopCategory {
43
56
  id: string;
44
57
  slug: string;
@@ -63,6 +76,13 @@ export interface CollectionPageProps {
63
76
 
64
77
  export interface ProductPageProps {
65
78
  product: Product;
79
+ /**
80
+ * Product metafield definitions keyed by metafield key — the labels (and
81
+ * types) the core-owned `<ProductMetafields />` section renders values
82
+ * under. OPTIONAL so external themes that destructure `{ product }` keep
83
+ * compiling; unknown keys fall back to a humanised key label.
84
+ */
85
+ metafieldDefinitions?: Record<string, { name: string; type: string }>;
66
86
  }
67
87
 
68
88
  export interface GalleryPageProps {
@@ -336,7 +356,7 @@ export interface ThemeManifest {
336
356
  * Block types that are framework-owned (React logic ships in core, themes
337
357
  * only style via `.core-block-*` CSS). Themes do NOT implement these.
338
358
  */
339
- export type FrameworkBlockType = 'divider' | 'embed' | 'video' | 'table' | 'button' | 'image' | 'reviews' | 'faq' | 'callout' | 'quote' | 'product_qa';
359
+ export type FrameworkBlockType = 'divider' | 'embed' | 'video' | 'table' | 'button' | 'image' | 'reviews' | 'faq' | 'callout' | 'quote' | 'product_qa' | 'metaobjects';
340
360
 
341
361
  /**
342
362
  * Block types the theme is responsible for implementing. Framework-owned
@@ -376,6 +396,11 @@ export interface ThemeModule {
376
396
  Collection: FC<CollectionPageProps>;
377
397
  Product: FC<ProductPageProps>;
378
398
  Gallery: FC<GalleryPageProps>;
399
+ /** Optional override for a metaobject entry's own page (`/{type}/{handle}`,
400
+ * only routed when the definition has `has_pages`). When absent, core
401
+ * renders `DefaultMetaobjectPage` (themeable via `.core-metaobject-page-*`
402
+ * CSS), same fallback pattern as `Policies`. */
403
+ Metaobject?: FC<MetaobjectPageProps>;
379
404
  };
380
405
  shells?: {
381
406
  CartShell?: FC<CartShellProps>;
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  LegacyProductLike,
3
+ MetafieldValue,
3
4
  Product,
4
5
  ProductCategory,
5
6
  ProductOption,
@@ -196,6 +197,18 @@ function normalizeCategories(raw: LegacyProductLike): ProductCategory[] {
196
197
  }, []);
197
198
  }
198
199
 
200
+ function normalizeMetafields(raw: LegacyProductLike): Record<string, MetafieldValue> {
201
+ // Explicit whitelist like every other field above: without this the
202
+ // presented metafields (incl. linked metaobject entries) are dropped at
203
+ // runtime and <ProductMetafields /> renders nothing at every call site.
204
+ const source = raw.metafields;
205
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
206
+ return {};
207
+ }
208
+
209
+ return source as Record<string, MetafieldValue>;
210
+ }
211
+
199
212
  export function normalizeProduct(raw: LegacyProductLike): Product {
200
213
  const shopId = String(raw.shop_id ?? raw.vendor_id ?? raw.shop?.id ?? raw.vendor?.id ?? '');
201
214
 
@@ -292,6 +305,7 @@ export function normalizeProduct(raw: LegacyProductLike): Product {
292
305
  : [],
293
306
  addons: raw.addons ?? [],
294
307
  reviews: raw.reviews ?? [],
308
+ metafields: normalizeMetafields(raw),
295
309
  created_at: raw.created_at ?? null,
296
310
  updated_at: raw.updated_at ?? null,
297
311
  };