@usequeek/theme-kit 0.1.6 → 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.
package/client.ts CHANGED
@@ -13,6 +13,7 @@ export * from './shared-blocks';
13
13
  export * from './components/blog';
14
14
  export * from './components/image';
15
15
  export * from './components/product-metafields';
16
+ export * from './components/default-metaobject-page';
16
17
  export * from './components/product-media-frame';
17
18
  export * from './components/variant-picker';
18
19
  export * from './components/auth/auth-flow';
@@ -0,0 +1,40 @@
1
+ import type { JSX } from 'react';
2
+ import type { MetaobjectPageProps } from '../types/theme';
3
+ import { humaniseMetafieldKey, renderValue } from './product-metafields';
4
+
5
+ /**
6
+ * Core-owned fallback for a metaobject entry's own page — used when the
7
+ * active theme doesn't provide `theme.pages.Metaobject`. Same field-label
8
+ * and per-type value rendering as `ProductMetafields`/`CoreMetaobjectsBlock`
9
+ * (via the shared `renderValue`), so an entry looks the same whether it's
10
+ * embedded in a product's metafields or viewed on its own page. Markup
11
+ * carries `core-metaobject-page*` classes for theme.css styling.
12
+ */
13
+ export function DefaultMetaobjectPage({ entry, definition }: MetaobjectPageProps): JSX.Element {
14
+ const fields = entry.fields && typeof entry.fields === 'object' ? entry.fields : {};
15
+ const fieldMeta = new Map(definition.fields.map((field) => [field.key, field]));
16
+
17
+ return (
18
+ <article className="core-metaobject-page">
19
+ <header className="core-metaobject-page__header">
20
+ <h1 className="core-metaobject-page__title">{entry.display_name}</h1>
21
+ </header>
22
+ <div className="core-metaobject-page__fields">
23
+ {Object.entries(fields).map(([key, value]) => {
24
+ const meta = fieldMeta.get(key);
25
+ const label = meta?.name?.trim() || humaniseMetafieldKey(key);
26
+ const type = meta?.type ?? '';
27
+
28
+ return (
29
+ <div className="core-metaobject-page__field" key={key}>
30
+ <h3 className="core-metaobject-page__label">{label}</h3>
31
+ <div className="core-metaobject-page__value">
32
+ {renderValue(value, key, label, type)}
33
+ </div>
34
+ </div>
35
+ );
36
+ })}
37
+ </div>
38
+ </article>
39
+ );
40
+ }
@@ -142,7 +142,14 @@ function renderEntryField(value: MetafieldScalar | MetafieldScalar[], fieldKey:
142
142
  return renderScalar(value, fieldKey, humaniseMetafieldKey(fieldKey), '');
143
143
  }
144
144
 
