@usequeek/theme-kit 0.1.5 → 0.1.6
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 +1 -0
- package/components/product-metafields.tsx +213 -0
- package/next-reference.d.ts +12 -0
- package/package.json +19 -3
- package/scripts/verify-package.ts +104 -0
- package/sdk/queek-client.ts +31 -2
- package/sdk/server-store-client.ts +5 -0
- package/tests/fixtures/external-theme.tsx +69 -0
- package/tests/markdown-render.test.ts +47 -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/product.ts +31 -0
- package/types/theme.ts +7 -0
- 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,7 @@ 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';
|
|
15
16
|
export * from './components/product-media-frame';
|
|
16
17
|
export * from './components/variant-picker';
|
|
17
18
|
export * from './components/auth/auth-flow';
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
function EntryCard({ entry }: { entry: MetaobjectEntry }): JSX.Element {
|
|
146
|
+
const fields = entry.fields && typeof entry.fields === 'object' ? entry.fields : {};
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<article className="core-metafields__entry-card">
|
|
150
|
+
<h4 className="core-metafields__entry-title">{entry.display_name}</h4>
|
|
151
|
+
{Object.entries(fields).map(([fieldKey, fieldValue]) => (
|
|
152
|
+
<div className="core-metafields__entry-field" key={fieldKey}>
|
|
153
|
+
<span className="core-metafields__entry-field-label">{humaniseMetafieldKey(fieldKey)}</span>
|
|
154
|
+
<div className="core-metafields__entry-field-value">
|
|
155
|
+
{renderEntryField(fieldValue as MetafieldScalar | MetafieldScalar[], fieldKey)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
</article>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderValue(value: MetafieldValue, fieldKey: string, label: string, type: string): JSX.Element {
|
|
164
|
+
if (isEntry(value)) {
|
|
165
|
+
return <EntryCard entry={value} />;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (Array.isArray(value)) {
|
|
169
|
+
if (value.length > 0 && value.every((item) => isEntry(item))) {
|
|
170
|
+
return (
|
|
171
|
+
<div className="core-metafields__entry-grid">
|
|
172
|
+
{(value as MetaobjectEntry[]).map((entry) => (
|
|
173
|
+
<EntryCard key={`${entry.type}:${entry.p_id}`} entry={entry} />
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<ul className="core-metafields__list">
|
|
181
|
+
{(value as MetafieldScalar[]).map((item, index) => (
|
|
182
|
+
// Presented lists are value snapshots; the index is stable per render.
|
|
183
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
184
|
+
<li key={index} className="core-metafields__list-item">
|
|
185
|
+
{typeof item === 'boolean' ? (item ? 'Yes' : 'No') : String(item)}
|
|
186
|
+
</li>
|
|
187
|
+
))}
|
|
188
|
+
</ul>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return renderScalar(value as MetafieldScalar, fieldKey, label, type);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function ProductMetafields({ product, definitions }: ProductMetafieldsProps): JSX.Element | null {
|
|
196
|
+
const metafields = product.metafields && typeof product.metafields === 'object' ? product.metafields : {};
|
|
197
|
+
const visible = Object.entries(metafields).filter(([, value]) => isVisibleValue(value as MetafieldValue));
|
|
198
|
+
|
|
199
|
+
if (visible.length === 0) return null;
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<section className="core-metafields" aria-label="Product details">
|
|
203
|
+
{visible.map(([key, value]) => (
|
|
204
|
+
<div className="core-metafields__field" key={key}>
|
|
205
|
+
<h3 className="core-metafields__label">{labelFor(key, definitions)}</h3>
|
|
206
|
+
<div className="core-metafields__value">
|
|
207
|
+
{renderValue(value as MetafieldValue, key, labelFor(key, definitions), typeFor(key, definitions))}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
))}
|
|
211
|
+
</section>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -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.6",
|
|
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
|
}
|
|
@@ -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,11 @@ 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
|
+
|
|
108
113
|
return null;
|
|
109
114
|
}
|
|
110
115
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A real theme, written the way an external developer would, against the
|
|
2
|
+
// PUBLISHED package only — no access to the storefront repo.
|
|
3
|
+
import type { JSX } from 'react';
|
|
4
|
+
import type { ThemeModule, HeaderProps, FooterProps } from '@usequeek/theme-kit/types/theme';
|
|
5
|
+
import type { Product } from '@usequeek/theme-kit/types/product';
|
|
6
|
+
import { useCart } from '@usequeek/theme-kit/hooks/use-cart';
|
|
7
|
+
import { useProducts } from '@usequeek/theme-kit/hooks/use-products';
|
|
8
|
+
import { useStorefront } from '@usequeek/theme-kit/provider';
|
|
9
|
+
import { useHref } from '@usequeek/theme-kit/hooks/use-href';
|
|
10
|
+
import { formatMoney } from '@usequeek/theme-kit/utils/format';
|
|
11
|
+
import { Image } from '@usequeek/theme-kit/components/image';
|
|
12
|
+
import { ThemeMount } from '@usequeek/theme-kit/theme-mount';
|
|
13
|
+
|
|
14
|
+
export function Header({ menu, showCart }: HeaderProps): JSX.Element {
|
|
15
|
+
const { vendor } = useStorefront();
|
|
16
|
+
const { items } = useCart();
|
|
17
|
+
const href = useHref();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<header>
|
|
21
|
+
<a href={href('/')}>{vendor?.name}</a>
|
|
22
|
+
{menu.map((item) => <a key={item.label} href={item.ref ?? '#'}>{item.label}</a>)}
|
|
23
|
+
{showCart ? <span>{items.length}</span> : null}
|
|
24
|
+
</header>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Footer({ copyright }: FooterProps): JSX.Element {
|
|
29
|
+
return <footer>{copyright}</footer>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ProductCard({ product }: { product: Product }): JSX.Element {
|
|
33
|
+
const { addProduct } = useCart();
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<article>
|
|
37
|
+
<Image src={product.media.thumbnail ?? ''} alt={product.title} />
|
|
38
|
+
<h3>{product.title}</h3>
|
|
39
|
+
<span>{formatMoney(product.pricing.sale_amount, product.currency)}</span>
|
|
40
|
+
<button type="button" onClick={() => addProduct(product, 1)}>Add to cart</button>
|
|
41
|
+
</article>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Grid(): JSX.Element {
|
|
46
|
+
const { products, isLoading } = useProducts();
|
|
47
|
+
if (isLoading) return <p>Loading…</p>;
|
|
48
|
+
|
|
49
|
+
return <div>{products.map((p) => <ProductCard key={p.id} product={p} />)}</div>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const theme: Pick<ThemeModule, 'Header' | 'Footer'> = { Header, Footer };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Mounting the theme — the thing an external developer could not do before
|
|
56
|
+
* ThemeMount existed, because the component that does it lived in the
|
|
57
|
+
* storefront app rather than the package.
|
|
58
|
+
*/
|
|
59
|
+
export function Preview({ theme: mod, children }: { theme: ThemeModule; children: JSX.Element }): JSX.Element {
|
|
60
|
+
return (
|
|
61
|
+
<ThemeMount
|
|
62
|
+
theme={mod}
|
|
63
|
+
headerProps={{ announcement: null, logo: null, menu: [], showSearch: true, showCart: true, showAccount: true, showOffers: true }}
|
|
64
|
+
footerProps={{ copyright: '© Demo Store' }}
|
|
65
|
+
>
|
|
66
|
+
{children}
|
|
67
|
+
</ThemeMount>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { renderMarkdown } from '../utils/markdown';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backend CT16 (queek_backend/.agent/frontend-chat/sessions/storefront-contact-block/chat.json):
|
|
8
|
+
* a heading immediately followed by other content, with no blank line between them, gets
|
|
9
|
+
* swallowed whole into the heading — `utils/markdown.tsx` used to treat an entire
|
|
10
|
+
* blank-line-delimited block as heading text the moment it matched a heading prefix. Real
|
|
11
|
+
* merchant copy (Jhema Wears' short_description) writes "### Our Offerings\n- item\n- item"
|
|
12
|
+
* with no blank line, which is common, valid markdown — the list must not disappear into the h3.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function html(markdown: string): string {
|
|
16
|
+
return renderToStaticMarkup(React.createElement(React.Fragment, null, ...renderMarkdown(markdown)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('renderMarkdown heading + trailing-content handling', () => {
|
|
20
|
+
it('a heading immediately followed by a list (no blank line) renders both, not one heading swallowing the list', () => {
|
|
21
|
+
const out = html(
|
|
22
|
+
'### Our Offerings\n- Latest fashion apparel for men, women, and children\n- Accessories to complement every outfit',
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(out).toContain('<h3');
|
|
26
|
+
expect(out).toContain('Our Offerings');
|
|
27
|
+
expect(out).toContain('<ul');
|
|
28
|
+
expect(out).toContain('<li>Latest fashion apparel for men, women, and children</li>');
|
|
29
|
+
expect(out).toContain('<li>Accessories to complement every outfit</li>');
|
|
30
|
+
expect(out).not.toMatch(/<h3[^>]*>[^<]*Latest fashion/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('a heading immediately followed by a paragraph (no blank line) renders both, not one heading swallowing the text', () => {
|
|
34
|
+
const out = html('## Our Story\nWe hand-pick every piece.');
|
|
35
|
+
|
|
36
|
+
expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('a heading with nothing after it still renders alone (regression guard)', () => {
|
|
40
|
+
expect(html('## Our Story')).toBe('<h2>Our Story</h2>');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('a heading followed by a blank-line-separated paragraph still renders as two blocks (regression guard)', () => {
|
|
44
|
+
const out = html('## Our Story\n\nWe hand-pick every piece.');
|
|
45
|
+
expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { ProductMetafields } from '../components/product-metafields';
|
|
5
|
+
import type { MetafieldValue, Product } from '../types/product';
|
|
6
|
+
|
|
7
|
+
function makeProduct(metafields?: Record<string, MetafieldValue>): Product {
|
|
8
|
+
return {
|
|
9
|
+
shop_id: 'shop-1',
|
|
10
|
+
id: 'product-1',
|
|
11
|
+
p_id: 1,
|
|
12
|
+
title: 'Test product',
|
|
13
|
+
slug: 'test-product',
|
|
14
|
+
excerpt: null,
|
|
15
|
+
description: null,
|
|
16
|
+
kind: 'physical',
|
|
17
|
+
status: 'active',
|
|
18
|
+
barcode: null,
|
|
19
|
+
currency: 'NGN',
|
|
20
|
+
pricing: {
|
|
21
|
+
base_amount: 1000,
|
|
22
|
+
sale_amount: 800,
|
|
23
|
+
compare_at_amount: 1000,
|
|
24
|
+
discount_amount: 200,
|
|
25
|
+
discount_percent: 20,
|
|
26
|
+
is_price_from: false,
|
|
27
|
+
tax_inclusive: false,
|
|
28
|
+
price_range: { min_amount: 800, max_amount: 800 },
|
|
29
|
+
},
|
|
30
|
+
shop: { id: 'shop-1', name: 'Shop', slug: 'shop', logo: null, rating: 0, rating_count: 0 },
|
|
31
|
+
media: { thumbnail: null, image: null },
|
|
32
|
+
inventory: { in_stock: true, tracking: false, quantity: 5 },
|
|
33
|
+
variants_count: 0,
|
|
34
|
+
review_summary: { rating: 0, review_count: 0 },
|
|
35
|
+
flags: { featured: false, is_marketplace: false, is_wholesale: false, is_new: false },
|
|
36
|
+
categories: [],
|
|
37
|
+
options: [],
|
|
38
|
+
variants: [],
|
|
39
|
+
addons: [],
|
|
40
|
+
reviews: [],
|
|
41
|
+
metafields,
|
|
42
|
+
created_at: null,
|
|
43
|
+
updated_at: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(metafields?: Record<string, MetafieldValue>, definitions?: Record<string, { name: string; type: string }>): string {
|
|
48
|
+
return renderToStaticMarkup(React.createElement(ProductMetafields, { product: makeProduct(metafields), definitions }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('ProductMetafields', () => {
|
|
52
|
+
it('renders nothing when the product has no metafields', () => {
|
|
53
|
+
expect(render(undefined)).toBe('');
|
|
54
|
+
expect(render({})).toBe('');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('renders nothing when every value is empty', () => {
|
|
58
|
+
expect(render({ fabric: '', tags: [] })).toBe('');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders text with the definition label', () => {
|
|
62
|
+
const html = render(
|
|
63
|
+
{ fabric: 'Ankara cotton' },
|
|
64
|
+
{ fabric: { name: 'Fabric', type: 'single_line_text' } },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(html).toContain('core-metafields');
|
|
68
|
+
expect(html).toContain('Fabric');
|
|
69
|
+
expect(html).toContain('Ankara cotton');
|
|
70
|
+
expect(html).toContain('<p');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('falls back to a humanised key label for unknown handles', () => {
|
|
74
|
+
const html = render({ care_instructions: 'Hand wash only' });
|
|
75
|
+
|
|
76
|
+
expect(html).toContain('Care instructions');
|
|
77
|
+
expect(html).toContain('Hand wash only');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('renders multi_line and rich text preformatted', () => {
|
|
81
|
+
expect(render({ notes: 'line one\nline two' }, { notes: { name: 'Notes', type: 'multi_line_text' } })).toContain('<pre');
|
|
82
|
+
expect(render({ story: '<p>rich</p>' }, { story: { name: 'Story', type: 'rich_text' } })).toContain('<pre');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders scalar lists as bullets', () => {
|
|
86
|
+
const html = render({ tags: ['cotton', 'handmade'] }, { tags: { name: 'Tags', type: 'list.single_line_text' } });
|
|
87
|
+
|
|
88
|
+
expect(html).toContain('<ul');
|
|
89
|
+
expect(html).toContain('<li');
|
|
90
|
+
expect(html).toContain('cotton');
|
|
91
|
+
expect(html).toContain('handmade');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders booleans as Yes/No', () => {
|
|
95
|
+
expect(render({ gift: true }, { gift: { name: 'Gift wrap', type: 'boolean' } })).toContain('>Yes<');
|
|
96
|
+
expect(render({ gift: false }, { gift: { name: 'Gift wrap', type: 'boolean' } })).toContain('>No<');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('renders dates localised instead of raw ISO', () => {
|
|
100
|
+
const html = render({ drop: '2026-09-20' }, { drop: { name: 'Drop date', type: 'date' } });
|
|
101
|
+
|
|
102
|
+
expect(html).not.toContain('2026-09-20');
|
|
103
|
+
expect(html).toContain('2026');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('renders urls as links', () => {
|
|
107
|
+
const html = render({ lookbook: 'https://example.com/look' }, { lookbook: { name: 'Lookbook', type: 'url' } });
|
|
108
|
+
|
|
109
|
+
expect(html).toContain('<a');
|
|
110
|
+
expect(html).toContain('href="https://example.com/look"');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders media_id as an image', () => {
|
|
114
|
+
const html = render({ swatch: 'https://cdn.example.com/swatch.jpg' }, { swatch: { name: 'Swatch', type: 'media_id' } });
|
|
115
|
+
|
|
116
|
+
expect(html).toContain('<img');
|
|
117
|
+
expect(html).toContain('src="https://cdn.example.com/swatch.jpg"');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders a linked entry as a card with its fields', () => {
|
|
121
|
+
const html = render(
|
|
122
|
+
{
|
|
123
|
+
designer: {
|
|
124
|
+
p_id: 1000,
|
|
125
|
+
type: 'designer',
|
|
126
|
+
handle: 'adaeze',
|
|
127
|
+
display_name: 'Adaeze',
|
|
128
|
+
fields: { name: 'Adaeze', instagram: '@adaeze' },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{ designer: { name: 'Designer', type: 'metaobject_reference' } },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(html).toContain('core-metafields__entry-card');
|
|
135
|
+
expect(html).toContain('Adaeze');
|
|
136
|
+
expect(html).toContain('@adaeze');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('renders a list of entries as a grid of cards', () => {
|
|
140
|
+
const html = render(
|
|
141
|
+
{
|
|
142
|
+
ingredients: [
|
|
143
|
+
{ p_id: 1, type: 'ingredient', handle: 'shea', display_name: 'Shea', fields: { name: 'Shea' } },
|
|
144
|
+
{ p_id: 2, type: 'ingredient', handle: 'cocoa', display_name: 'Cocoa', fields: { name: 'Cocoa' } },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{ ingredients: { name: 'Ingredients', type: 'list.metaobject_reference' } },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(html).toContain('core-metafields__entry-grid');
|
|
151
|
+
expect(html).toContain('Shea');
|
|
152
|
+
expect(html).toContain('Cocoa');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const ROOT = resolve(__dirname, '..');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The barrels re-export by RELATIVE path — the kit carries no `@/` alias, so it
|
|
9
|
+
* resolves when imported from outside this repo. `./types/theme` and
|
|
10
|
+
* `@usequeek/theme-kit/types/theme` name the same module; normalise to the second.
|
|
11
|
+
*/
|
|
12
|
+
function barrelModules(file: string): Set<string> {
|
|
13
|
+
const source = readFileSync(join(ROOT, file), 'utf8');
|
|
14
|
+
return new Set(
|
|
15
|
+
[...source.matchAll(/from '\.\/([^']+)'/g)].map((m) => m[1]),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `index.ts` + `client.ts` declare the surface a theme may build against — the
|
|
21
|
+
* set that ships as `@usequeek/theme-kit`.
|
|
22
|
+
*
|
|
23
|
+
* This is the kit-internal half of the contract: every module the barrels
|
|
24
|
+
* declare must exist. (The storefront repo holds the other half —
|
|
25
|
+
* `core-public-surface.test.ts` there asserts the barrels cover every module
|
|
26
|
+
* the themes actually import.)
|
|
27
|
+
*/
|
|
28
|
+
describe('core public surface (kit-internal)', () => {
|
|
29
|
+
const declared = new Set([...barrelModules('index.ts'), ...barrelModules('client.ts')]);
|
|
30
|
+
|
|
31
|
+
it('declares nothing that no longer exists', () => {
|
|
32
|
+
const missing = [...declared].filter(
|
|
33
|
+
(mod) => !['.ts', '.tsx', '/index.ts', '/index.tsx'].some((ext) => existsSync(join(ROOT, mod + ext))),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(missing).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -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/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
|
@@ -63,6 +63,13 @@ export interface CollectionPageProps {
|
|
|
63
63
|
|
|
64
64
|
export interface ProductPageProps {
|
|
65
65
|
product: Product;
|
|
66
|
+
/**
|
|
67
|
+
* Product metafield definitions keyed by metafield key — the labels (and
|
|
68
|
+
* types) the core-owned `<ProductMetafields />` section renders values
|
|
69
|
+
* under. OPTIONAL so external themes that destructure `{ product }` keep
|
|
70
|
+
* compiling; unknown keys fall back to a humanised key label.
|
|
71
|
+
*/
|
|
72
|
+
metafieldDefinitions?: Record<string, { name: string; type: string }>;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export interface GalleryPageProps {
|
|
@@ -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
|
};
|