@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.
- package/README.md +55 -5
- package/client.ts +2 -0
- package/components/default-metaobject-page.tsx +40 -0
- package/components/product-metafields.tsx +226 -0
- package/next-reference.d.ts +12 -0
- package/package.json +19 -3
- package/page-renderer.tsx +55 -1
- package/scripts/verify-package.ts +104 -0
- package/sdk/queek-client.ts +31 -2
- package/sdk/server-store-client.ts +13 -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/fixtures/external-theme.tsx +69 -0
- package/tests/manifest-parity.test.ts +54 -0
- package/tests/markdown-render.test.ts +47 -0
- package/tests/metaobjects-block.test.tsx +70 -0
- package/tests/product-metafields.test.tsx +154 -0
- package/tests/public-surface.test.ts +38 -0
- package/tests/storefront-client-scope.test.ts +43 -0
- package/types/block.ts +31 -2
- package/types/product.ts +31 -0
- package/types/theme.ts +27 -2
- package/utils/product-normalizer.ts +14 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The framework a Queek storefront theme is built against: hooks, stores, headless
|
|
4
4
|
flows (auth, cart, checkout), types and the block renderer.
|
|
5
5
|
|
|
6
|
-
**Ships TypeScript source, not a build.**
|
|
6
|
+
**Ships TypeScript source, not a build.** Most of its modules carry a
|
|
7
7
|
`'use client'` directive, and that directive is the whole contract with the React
|
|
8
8
|
Server Components boundary — bundlers routinely strip or hoist it when a library
|
|
9
9
|
is pre-compiled, which turns a client module into a server one silently. The
|
|
@@ -15,8 +15,16 @@ map. One was tried: with source-shipped `.ts`/`.tsx` an extensionless wildcard
|
|
|
15
15
|
target does not resolve, and spelling out extensions cannot cover both. Legacy
|
|
16
16
|
resolution under `moduleResolution: bundler` handles it. The cost is that the
|
|
17
17
|
package has no subpath encapsulation, so what a theme may import is enforced by
|
|
18
|
-
`tests/
|
|
19
|
-
|
|
18
|
+
convention plus tests: `tests/public-surface.test.ts` here (barrels declare
|
|
19
|
+
only modules that exist) and `core-public-surface.test.ts` in the storefront
|
|
20
|
+
repo (barrels cover every module the themes actually import).
|
|
21
|
+
|
|
22
|
+
Every module that annotates a return as `JSX.Element` does
|
|
23
|
+
`import type { JSX } from 'react'` — React 19 removed the global `JSX`
|
|
24
|
+
namespace, and 0.1.0 shipped broken for every external consumer by relying on
|
|
25
|
+
the storefront app's `next-env.d.ts` to provide it. `scripts/verify-package.ts`
|
|
26
|
+
packs the current source and typechecks a real theme against it from a sandbox,
|
|
27
|
+
so that class of breakage fails here instead of after publishing.
|
|
20
28
|
|
|
21
29
|
## Use
|
|
22
30
|
|
|
@@ -30,8 +38,50 @@ to DECLARE the public surface — what a theme is allowed to build against — b
|
|
|
30
38
|
importing through them pulls every client module into one chunk and was measured
|
|
31
39
|
to break the app's build. Import the module you want.
|
|
32
40
|
|
|
41
|
+
Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks.css`
|
|
42
|
+
(framework-owned blocks), plus per-component CSS next to its component
|
|
43
|
+
(`components/variant-picker.css`, `apps/apps.css`).
|
|
44
|
+
|
|
45
|
+
## Consumers
|
|
46
|
+
|
|
47
|
+
- **The Queek storefront** (`usequeek/queek-storefront`) installs this package
|
|
48
|
+
from the npm registry (`"@usequeek/theme-kit": "^x.y.z"`) and transpiles it
|
|
49
|
+
via `transpilePackages`. Its vitest config inlines the package
|
|
50
|
+
(`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
|
|
51
|
+
source is transformed like first-party code.
|
|
52
|
+
- **External theme developers** start from `packages/theme-starter` in the
|
|
53
|
+
storefront repo, which builds standalone against a packed tarball of this
|
|
54
|
+
source. Anyone testing a theme with vitest needs the same `inline` line;
|
|
55
|
+
anyone compiling one needs `moduleResolution: bundler` + `jsx: react-jsx`.
|
|
56
|
+
|
|
33
57
|
## Boundary
|
|
34
58
|
|
|
35
59
|
The kit must never import the storefront app, its themes, or `lib/storefront`.
|
|
36
|
-
That one-way rule is
|
|
37
|
-
|
|
60
|
+
That one-way rule is what makes this package extractable at all.
|
|
61
|
+
|
|
62
|
+
## Develop
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install
|
|
66
|
+
npm run typecheck # tsc --noEmit, standalone — no storefront files
|
|
67
|
+
npm test # vitest run
|
|
68
|
+
npm run verify-package # pack current source, typecheck a real theme against it
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Release
|
|
72
|
+
|
|
73
|
+
The package is public on npm; the repo is private. Publishing runs on tags via
|
|
74
|
+
`.github/workflows/publish.yml` (needs the `NPM_TOKEN` secret):
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 1. Bump version in package.json and commit
|
|
78
|
+
# 2. Tag and push the tag — the workflow typechecks, tests, verify-packages,
|
|
79
|
+
# then `npm publish --access public`:
|
|
80
|
+
git tag v0.1.6
|
|
81
|
+
git push origin main --tags
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The storefront then bumps its dependency to match the published version
|
|
85
|
+
(`yarn add @usequeek/theme-kit@^0.1.6` + lockfile) and pushes separately —
|
|
86
|
+
never the other way round: the app must only ever depend on a version that
|
|
87
|
+
actually exists on the registry.
|
package/client.ts
CHANGED
|
@@ -12,6 +12,8 @@ export * from './theme-variant-resolver';
|
|
|
12
12
|
export * from './shared-blocks';
|
|
13
13
|
export * from './components/blog';
|
|
14
14
|
export * from './components/image';
|
|
15
|
+
export * from './components/product-metafields';
|
|
16
|
+
export * from './components/default-metaobject-page';
|
|
15
17
|
export * from './components/product-media-frame';
|
|
16
18
|
export * from './components/variant-picker';
|
|
17
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
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
MetafieldScalar,
|
|
4
|
+
MetafieldValue,
|
|
5
|
+
MetaobjectEntry,
|
|
6
|
+
Product,
|
|
7
|
+
} from '../types/product';
|
|
8
|
+
|
|
9
|
+
export interface ProductMetafieldDefinitionLabels {
|
|
10
|
+
[key: string]: { name: string; type: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ProductMetafieldsProps {
|
|
14
|
+
product: Product;
|
|
15
|
+
definitions?: ProductMetafieldDefinitionLabels;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Core-owned product metafields section — the AuthFlow pattern: themes
|
|
20
|
+
* import-and-place it, never re-implement it.
|
|
21
|
+
*
|
|
22
|
+
* Renders the product's presented metafields (all types), with linked
|
|
23
|
+
* metaobject entries as cards. Renders nothing when there are no visible
|
|
24
|
+
* values. Markup carries `core-metafields*` classes so each theme styles it
|
|
25
|
+
* in its own `theme.css` (framework-owned styling contract) — no theme CSS
|
|
26
|
+
* lives in core.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
function isEntry(value: unknown): value is MetaobjectEntry {
|
|
30
|
+
return (
|
|
31
|
+
!!value &&
|
|
32
|
+
typeof value === 'object' &&
|
|
33
|
+
!Array.isArray(value) &&
|
|
34
|
+
typeof (value as MetaobjectEntry).display_name === 'string' &&
|
|
35
|
+
typeof (value as MetaobjectEntry).handle === 'string'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `care_instructions` → `Care instructions`; unknown handles label from the key. */
|
|
40
|
+
export function humaniseMetafieldKey(key: string): string {
|
|
41
|
+
const words = key.replace(/[_-]+/g, ' ').trim();
|
|
42
|
+
if (words === '') return key;
|
|
43
|
+
|
|
44
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function labelFor(key: string, definitions?: ProductMetafieldDefinitionLabels): string {
|
|
48
|
+
const name = definitions?.[key]?.name?.trim();
|
|
49
|
+
return name && name !== '' ? name : humaniseMetafieldKey(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function typeFor(key: string, definitions?: ProductMetafieldDefinitionLabels): string {
|
|
53
|
+
return definitions?.[key]?.type ?? '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isVisibleScalar(value: MetafieldScalar): boolean {
|
|
57
|
+
if (typeof value === 'string') return value.trim() !== '';
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isVisibleValue(value: MetafieldValue): boolean {
|
|
62
|
+
if (value === null || value === undefined) return false;
|
|
63
|
+
if (Array.isArray(value)) return value.some((item) => (isEntry(item) ? true : isVisibleScalar(item as MetafieldScalar)));
|
|
64
|
+
if (isEntry(value)) return true;
|
|
65
|
+
return isVisibleScalar(value as MetafieldScalar);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normaliseBoolean(value: MetafieldValue): boolean | null {
|
|
69
|
+
if (typeof value === 'boolean') return value;
|
|
70
|
+
if (value === 1 || value === '1' || value === 'true') return true;
|
|
71
|
+
if (value === 0 || value === '0' || value === 'false') return false;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const IMAGE_URL_PATTERN = /^https?:\/\/\S+\.(jpg|jpeg|png|gif|webp|avif)(\?\S*)?$/i;
|
|
76
|
+
|
|
77
|
+
function renderScalar(value: MetafieldScalar, key: string, label: string, type: string): JSX.Element {
|
|
78
|
+
if (typeof value === 'boolean' || (type === 'boolean' && normaliseBoolean(value) !== null)) {
|
|
79
|
+
const yes = typeof value === 'boolean' ? value : normaliseBoolean(value) === true;
|
|
80
|
+
return <p className="core-metafields__text">{yes ? 'Yes' : 'No'}</p>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const text = String(value);
|
|
84
|
+
|
|
85
|
+
if (type === 'url') {
|
|
86
|
+
return /^https?:\/\//i.test(text) ? (
|
|
87
|
+
<a className="core-metafields__link" href={text} target="_blank" rel="noopener noreferrer">
|
|
88
|
+
{text}
|
|
89
|
+
</a>
|
|
90
|
+
) : (
|
|
91
|
+
<p className="core-metafields__text">{text}</p>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (type === 'media_id') {
|
|
96
|
+
return /^https?:\/\//i.test(text) ? (
|
|
97
|
+
// Media values arrive as resolved URLs (media contract) — plain <img>:
|
|
98
|
+
// this is core, not a theme, so the themes' <Image> rule doesn't apply.
|
|
99
|
+
<img className="core-metafields__image" src={text} alt={label} loading="lazy" />
|
|
100
|
+
) : (
|
|
101
|
+
<p className="core-metafields__text">{text}</p>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (type === 'date') {
|
|
106
|
+
const time = new Date(text).getTime();
|
|
107
|
+
return (
|
|
108
|
+
<p className="core-metafields__text">
|
|
109
|
+
{Number.isNaN(time) ? text : new Date(time).toLocaleDateString()}
|
|
110
|
+
</p>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (type === 'multi_line_text' || type === 'rich_text') {
|
|
115
|
+
return <pre className="core-metafields__pre">{text}</pre>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Entry sub-fields carry no per-field type — an image-looking URL renders
|
|
119
|
+
// as an image, everything else as text.
|
|
120
|
+
if (type === '' && IMAGE_URL_PATTERN.test(text)) {
|
|
121
|
+
return <img className="core-metafields__image" src={text} alt={label} loading="lazy" />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return <p className="core-metafields__text">{text}</p>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderEntryField(value: MetafieldScalar | MetafieldScalar[], fieldKey: string): JSX.Element {
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
return (
|
|
130
|
+
<ul className="core-metafields__list">
|
|
131
|
+
{value.map((item, index) => (
|
|
132
|
+
// Entry values are flat scalars; the index is stable per render.
|
|
133
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
134
|
+
<li key={index} className="core-metafields__list-item">
|
|
135
|
+
{typeof item === 'boolean' ? (item ? 'Yes' : 'No') : String(item)}
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return renderScalar(value, fieldKey, humaniseMetafieldKey(fieldKey), '');
|
|
143
|
+
}
|
|
144
|
+
|
|
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 {
|
|
153
|
+
const fields = entry.fields && typeof entry.fields === 'object' ? entry.fields : {};
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<article className="core-metafields__entry-card">
|
|
157
|
+
<h4 className="core-metafields__entry-title">{entry.display_name}</h4>
|
|
158
|
+
{Object.entries(fields).map(([fieldKey, fieldValue]) => (
|
|
159
|
+
<div className="core-metafields__entry-field" key={fieldKey}>
|
|
160
|
+
<span className="core-metafields__entry-field-label">{humaniseMetafieldKey(fieldKey)}</span>
|
|
161
|
+
<div className="core-metafields__entry-field-value">
|
|
162
|
+
{renderEntryField(fieldValue as MetafieldScalar | MetafieldScalar[], fieldKey)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</article>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
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 {
|
|
177
|
+
if (isEntry(value)) {
|
|
178
|
+
return <EntryCard entry={value} />;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (Array.isArray(value)) {
|
|
182
|
+
if (value.length > 0 && value.every((item) => isEntry(item))) {
|
|
183
|
+
return (
|
|
184
|
+
<div className="core-metafields__entry-grid">
|
|
185
|
+
{(value as MetaobjectEntry[]).map((entry) => (
|
|
186
|
+
<EntryCard key={`${entry.type}:${entry.p_id}`} entry={entry} />
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<ul className="core-metafields__list">
|
|
194
|
+
{(value as MetafieldScalar[]).map((item, index) => (
|
|
195
|
+
// Presented lists are value snapshots; the index is stable per render.
|
|
196
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
197
|
+
<li key={index} className="core-metafields__list-item">
|
|
198
|
+
{typeof item === 'boolean' ? (item ? 'Yes' : 'No') : String(item)}
|
|
199
|
+
</li>
|
|
200
|
+
))}
|
|
201
|
+
</ul>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return renderScalar(value as MetafieldScalar, fieldKey, label, type);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function ProductMetafields({ product, definitions }: ProductMetafieldsProps): JSX.Element | null {
|
|
209
|
+
const metafields = product.metafields && typeof product.metafields === 'object' ? product.metafields : {};
|
|
210
|
+
const visible = Object.entries(metafields).filter(([, value]) => isVisibleValue(value as MetafieldValue));
|
|
211
|
+
|
|
212
|
+
if (visible.length === 0) return null;
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<section className="core-metafields" aria-label="Product details">
|
|
216
|
+
{visible.map(([key, value]) => (
|
|
217
|
+
<div className="core-metafields__field" key={key}>
|
|
218
|
+
<h3 className="core-metafields__label">{labelFor(key, definitions)}</h3>
|
|
219
|
+
<div className="core-metafields__value">
|
|
220
|
+
{renderValue(value as MetafieldValue, key, labelFor(key, definitions), typeFor(key, definitions))}
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
))}
|
|
224
|
+
</section>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulls in Next.js's own published global types (RequestInit.next, image
|
|
3
|
+
* types, …) from the `next` PEER dependency — the same
|
|
4
|
+
* `/// <reference types="next" />` every Next app gets via next-env.d.ts.
|
|
5
|
+
*
|
|
6
|
+
* The kit runs inside Next apps, so depending on Next's own type package is
|
|
7
|
+
* correct. Depending on the storefront's next-env.d.ts (as the kit implicitly
|
|
8
|
+
* did before extraction — the file the kit free-rode on when 0.1.0 shipped
|
|
9
|
+
* broken) is what must never happen again. This file is that dependency,
|
|
10
|
+
* written down.
|
|
11
|
+
*/
|
|
12
|
+
/// <reference types="next" />
|
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequeek/theme-kit",
|
|
3
|
-
"version": "0.1.
|
|
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.",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/usequeek/
|
|
10
|
-
"directory": "packages/theme-kit"
|
|
9
|
+
"url": "git+https://github.com/usequeek/theme-kit.git"
|
|
11
10
|
},
|
|
12
11
|
"publishConfig": {
|
|
13
12
|
"access": "public"
|
|
@@ -38,5 +37,22 @@
|
|
|
38
37
|
"react-markdown": "^10.0.0",
|
|
39
38
|
"remark-gfm": "^4.0.0",
|
|
40
39
|
"html-to-image": "^1.11.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@queekai/client-sdk": "^0.3.0",
|
|
43
|
+
"@types/node": "^20",
|
|
44
|
+
"@types/react": "^19",
|
|
45
|
+
"@types/react-dom": "^19",
|
|
46
|
+
"next": "^16.0.0",
|
|
47
|
+
"react": "^19.2.3",
|
|
48
|
+
"react-dom": "^19.2.3",
|
|
49
|
+
"tsx": "^4.21.0",
|
|
50
|
+
"typescript": "^5",
|
|
51
|
+
"vitest": "^3.2.4"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"typecheck": "tsc --noEmit",
|
|
56
|
+
"verify-package": "tsx scripts/verify-package.ts"
|
|
41
57
|
}
|
|
42
58
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumes the kit the way an OUTSIDER does, from a packed tarball.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. @usequeek/theme-kit@0.1.0 was published and was broken for
|
|
5
|
+
* every external consumer:
|
|
6
|
+
*
|
|
7
|
+
* node_modules/@usequeek/theme-kit/provider.tsx(58,5):
|
|
8
|
+
* error TS2503: Cannot find namespace 'JSX'.
|
|
9
|
+
*
|
|
10
|
+
* 42 modules annotated returns as `JSX.Element`, relying on a GLOBAL JSX
|
|
11
|
+
* namespace that came from the storefront app's `next-env.d.ts` — not from
|
|
12
|
+
* React. Inside the storefront repo the kit free-rode on that file, so tsc,
|
|
13
|
+
* the tests, the build and the render smoke were ALL green. The workspace
|
|
14
|
+
* supplies exactly the file whose absence is the bug, which is why no in-repo
|
|
15
|
+
* check could ever have found it. (Every module now does
|
|
16
|
+
* `import type { JSX } from 'react'`, and this script proves it.)
|
|
17
|
+
*
|
|
18
|
+
* It packs the CURRENT SOURCE rather than installing the published version, so
|
|
19
|
+
* it fails before a broken version ships instead of after. Installing from the
|
|
20
|
+
* registry would only ever test the last release.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { dirname, join, resolve } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
// The kit package is `"type": "module"`, so `__dirname` does not exist here.
|
|
29
|
+
// (The storefront copy of this script could use it only because that package
|
|
30
|
+
// is CJS.)
|
|
31
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const KIT = ROOT;
|
|
33
|
+
|
|
34
|
+
function run(cmd: string, args: string[], cwd: string): string {
|
|
35
|
+
return execFileSync(cmd, args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function main(): void {
|
|
39
|
+
const sandbox = mkdtempSync(join(tmpdir(), 'kit-consumer-'));
|
|
40
|
+
let failed = false;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const kitPkg = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf-8')) as {
|
|
44
|
+
name: string; version: string; peerDependencies?: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
console.log(`verify-package: packing ${kitPkg.name}@${kitPkg.version} and consuming it as an outsider`);
|
|
48
|
+
run('npm', ['pack', '--pack-destination', sandbox], KIT);
|
|
49
|
+
const tarball = readdirSync(sandbox).find((f) => f.endsWith('.tgz'));
|
|
50
|
+
if (!tarball) throw new Error('npm pack produced no tarball');
|
|
51
|
+
|
|
52
|
+
// Peers are declared by the kit; pin the ones the fixture needs to compile.
|
|
53
|
+
const peers = kitPkg.peerDependencies ?? {};
|
|
54
|
+
writeFileSync(join(sandbox, 'package.json'), JSON.stringify({
|
|
55
|
+
name: 'kit-consumer', private: true, version: '1.0.0',
|
|
56
|
+
dependencies: {
|
|
57
|
+
[kitPkg.name]: `file:./${tarball}`,
|
|
58
|
+
'@queekai/client-sdk': peers['@queekai/client-sdk'] ?? '*',
|
|
59
|
+
next: '^16.0.0', react: '^19.0.0', 'react-dom': '^19.0.0',
|
|
60
|
+
},
|
|
61
|
+
devDependencies: { typescript: '^5', '@types/react': '^19', '@types/react-dom': '^19', '@types/node': '^22' },
|
|
62
|
+
}, null, 2));
|
|
63
|
+
|
|
64
|
+
// `bundler` resolution + no ambient Next types — exactly what a theme
|
|
65
|
+
// developer's own project looks like. NOT this repo's tsconfig.
|
|
66
|
+
writeFileSync(join(sandbox, 'tsconfig.json'), JSON.stringify({
|
|
67
|
+
compilerOptions: {
|
|
68
|
+
target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'], jsx: 'react-jsx',
|
|
69
|
+
module: 'esnext', moduleResolution: 'bundler', strict: true, noEmit: true,
|
|
70
|
+
skipLibCheck: true, esModuleInterop: true, isolatedModules: true,
|
|
71
|
+
},
|
|
72
|
+
include: ['app/**/*.ts', 'app/**/*.tsx'],
|
|
73
|
+
}, null, 2));
|
|
74
|
+
|
|
75
|
+
mkdirSync(join(sandbox, 'app'), { recursive: true });
|
|
76
|
+
writeFileSync(join(sandbox, 'app/theme.tsx'), readFileSync(join(ROOT, 'tests/fixtures/external-theme.tsx'), 'utf-8'));
|
|
77
|
+
|
|
78
|
+
console.log('verify-package: installing from the tarball…');
|
|
79
|
+
run('npm', ['install', '--no-audit', '--no-fund'], sandbox);
|
|
80
|
+
|
|
81
|
+
console.log('verify-package: typechecking a real theme against it…');
|
|
82
|
+
try {
|
|
83
|
+
run(join(sandbox, 'node_modules/.bin/tsc'), ['--noEmit'], sandbox);
|
|
84
|
+
console.log(`\n✓ ${kitPkg.name} is consumable from outside this repo.`);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const out = (error as { stdout?: string; stderr?: string });
|
|
87
|
+
console.error('\n✗ the packed kit does NOT work for an external consumer:\n');
|
|
88
|
+
console.error((out.stdout ?? '') + (out.stderr ?? ''));
|
|
89
|
+
console.error('Errors inside node_modules/@usequeek/theme-kit are PACKAGE bugs — the kit is');
|
|
90
|
+
console.error('relying on something only this repo provides. Errors in app/theme.tsx mean the');
|
|
91
|
+
console.error('fixture drifted from the real API.');
|
|
92
|
+
failed = true;
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error('✗ verify-package failed to run:', (error as Error).message);
|
|
96
|
+
failed = true;
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
process.exit(failed ? 1 : 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main();
|
package/sdk/queek-client.ts
CHANGED
|
@@ -39,11 +39,40 @@ const platformFetch: typeof fetch = (input, init) => {
|
|
|
39
39
|
if (init?.headers) {
|
|
40
40
|
const headers = new Headers(init.headers);
|
|
41
41
|
headers.set('X-Platform', getActivePlatform());
|
|
42
|
-
return fetch(input, { ...init, headers });
|
|
42
|
+
return fetch(input, { ...init, headers }).then(captureRequestId);
|
|
43
43
|
}
|
|
44
|
-
return fetch(input, init);
|
|
44
|
+
return fetch(input, init).then(captureRequestId);
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Every API response carries a backend request id. The SDK's `request()` returns
|
|
49
|
+
* only the parsed body, so this wrapper — the one place every SDK call's raw
|
|
50
|
+
* Response passes through — is where it can be read at all. Kept as the last-seen
|
|
51
|
+
* value: the storefront has no error-reporting integration (no Sentry), so the
|
|
52
|
+
* only consumer today is the checkout failure log, which is exactly the report a
|
|
53
|
+
* customer support ticket needs to be traceable to a backend request.
|
|
54
|
+
*/
|
|
55
|
+
const REQUEST_ID_HEADERS = ['x-request-id', 'x-correlation-id', 'request-id'];
|
|
56
|
+
|
|
57
|
+
let lastRequestId: string | null = null;
|
|
58
|
+
|
|
59
|
+
function captureRequestId(response: Response): Response {
|
|
60
|
+
for (const name of REQUEST_ID_HEADERS) {
|
|
61
|
+
const value = response.headers.get(name);
|
|
62
|
+
if (value) {
|
|
63
|
+
lastRequestId = value;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The backend request id of the most recent SDK call, when it sent one. */
|
|
72
|
+
export function getLastRequestId(): string | null {
|
|
73
|
+
return lastRequestId;
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
let cachedClient: QueekClientInstance | null = null;
|
|
48
77
|
let cachedSlug: string | null = null;
|
|
49
78
|
|
|
@@ -105,6 +105,19 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
|
|
|
105
105
|
const product = /^\/products\/([^/]+)(?:\/related)?$/.exec(path);
|
|
106
106
|
if (product) return policy(60, 'products', `product:${product[1]}`);
|
|
107
107
|
|
|
108
|
+
// Product metafield definitions (the labels/types the core-owned
|
|
109
|
+
// <ProductMetafields /> section renders values under). Merchant-edited,
|
|
110
|
+
// shared across visitors — cached like pages, busted by revalidation.
|
|
111
|
+
if (path === '/metafield-definitions') return policy(300, 'metafields');
|
|
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
|
+
|
|
108
121
|
return null;
|
|
109
122
|
}
|
|
110
123
|
|