145
- function EntryCard({ entry }: { entry: MetaobjectEntry }): JSX.Element {
145
+ /**
146
+ * Renders one linked metaobject entry as a card — shared by
147
+ * `ProductMetafields` (a product's linked entries), `CoreMetaobjectsBlock`
148
+ * (a section listing a type's entries) and `DefaultMetaobjectPage` (via
149
+ * `renderValue` below, for entry detail pages). Exported so those two
150
+ * consumers never re-implement entry-card markup.
151
+ */
152
+ export function EntryCard({ entry }: { entry: MetaobjectEntry }): JSX.Element {
146
153
  const fields = entry.fields && typeof entry.fields === 'object' ? entry.fields : {};
147
154
 
148
155
  return (
@@ -160,7 +167,13 @@ function EntryCard({ entry }: { entry: MetaobjectEntry }): JSX.Element {
160
167
  );
161
168
  }
162
169
 
163
- function renderValue(value: MetafieldValue, fieldKey: string, label: string, type: string): JSX.Element {
170
+ /**
171
+ * Per-type value renderer — scalar, scalar list, linked entry, or list of
172
+ * entries — shared by `ProductMetafields` and `DefaultMetaobjectPage` (a
173
+ * metaobject entry's own fields are always flat scalars, never nested
174
+ * entries, so this same function covers both without a second renderer).
175
+ */
176
+ export function renderValue(value: MetafieldValue, fieldKey: string, label: string, type: string): JSX.Element {
164
177
  if (isEntry(value)) {
165
178
  return <EntryCard entry={value} />;
166
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
package/page-renderer.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { JSX } from 'react';
4
4
  import { Component, Suspense, use, useEffect, useMemo, type ComponentType, type ReactNode } from 'react';
5
- import type { Block, BlockDataMap } from './types/block';
5
+ import type { Block, BlockDataMap, MetaobjectsResolved } from './types/block';
6
6
  import type { Product } from './types/product';
7
7
  import { useTheme } from './theme-context';
8
8
  import { useStorefront } from './provider';
@@ -16,6 +16,7 @@ import {
16
16
  CoreQuoteBlock,
17
17
  CoreFaqBlock,
18
18
  CoreImageBlock,
19
+ CoreMetaobjectsBlock,
19
20
  CoreProductQaBlock,
20
21
  CoreReviewsBlock,
21
22
  CoreTableBlock,
@@ -323,6 +324,21 @@ export function PageRenderer({ blocks }: { blocks: Block[] }): JSX.Element {
323
324
  const data = block.data as BlockDataMap['product_qa'];
324
325
  return <CoreProductQaBlock key={key} {...data} />;
325
326
  }
327
+ case 'metaobjects': {
328
+ // Framework-owned. Themes never fetch metaobjects client-side —
329
+ // without a server-seeded promise (a route that hasn't called
330
+ // `hydrateProductBlocks`), the block renders nothing rather than
331
+ // falling back to a client fetch (mirrors `products`' fallback
332
+ // being a THEME concern only because that block is theme-owned).
333
+ const data = block.data as BlockDataMap['metaobjects'];
334
+ if (!block.metaobjectsPromise) return null;
335
+
336
+ return (
337
+ <Suspense key={key} fallback={<MetaobjectsBlockFallback title={data.title} limit={data.limit} />}>
338
+ <MetaobjectsBlockResolved data={data} promise={block.metaobjectsPromise} />
339
+ </Suspense>
340
+ );
341
+ }
326
342
  default:
327
343
  return null;
328
344
  }
@@ -364,3 +380,41 @@ function ProductsBlockFallback({ title, limit }: { title?: string | null; limit?
364
380
  </section>
365
381
  );
366
382
  }
383
+
384
+ /** Unwraps a server-seeded metaobjects promise and hands the resolved entries
385
+ * + definition straight to `CoreMetaobjectsBlock`. */
386
+ function MetaobjectsBlockResolved({
387
+ data,
388
+ promise,
389
+ }: {
390
+ data: BlockDataMap['metaobjects'];
391
+ promise: Promise<MetaobjectsResolved>;
392
+ }): JSX.Element | null {
393
+ const resolved = use(promise);
394
+ return (
395
+ <CoreMetaobjectsBlock
396
+ title={data.title}
397
+ layout={data.layout ?? 'grid'}
398
+ entries={resolved.entries}
399
+ definition={resolved.definition}
400
+ />
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Shown only while a `metaobjects` block's server-side fetch is still
406
+ * pending (cache-cold request). Deliberately theme-neutral, same rationale
407
+ * as `ProductsBlockFallback`.
408
+ */
409
+ function MetaobjectsBlockFallback({ title, limit }: { title?: string | null; limit?: number }): JSX.Element {
410
+ return (
411
+ <section aria-hidden="true">
412
+ {title ? <h2>{title}</h2> : null}
413
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '1rem' }}>
414
+ {Array.from({ length: limit ?? 12 }).map((_, i) => (
415
+ <div key={i} style={{ minHeight: 160, borderRadius: 8, background: 'rgba(127,127,127,0.15)' }} />
416
+ ))}
417
+ </div>
418
+ </section>
419
+ );
420
+ }
@@ -110,6 +110,14 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
110
110
  // shared across visitors — cached like pages, busted by revalidation.
111
111
  if (path === '/metafield-definitions') return policy(300, 'metafields');
112
112
 
113
+ // Metaobject definitions (storefront-visible types + their field labels)
114
+ // and entry lists/detail reads for the `metaobjects` block and its entry
115
+ // pages. Merchant-edited, shared across visitors — same tag for both so
116
+ // a definition edit (e.g. has_pages) and an entry write both bust the
117
+ // same cache entries.
118
+ if (path === '/metaobject-definitions') return policy(300, 'metaobjects');
119
+ if (/^\/metaobjects(\/.+)?$/.test(path)) return policy(300, 'metaobjects');
120
+
113
121
  return null;
114
122
  }
115
123
 
@@ -1790,3 +1790,66 @@
1790
1790
  font-size: 0.85rem;
1791
1791
  color: var(--brand-text-muted, rgba(0, 0, 0, 0.6));
1792
1792
  }
1793
+
1794
+ /* ── Metaobjects (content-entries section block) ────────────────── */
1795
+ .core-block-metaobjects__title {
1796
+ margin: 0 0 1.5rem;
1797
+ font-size: clamp(1.25rem, 2.5vw, 1.75rem);
1798
+ font-weight: 600;
1799
+ color: var(--brand-text, #1a1a1a);
1800
+ }
1801
+
1802
+ .core-block-metaobjects__list {
1803
+ display: grid;
1804
+ gap: 1.25rem;
1805
+ }
1806
+
1807
+ .core-block-metaobjects__list--grid {
1808
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
1809
+ }
1810
+
1811
+ .core-block-metaobjects__list--list {
1812
+ grid-template-columns: 1fr;
1813
+ }
1814
+
1815
+ .core-block-metaobjects__card-link {
1816
+ display: block;
1817
+ color: inherit;
1818
+ text-decoration: none;
1819
+ }
1820
+
1821
+ .core-block-metaobjects__card-link:hover .core-metafields__entry-card {
1822
+ border-color: var(--brand-accent, rgba(0, 0, 0, 0.2));
1823
+ }
1824
+
1825
+ /* ── Metaobject entry page (DefaultMetaobjectPage) ───────────────── */
1826
+ .core-metaobject-page {
1827
+ margin: 0 auto;
1828
+ padding: clamp(1.5rem, 4vw, 3rem) 1.25rem;
1829
+ max-width: var(--core-block-max-width, 1200px);
1830
+ }
1831
+
1832
+ .core-metaobject-page__title {
1833
+ margin: 0 0 2rem;
1834
+ font-size: clamp(1.5rem, 3vw, 2.25rem);
1835
+ font-weight: 700;
1836
+ color: var(--brand-text, #1a1a1a);
1837
+ }
1838
+
1839
+ .core-metaobject-page__fields {
1840
+ display: grid;
1841
+ gap: 1.5rem;
1842
+ }
1843
+
1844
+ .core-metaobject-page__label {
1845
+ margin: 0 0 0.5rem;
1846
+ font-size: 0.85rem;
1847
+ font-weight: 600;
1848
+ text-transform: uppercase;
1849
+ letter-spacing: 0.03em;
1850
+ color: var(--brand-text-muted, rgba(0, 0, 0, 0.55));
1851
+ }
1852
+
1853
+ .core-metaobject-page__value {
1854
+ color: var(--brand-text, #1a1a1a);
1855
+ }
@@ -16,6 +16,7 @@ export { CoreContentDefaultBlock } from './content-default';
16
16
  export { CoreReviewsBlock } from './reviews';
17
17
  export { CoreFaqBlock } from './faq';
18
18
  export { CoreProductQaBlock } from './product-qa';
19
+ export { CoreMetaobjectsBlock } from './metaobjects';
19
20
 
20
21
  export {
21
22
  FRAMEWORK_BLOCK_MANIFEST,
@@ -46,6 +46,10 @@ export const FRAMEWORK_BLOCK_MANIFEST: Record<string, ThemeManifestVariant[]> =
46
46
  product_qa: [
47
47
  { id: 'default', label: 'Product Q&A', default: true, purpose: 'Customer questions and vendor answers for this product' },
48
48
  ],
49
+ metaobjects: [
50
+ { id: 'grid', label: 'Grid', default: true, purpose: 'Entries of a chosen content type (designers, ingredients, …) in a responsive grid' },
51
+ { id: 'list', label: 'List', purpose: 'Entries of a chosen content type as a vertical list' },
52
+ ],
49
53
  };
50
54
 
51
55
  /**
@@ -65,6 +69,7 @@ export const FRAMEWORK_OWNED_TYPES = new Set<string>([
65
69
  'reviews',
66
70
  'faq',
67
71
  'product_qa',
72
+ 'metaobjects',
68
73
  ]);
69
74
 
70
75
  /**
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import type { JSX } from 'react';
4
+ import type { MetaobjectsResolved } from '../types/block';
5
+ import { EntryCard } from '../components/product-metafields';
6
+
7
+ export interface CoreMetaobjectsBlockProps extends MetaobjectsResolved {
8
+ title?: string | null;
9
+ layout?: 'grid' | 'list';
10
+ }
11
+
12
+ /**
13
+ * Framework-owned "custom content" section — a merchant-picked metaobject
14
+ * type rendered as a grid or list of entry cards, reusing the same
15
+ * `EntryCard` a product's linked-entry metafields render. Renders nothing
16
+ * when the type is unknown, hidden, or has no active entries (the server
17
+ * seeds `entries: []` for all three cases — see `hydrateProductBlocks` in
18
+ * the storefront). Each card links to `/{type}/{handle}` only when the
19
+ * definition opted into entry pages (`has_pages`); otherwise it's inert.
20
+ */
21
+ export function CoreMetaobjectsBlock({
22
+ title,
23
+ layout = 'grid',
24
+ entries,
25
+ definition,
26
+ }: CoreMetaobjectsBlockProps): JSX.Element | null {
27
+ const safeEntries = Array.isArray(entries) ? entries : [];
28
+ if (safeEntries.length === 0) return null;
29
+
30
+ return (
31
+ <section className={`core-block core-block-metaobjects core-block-metaobjects--${layout}`}>
32
+ {title ? <h2 className="core-block-metaobjects__title">{title}</h2> : null}
33
+ <div className={`core-block-metaobjects__list core-block-metaobjects__list--${layout}`}>
34
+ {safeEntries.map((entry) =>
35
+ definition?.has_pages ? (
36
+ <a
37
+ key={`${entry.type}:${entry.p_id}`}
38
+ className="core-block-metaobjects__card-link"
39
+ href={`/${entry.type}/${entry.handle}`}
40
+ >
41
+ <EntryCard entry={entry} />
42
+ </a>
43
+ ) : (
44
+ <EntryCard key={`${entry.type}:${entry.p_id}`} entry={entry} />
45
+ ),
46
+ )}
47
+ </div>
48
+ </section>
49
+ );
50
+ }
@@ -0,0 +1,56 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { DefaultMetaobjectPage } from '../components/default-metaobject-page';
5
+ import type { MetaobjectEntry } from '../types/product';
6
+ import type { MetaobjectPageDefinition } from '../types/theme';
7
+
8
+ function render(fields: MetaobjectEntry['fields'], fieldDefs: MetaobjectPageDefinition['fields']): string {
9
+ const entry: MetaobjectEntry = {
10
+ p_id: 1,
11
+ type: 'designer',
12
+ handle: 'adaeze',
13
+ display_name: 'Adaeze',
14
+ fields,
15
+ };
16
+ const definition: MetaobjectPageDefinition = { type: 'designer', name: 'Designer', fields: fieldDefs };
17
+
18
+ return renderToStaticMarkup(React.createElement(DefaultMetaobjectPage, { entry, definition }));
19
+ }
20
+
21
+ describe('DefaultMetaobjectPage', () => {
22
+ it('renders the entry display name as the title', () => {
23
+ const html = render({}, []);
24
+ expect(html).toContain('core-metaobject-page__title');
25
+ expect(html).toContain('Adaeze');
26
+ });
27
+
28
+ it('renders a text field with its definition label', () => {
29
+ const html = render({ bio: 'Lagos-based designer' }, [{ key: 'bio', name: 'Biography', type: 'single_line_text' }]);
30
+ expect(html).toContain('Biography');
31
+ expect(html).toContain('Lagos-based designer');
32
+ });
33
+
34
+ it('falls back to a humanised key label when no definition field matches', () => {
35
+ const html = render({ years_active: '10' }, []);
36
+ expect(html).toContain('Years active');
37
+ });
38
+
39
+ it('renders a boolean field as Yes/No', () => {
40
+ const html = render({ verified: true }, [{ key: 'verified', name: 'Verified', type: 'boolean' }]);
41
+ expect(html).toContain('>Yes<');
42
+ });
43
+
44
+ it('renders a url field as a link', () => {
45
+ const html = render({ portfolio: 'https://example.com/adaeze' }, [{ key: 'portfolio', name: 'Portfolio', type: 'url' }]);
46
+ expect(html).toContain('<a');
47
+ expect(html).toContain('href="https://example.com/adaeze"');
48
+ });
49
+
50
+ it('renders a list field as bullets', () => {
51
+ const html = render({ tags: ['couture', 'bridal'] }, [{ key: 'tags', name: 'Tags', type: 'list.single_line_text' }]);
52
+ expect(html).toContain('<ul');
53
+ expect(html).toContain('couture');
54
+ expect(html).toContain('bridal');
55
+ });
56
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { FRAMEWORK_BLOCK_MANIFEST, FRAMEWORK_OWNED_TYPES } from '../shared-blocks/manifest';
3
+ import type { BlockType } from '../types/block';
4
+ import type { FrameworkBlockType } from '../types/theme';
5
+
6
+ /**
7
+ * Three places declare "this block type is framework-owned" and must never
8
+ * drift: `types/theme.ts` `FrameworkBlockType`, `shared-blocks/manifest.ts`
9
+ * `FRAMEWORK_OWNED_TYPES`, and `page-renderer.tsx`'s switch (checked here by
10
+ * proxy — every framework-owned type must at least have a manifest entry, or
11
+ * a theme's Section Library drawer would offer a block the renderer has no
12
+ * variant list for).
13
+ */
14
+ describe('framework-owned block manifest parity', () => {
15
+ // Mirrors `FrameworkBlockType` in types/theme.ts — TypeScript enforces the
16
+ // literal union is exhaustive against `BlockType` here (compile error if
17
+ // one is added without the other), so this list doubles as the type check.
18
+ const frameworkBlockTypes: FrameworkBlockType[] = [
19
+ 'divider',
20
+ 'embed',
21
+ 'video',
22
+ 'table',
23
+ 'button',
24
+ 'image',
25
+ 'reviews',
26
+ 'faq',
27
+ 'callout',
28
+ 'quote',
29
+ 'product_qa',
30
+ 'metaobjects',
31
+ ];
32
+
33
+ it('FrameworkBlockType and FRAMEWORK_OWNED_TYPES name the same set', () => {
34
+ expect(new Set(frameworkBlockTypes)).toEqual(FRAMEWORK_OWNED_TYPES);
35
+ });
36
+
37
+ it('every framework-owned type (except content, handled specially) has a manifest entry', () => {
38
+ for (const type of FRAMEWORK_OWNED_TYPES) {
39
+ expect(FRAMEWORK_BLOCK_MANIFEST[type], `${type} missing from FRAMEWORK_BLOCK_MANIFEST`).toBeDefined();
40
+ expect(FRAMEWORK_BLOCK_MANIFEST[type].length).toBeGreaterThan(0);
41
+ }
42
+ });
43
+
44
+ it('metaobjects is a real BlockType', () => {
45
+ const type: BlockType = 'metaobjects';
46
+ expect(FRAMEWORK_OWNED_TYPES.has(type)).toBe(true);
47
+ });
48
+
49
+ it('metaobjects manifest declares grid (default) and list variants', () => {
50
+ const variants = FRAMEWORK_BLOCK_MANIFEST.metaobjects;
51
+ expect(variants.map((v) => v.id)).toEqual(expect.arrayContaining(['grid', 'list']));
52
+ expect(variants.find((v) => v.default)?.id).toBe('grid');
53
+ });
54
+ });
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { CoreMetaobjectsBlock } from '../shared-blocks/metaobjects';
5
+ import type { CoreMetaobjectsBlockProps } from '../shared-blocks/metaobjects';
6
+ import type { MetaobjectEntry } from '../types/product';
7
+
8
+ function makeEntry(overrides: Partial<MetaobjectEntry> = {}): MetaobjectEntry {
9
+ return {
10
+ p_id: 1,
11
+ type: 'designer',
12
+ handle: 'adaeze',
13
+ display_name: 'Adaeze',
14
+ fields: { bio: 'Lagos-based designer' },
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ function render(props: Partial<CoreMetaobjectsBlockProps> = {}): string {
20
+ return renderToStaticMarkup(
21
+ React.createElement(CoreMetaobjectsBlock, {
22
+ entries: [makeEntry()],
23
+ definition: { type: 'designer', name: 'Designer', has_pages: false },
24
+ ...props,
25
+ }),
26
+ );
27
+ }
28
+
29
+ describe('CoreMetaobjectsBlock', () => {
30
+ it('renders nothing when there are no entries', () => {
31
+ expect(render({ entries: [] })).toBe('');
32
+ });
33
+
34
+ it('renders a grid by default with entry cards', () => {
35
+ const html = render();
36
+ expect(html).toContain('core-block-metaobjects--grid');
37
+ expect(html).toContain('core-block-metaobjects__list--grid');
38
+ expect(html).toContain('core-metafields__entry-card');
39
+ expect(html).toContain('Adaeze');
40
+ });
41
+
42
+ it('renders a list layout when requested', () => {
43
+ const html = render({ layout: 'list' });
44
+ expect(html).toContain('core-block-metaobjects--list');
45
+ expect(html).toContain('core-block-metaobjects__list--list');
46
+ });
47
+
48
+ it('renders the title when provided', () => {
49
+ expect(render({ title: 'Our designers' })).toContain('Our designers');
50
+ });
51
+
52
+ it('does not link entries when the definition has no pages', () => {
53
+ const html = render({ definition: { type: 'designer', name: 'Designer', has_pages: false } });
54
+ expect(html).not.toContain('<a ');
55
+ });
56
+
57
+ it('links each entry to /{type}/{handle} when the definition has pages', () => {
58
+ const html = render({ definition: { type: 'designer', name: 'Designer', has_pages: true } });
59
+ expect(html).toContain('<a ');
60
+ expect(html).toContain('href="/designer/adaeze"');
61
+ });
62
+
63
+ it('renders multiple entries', () => {
64
+ const html = render({
65
+ entries: [makeEntry(), makeEntry({ p_id: 2, handle: 'chidi', display_name: 'Chidi' })],
66
+ });
67
+ expect(html).toContain('Adaeze');
68
+ expect(html).toContain('Chidi');
69
+ });
70
+ });
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/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;
@@ -343,7 +356,7 @@ export interface ThemeManifest {
343
356
  * Block types that are framework-owned (React logic ships in core, themes
344
357
  * only style via `.core-block-*` CSS). Themes do NOT implement these.
345
358
  */
346
- 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';
347
360
 
348
361
  /**
349
362
  * Block types the theme is responsible for implementing. Framework-owned
@@ -383,6 +396,11 @@ export interface ThemeModule {
383
396
  Collection: FC<CollectionPageProps>;
384
397
  Product: FC<ProductPageProps>;
385
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>;
386
404
  };
387
405
  shells?: {
388
406
  CartShell?: FC<CartShellProps>;