@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 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.** 78 of its 115 modules carry a
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/core-public-surface.test.ts` in the storefront repo instead — which is
19
- where the real contract lives anyway. Revisit if this is ever published.
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 enforced by `tests/core-storefront-boundary.test.ts` in the
37
- storefront repo, and it is why this package can be extracted at all.
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.5",
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/queek-storefront.git",
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();
@@ -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