@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 +1 -0
- package/components/default-metaobject-page.tsx +40 -0
- package/components/product-metafields.tsx +15 -2
- package/package.json +1 -1
- package/page-renderer.tsx +55 -1
- package/sdk/server-store-client.ts +8 -0
- package/shared-blocks/core-blocks.css +63 -0
- package/shared-blocks/index.ts +1 -0
- package/shared-blocks/manifest.ts +5 -0
- package/shared-blocks/metaobjects.tsx +50 -0
- package/tests/default-metaobject-page.test.tsx +56 -0
- package/tests/manifest-parity.test.ts +54 -0
- package/tests/metaobjects-block.test.tsx +70 -0
- package/types/block.ts +31 -2
- package/types/theme.ts +20 -2
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
|
-
|
|
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
|
-
|
|
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
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
|
+
}
|
package/shared-blocks/index.ts
CHANGED
|
@@ -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>;
|