@usequeek/theme-check 0.1.0
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/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/context.d.ts +19 -0
- package/dist/context.js +98 -0
- package/dist/format.d.ts +20 -0
- package/dist/format.js +75 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/parity/field-parity.d.ts +11 -0
- package/dist/parity/field-parity.js +240 -0
- package/dist/parity/variant-parity.d.ts +28 -0
- package/dist/parity/variant-parity.js +167 -0
- package/dist/rules/analysis.d.ts +5 -0
- package/dist/rules/analysis.js +105 -0
- package/dist/rules/static.d.ts +41 -0
- package/dist/rules/static.js +961 -0
- package/dist/run.d.ts +25 -0
- package/dist/run.js +42 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.js +12 -0
- package/dist/utils/business-vocabulary.d.ts +6 -0
- package/dist/utils/business-vocabulary.js +18 -0
- package/dist/utils/business-vocabulary.json +20 -0
- package/dist/utils/theme-demo-images.d.ts +42 -0
- package/dist/utils/theme-demo-images.js +138 -0
- package/dist/utils/theme-demos.d.ts +28 -0
- package/dist/utils/theme-demos.js +66 -0
- package/dist/utils/theme-templates.d.ts +96 -0
- package/dist/utils/theme-templates.js +243 -0
- package/package.json +55 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { foreignImageRefs } from '../utils/theme-demo-images.js';
|
|
4
|
+
import { DEMO_ID_FORMAT, PRIMARY_DEMO_ID } from '../utils/theme-demos.js';
|
|
5
|
+
import { SCREENSHOT_LOCK, TEMPLATE_DESCRIPTION_MAX, TEMPLATE_DESCRIPTION_PLACEHOLDER, isPresentationalField, screenshotFile, screenshotUrl } from '../utils/theme-templates.js';
|
|
6
|
+
import { BUSINESS_KEYS, isBusinessKey } from '../utils/business-vocabulary.js';
|
|
7
|
+
import { themeSourceFiles } from '../context.js';
|
|
8
|
+
import { finding } from '../types.js';
|
|
9
|
+
const REQUIRED_FILES = [
|
|
10
|
+
'index.ts', 'manifest.ts', 'theme.config.ts',
|
|
11
|
+
'layout.tsx', 'header.tsx', 'footer.tsx',
|
|
12
|
+
'theme.css', 'demo.json',
|
|
13
|
+
];
|
|
14
|
+
/** Theme-owned blocks only — divider/embed/video/table/button/image are framework-owned. */
|
|
15
|
+
const REQUIRED_BLOCKS = ['gallery', 'products', 'categories'];
|
|
16
|
+
const REQUIRED_PAGES = ['home', 'page', 'blog', 'post', 'collection', 'product', 'gallery-page'];
|
|
17
|
+
const REQUIRED_SHELLS = ['cart-shell', 'login-shell', 'signup-shell', 'account-shell'];
|
|
18
|
+
const SCREENSHOTS = ['theme.jpg', 'theme.png'];
|
|
19
|
+
/** Crude but sufficient: keeps a prose mention of a tag from reading as markup. */
|
|
20
|
+
function stripComments(source) {
|
|
21
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
22
|
+
}
|
|
23
|
+
export const structureRule = {
|
|
24
|
+
id: 'theme/structure',
|
|
25
|
+
summary: 'Every required file, block, page and shell is present',
|
|
26
|
+
kind: 'static',
|
|
27
|
+
run(context) {
|
|
28
|
+
const missing = (paths, label) => paths.filter((path) => !context.exists(path)).map((path) => finding(context, 'theme/structure', 'reject', {
|
|
29
|
+
where: `${context.env.root}${path}`,
|
|
30
|
+
found: `missing ${label}: ${path}`,
|
|
31
|
+
fix: `Create ${path}. ${context.env.scaffold} scaffolds a complete set — copy the shape from it.`,
|
|
32
|
+
docs: `${context.env.docs}#required-files`,
|
|
33
|
+
}));
|
|
34
|
+
// A chrome-only theme (declares no page-block variants — themes/default)
|
|
35
|
+
// renders one page and lets core supply the rest, so the page-based
|
|
36
|
+
// structure is not its contract. Derived from the manifest, not a name.
|
|
37
|
+
const findings = [
|
|
38
|
+
...missing(REQUIRED_FILES.filter((file) => file !== 'index.ts' || !context.exists('index.tsx')), 'required file'),
|
|
39
|
+
...(context.pageBased ? [
|
|
40
|
+
...missing(REQUIRED_BLOCKS.map((b) => `blocks/${b}.tsx`), 'theme-owned block'),
|
|
41
|
+
...missing(REQUIRED_PAGES.map((p) => `pages/${p}.tsx`), 'page'),
|
|
42
|
+
...missing(REQUIRED_SHELLS.map((s) => `shells/${s}.tsx`), 'shell'),
|
|
43
|
+
] : []),
|
|
44
|
+
];
|
|
45
|
+
if (!SCREENSHOTS.some(context.exists)) {
|
|
46
|
+
findings.push(finding(context, 'theme/structure', 'reject', {
|
|
47
|
+
where: `${context.env.root}`,
|
|
48
|
+
found: `no theme screenshot (${SCREENSHOTS.join(' or ')})`,
|
|
49
|
+
fix: `Capture the homepage at 1280×800 from ${context.env.preview('default')} and save it as theme.jpg.`,
|
|
50
|
+
docs: `${context.env.docs}#theme-png`,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
return findings;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
/** The stores a rule runs over, the primary first — plus a synthetic missing
|
|
57
|
+
* primary, so "there is no demo.json" is a finding and not a silent skip. */
|
|
58
|
+
function storesOf(context) {
|
|
59
|
+
const stores = [...context.demos];
|
|
60
|
+
if (!stores.some((store) => store.id === PRIMARY_DEMO_ID))
|
|
61
|
+
stores.unshift({ id: PRIMARY_DEMO_ID, file: 'demo.json', data: null });
|
|
62
|
+
return stores;
|
|
63
|
+
}
|
|
64
|
+
export const demoStoreRule = {
|
|
65
|
+
id: 'theme/demo-store',
|
|
66
|
+
summary: 'Every demo store is a believable store that identifies itself as this theme',
|
|
67
|
+
kind: 'static',
|
|
68
|
+
run(context) {
|
|
69
|
+
const findings = [];
|
|
70
|
+
for (const store of storesOf(context)) {
|
|
71
|
+
const at = `${context.env.root}${store.file}`;
|
|
72
|
+
const demo = store.data;
|
|
73
|
+
if (!demo) {
|
|
74
|
+
findings.push(finding(context, 'theme/demo-store', 'reject', {
|
|
75
|
+
where: at,
|
|
76
|
+
found: 'missing or not valid JSON',
|
|
77
|
+
fix: store.id === PRIMARY_DEMO_ID
|
|
78
|
+
? 'demo.json is both the vendor-facing preview and what the backend builder composes real stores from. It cannot be skipped.'
|
|
79
|
+
: 'An alternative store is previewed and published like the primary — it has to parse.',
|
|
80
|
+
docs: `${context.env.docs}#demojson`,
|
|
81
|
+
}));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const add = (found, fix) => {
|
|
85
|
+
findings.push(finding(context, 'theme/demo-store', 'reject', { where: at, found, fix, docs: `${context.env.docs}#demojson` }));
|
|
86
|
+
};
|
|
87
|
+
const profile = demo.profile;
|
|
88
|
+
const config = demo.config;
|
|
89
|
+
if (profile?.slug !== context.slug)
|
|
90
|
+
add(`profile.slug is "${profile?.slug ?? 'unset'}"`, `Set profile.slug to "${context.slug}".`);
|
|
91
|
+
if (config?.theme !== context.slug)
|
|
92
|
+
add(`config.theme is "${config?.theme ?? 'unset'}"`, `Set config.theme to "${context.slug}".`);
|
|
93
|
+
// The cart is keyed by the product's shop_id and the preview seeds it
|
|
94
|
+
// by profile.id. A product that belongs to another shop id cannot be
|
|
95
|
+
// bought in its own preview — and two stores that share one bleed
|
|
96
|
+
// their carts into each other.
|
|
97
|
+
const products = Array.isArray(demo.products) ? demo.products : [];
|
|
98
|
+
const foreign = [...new Set(products.map((product) => product.shop_id).filter((id) => id !== profile?.id))];
|
|
99
|
+
if (profile?.id && foreign.length > 0) {
|
|
100
|
+
add(`products carry shop_id ${foreign.map((id) => `"${id ?? 'unset'}"`).join(', ')} but profile.id is "${profile.id}"`, `Set every products[].shop_id to "${profile.id}" — the cart and the preview checkout key on it.`);
|
|
101
|
+
}
|
|
102
|
+
const count = (value) => (Array.isArray(value) ? value.length : 0);
|
|
103
|
+
const minimums = [
|
|
104
|
+
['products', count(demo.products), 6],
|
|
105
|
+
['categories', count(demo.categories), 3],
|
|
106
|
+
['posts', count(demo.posts), 1],
|
|
107
|
+
['menus', count(demo.menus), 1],
|
|
108
|
+
['pages', Object.keys(demo.pages ?? {}).length, 4],
|
|
109
|
+
];
|
|
110
|
+
for (const [key, actual, required] of minimums) {
|
|
111
|
+
if (actual < required) {
|
|
112
|
+
add(`${key}: ${actual}`, `Needs at least ${required}. A sparse demo store does not sell the theme, and the builder composes from it.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* The declaration in theme.config.ts and the files under demos/ are one list.
|
|
121
|
+
* A declared store with no file 404s in the preview the registry advertises;
|
|
122
|
+
* a file nobody declared is previewable but never offered; an id that is not
|
|
123
|
+
* a slug cannot be a URL segment; two stores on one profile.id share a cart.
|
|
124
|
+
*/
|
|
125
|
+
export const demoStoresRule = {
|
|
126
|
+
id: 'theme/demo-stores',
|
|
127
|
+
summary: 'demos/ and the `demos` declaration in theme.config.ts agree',
|
|
128
|
+
kind: 'static',
|
|
129
|
+
run(context) {
|
|
130
|
+
const findings = [];
|
|
131
|
+
const at = `${context.env.root}theme.config.ts`;
|
|
132
|
+
const add = (where, found, fix) => {
|
|
133
|
+
findings.push(finding(context, 'theme/demo-stores', 'reject', { where, found, fix, docs: `${context.env.docs}#alternative-demo-stores` }));
|
|
134
|
+
};
|
|
135
|
+
const declared = context.declaredDemos ?? [];
|
|
136
|
+
const secondaries = context.demos.filter((store) => store.id !== PRIMARY_DEMO_ID);
|
|
137
|
+
for (const demo of declared) {
|
|
138
|
+
const id = String(demo?.id ?? '');
|
|
139
|
+
if (id === PRIMARY_DEMO_ID) {
|
|
140
|
+
add(at, `demos[] declares "default" — "default" is reserved for demo.json`, 'Remove it; the primary store is demo.json and needs no declaration.');
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!DEMO_ID_FORMAT.test(id)) {
|
|
144
|
+
add(at, `demos[] id "${id}" is not a slug`, 'Use lowercase letters, digits and single hyphens — the id becomes a URL segment (`/<slug>~<id>`) and a filename.');
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!secondaries.some((store) => store.id === id)) {
|
|
148
|
+
add(at, `demos[] declares "${id}" but there is no file ${context.env.root}demos/${id}.json`, 'Add the store, or drop the declaration. The registry advertises a preview URL for every declared store.');
|
|
149
|
+
}
|
|
150
|
+
if (typeof demo.label !== 'string' || demo.label.trim() === '') {
|
|
151
|
+
add(at, `demos[] "${id}" has no label`, 'Give it the name a merchant sees, e.g. "Restaurant & takeaway".');
|
|
152
|
+
}
|
|
153
|
+
if (!Array.isArray(demo.for) || demo.for.length === 0 || demo.for.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
154
|
+
add(at, `demos[] "${id}" has no \`for\` business list`, `List the businesses this store is for, most specific first, from ${context.env.vocabulary} (service slugs like "foods", catalogue keys like "wigs-extensions-hair-accessories"). The backend offers it to matching vendors.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const store of secondaries) {
|
|
158
|
+
if (store.id === PRIMARY_DEMO_ID) {
|
|
159
|
+
add(`${context.env.root}${store.file}`, '"default" is reserved for demo.json', 'Rename the file — or fold it into demo.json if it is the primary.');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (!DEMO_ID_FORMAT.test(store.id)) {
|
|
163
|
+
add(`${context.env.root}${store.file}`, `"${store.id}" is not a slug`, 'Name the file with lowercase letters, digits and single hyphens — it becomes a URL segment.');
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (!declared.some((demo) => demo?.id === store.id)) {
|
|
167
|
+
add(`${context.env.root}${store.file}`, `demos/${store.id}.json is not declared in theme.config.ts`, `Add \`{ id: '${store.id}', label, for: [...] }\` to \`demos\` in theme.config.ts, or remove the file. An undeclared store is previewable but never offered to anyone.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const seen = new Map();
|
|
171
|
+
for (const store of context.demos) {
|
|
172
|
+
const id = store.data?.profile?.id;
|
|
173
|
+
if (!id)
|
|
174
|
+
continue;
|
|
175
|
+
const first = seen.get(id);
|
|
176
|
+
if (first) {
|
|
177
|
+
add(`${context.env.root}${store.file}`, `profile.id "${id}" is already used by ${first}`, 'Give each store its own profile.id (and matching products[].shop_id) — the cart is keyed by it, so two stores on one id share a cart in the preview.');
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
seen.set(id, store.file);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return findings;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
export const demoArtRule = {
|
|
187
|
+
id: 'theme/demo-art-hosting',
|
|
188
|
+
summary: 'Every demo image, in every store, is served from Queek — not a foreign host or a local file',
|
|
189
|
+
kind: 'static',
|
|
190
|
+
run(context) {
|
|
191
|
+
// Queek moves demo art onto its CDN when a theme is submitted, so a
|
|
192
|
+
// developer references any public URL; only the submission run checks it.
|
|
193
|
+
if (context.retired || !context.env.submission)
|
|
194
|
+
return [];
|
|
195
|
+
return context.demos.flatMap((store) => foreignImageRefs(store.data).map((ref) => finding(context, 'theme/demo-art-hosting', 'reject', {
|
|
196
|
+
where: `${context.env.root}${store.file}`,
|
|
197
|
+
found: `image served from elsewhere: ${ref}`,
|
|
198
|
+
fix: `Inside this repo, run \`yarn theme:check ${context.slug} --fix\` (or \`yarn theme:rehost-images --theme ${context.slug}\`) and commit the rewritten ${store.file}. Submitting from outside? Leave it — \`theme:pull\` rehosts your art at publish time. Your art reaches real stores through the registry, so it cannot ship on a host we do not control.`,
|
|
199
|
+
fixable: true,
|
|
200
|
+
docs: `${context.env.docs}#demo-art-hosting-mandatory`,
|
|
201
|
+
})));
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
export const codeQualityRule = {
|
|
205
|
+
id: 'theme/code-quality',
|
|
206
|
+
summary: 'Header wiring, client directives, and no cross-theme imports',
|
|
207
|
+
kind: 'static',
|
|
208
|
+
run(context) {
|
|
209
|
+
const findings = [];
|
|
210
|
+
const headerSources = () => {
|
|
211
|
+
const header = context.read('header.tsx') ?? '';
|
|
212
|
+
const dir = context.file('headers');
|
|
213
|
+
const variants = existsSync(dir)
|
|
214
|
+
? readdirSync(dir).map((name) => readFileSync(`${dir}/${name}`, 'utf8'))
|
|
215
|
+
: [];
|
|
216
|
+
return [header, ...variants].join('\n');
|
|
217
|
+
};
|
|
218
|
+
const headers = headerSources();
|
|
219
|
+
if (/<button[^>]*logo-btn/.test(headers) || /<button[^>]*brand"/.test(headers)) {
|
|
220
|
+
findings.push(finding(context, 'theme/code-quality', 'reject', {
|
|
221
|
+
where: `${context.env.root}header.tsx`,
|
|
222
|
+
found: 'the logo is a <button>',
|
|
223
|
+
fix: 'Use <Link href={`/${vendor.slug}`}>. A button is not navigable, crawlable, or middle-clickable.',
|
|
224
|
+
docs: `${context.env.docs}#component-rules`,
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
if (!/menuItemToHref|menuItem\(/.test(headers)) {
|
|
228
|
+
findings.push(finding(context, 'theme/code-quality', 'reject', {
|
|
229
|
+
where: `${context.env.root}header.tsx`,
|
|
230
|
+
found: 'menu links are built by hand',
|
|
231
|
+
fix: 'Use menuItemToHref() from @usequeek/theme-kit/utils/menu-link — it resolves subdomain vs path-based vendors.',
|
|
232
|
+
docs: `${context.env.docs}#component-rules`,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
for (const file of ['header.tsx', 'blocks/products.tsx', 'blocks/categories.tsx', 'blocks/gallery.tsx']) {
|
|
236
|
+
const source = context.read(file);
|
|
237
|
+
if (source && !source.startsWith("'use client'")) {
|
|
238
|
+
findings.push(finding(context, 'theme/code-quality', 'reject', {
|
|
239
|
+
where: `${context.env.root}${file}`,
|
|
240
|
+
found: 'uses hooks without a client directive',
|
|
241
|
+
fix: "Add 'use client' as the first line.",
|
|
242
|
+
docs: `${context.env.docs}#component-rules`,
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const path of themeSourceFiles(context.dir)) {
|
|
247
|
+
const source = readFileSync(path, 'utf8');
|
|
248
|
+
// JSX only, comments stripped: `<img>` is a perfectly normal thing to
|
|
249
|
+
// mention in a docblock, and three themes do.
|
|
250
|
+
if (path.endsWith('.tsx') && /<img[\s>]/.test(stripComments(source))) {
|
|
251
|
+
findings.push(finding(context, 'theme/code-quality', 'reject', {
|
|
252
|
+
where: relative(context.dir, path),
|
|
253
|
+
found: 'renders a raw <img>',
|
|
254
|
+
fix: "Use <Image /> from @usequeek/theme-kit/components/image. It carries the broken-image fallback, the Smart Placeholder for empty slots, and the srcset the backend ships as image_variants — a raw <img> silently gives up all three.",
|
|
255
|
+
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
const match = source.match(/from '(?:@\/)?themes\/(?!.*\/)?([\w-]+)/);
|
|
259
|
+
if (match && match[1] !== context.slug) {
|
|
260
|
+
findings.push(finding(context, 'theme/code-quality', 'reject', {
|
|
261
|
+
where: relative(context.dir, path),
|
|
262
|
+
found: `imports from the "${match[1]}" theme`,
|
|
263
|
+
fix: 'A theme is self-contained. Copy what you need into your own folder, or ask for it to be promoted into @usequeek/theme-kit if every theme needs it.',
|
|
264
|
+
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return findings;
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
export const sdkBoundaryRule = {
|
|
272
|
+
id: 'theme/core-boundary',
|
|
273
|
+
summary: 'No SDK imports, no direct API calls, no store mutations',
|
|
274
|
+
kind: 'static',
|
|
275
|
+
run(context) {
|
|
276
|
+
const findings = [];
|
|
277
|
+
for (const path of themeSourceFiles(context.dir)) {
|
|
278
|
+
const source = readFileSync(path, 'utf8');
|
|
279
|
+
const where = relative(context.dir, path);
|
|
280
|
+
if (/from '@queekai\/client-sdk'|from '@\/lib\/core\/sdk\//.test(source)) {
|
|
281
|
+
findings.push(finding(context, 'theme/core-boundary', 'reject', {
|
|
282
|
+
where,
|
|
283
|
+
found: 'imports the SDK directly',
|
|
284
|
+
fix: 'Themes are presentation. Go through a core hook (useProducts, useCategories, useCart) — core owns transport, auth and retries.',
|
|
285
|
+
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
if (/\b(?:fetch|axios)\s*\(\s*['"`]https?:/.test(source)) {
|
|
289
|
+
findings.push(finding(context, 'theme/core-boundary', 'reject', {
|
|
290
|
+
where,
|
|
291
|
+
found: 'calls a remote endpoint directly',
|
|
292
|
+
fix: 'Use a core hook instead — a direct call misses auth, the 401 refresh, and preview-mode data.',
|
|
293
|
+
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return findings;
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* The ThemeModule shape (Layout, Header, Footer, blocks, getBlock, pages…) is
|
|
302
|
+
* enforced by TypeScript, because a theme writes `const theme: ThemeModule`.
|
|
303
|
+
* The one thing the compiler cannot catch is a theme that drops the annotation
|
|
304
|
+
* — then nothing checks the shape at all, and core discovers the missing slot
|
|
305
|
+
* at render time in a vendor's store.
|
|
306
|
+
*
|
|
307
|
+
* Checked by reading the source rather than importing it: index.ts pulls the
|
|
308
|
+
* whole theme graph, which reaches the SDK and will not load outside a bundler.
|
|
309
|
+
*/
|
|
310
|
+
export const moduleContractRule = {
|
|
311
|
+
id: 'theme/module-contract',
|
|
312
|
+
summary: 'The theme module is typed as ThemeModule, so its shape is compiler-enforced',
|
|
313
|
+
kind: 'static',
|
|
314
|
+
run(context) {
|
|
315
|
+
const source = context.read('index.ts') ?? context.read('index.tsx');
|
|
316
|
+
if (source === null) {
|
|
317
|
+
return [finding(context, 'theme/module-contract', 'reject', {
|
|
318
|
+
where: `${context.env.root}`,
|
|
319
|
+
found: 'no index.ts or index.tsx',
|
|
320
|
+
fix: 'Export a default ThemeModule from index.ts — it is how core loads your theme.',
|
|
321
|
+
docs: `${context.env.docs}#theme-contract`,
|
|
322
|
+
})];
|
|
323
|
+
}
|
|
324
|
+
const annotated = /:\s*ThemeModule\b/.test(source);
|
|
325
|
+
const exported = /export\s+default\s/.test(source);
|
|
326
|
+
const findings = [];
|
|
327
|
+
if (!exported) {
|
|
328
|
+
findings.push(finding(context, 'theme/module-contract', 'reject', {
|
|
329
|
+
where: `${context.env.root}index.ts`,
|
|
330
|
+
found: 'no default export',
|
|
331
|
+
fix: 'Core loads a theme with `import(...).then(m => m.default)`. Without a default export it cannot render at all.',
|
|
332
|
+
docs: `${context.env.docs}#theme-contract`,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
if (!annotated) {
|
|
336
|
+
findings.push(finding(context, 'theme/module-contract', 'reject', {
|
|
337
|
+
where: `${context.env.root}index.ts`,
|
|
338
|
+
found: 'the exported module is not annotated `: ThemeModule`',
|
|
339
|
+
fix: 'Write `const theme: ThemeModule = { ... }`. The annotation is what makes the compiler check every required slot — without it a missing page or block surfaces in a live store instead of at build time.',
|
|
340
|
+
docs: `${context.env.docs}#theme-contract`,
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
return findings;
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
/** Mirrors queek_backend `config/category_packs.php` (minus the `_default` fallback). */
|
|
347
|
+
const VALID_BUCKETS = ['food', 'supermarket', 'product', 'pharmacy', 'laundry', 'delivery', 'service', 'gas-refill', 'local_market'];
|
|
348
|
+
/**
|
|
349
|
+
* Chrome renders once per page, so it is never a composed SECTION and demo
|
|
350
|
+
* completeness cannot apply — its best_for/auto_pick are still validated by
|
|
351
|
+
* theme/selection-metadata.
|
|
352
|
+
*
|
|
353
|
+
* A DENYLIST on purpose. This started as an allowlist of four scopes, which
|
|
354
|
+
* meant every scope a theme invented was exempt by default: `contact` was
|
|
355
|
+
* declared by six of seven themes and verified on none, and `blog` likewise.
|
|
356
|
+
* Inverted, a new scope is covered the day it lands and opting out costs a
|
|
357
|
+
* deliberate line here.
|
|
358
|
+
*/
|
|
359
|
+
const CHROME_SCOPES = ['header', 'footer', 'subscribe'];
|
|
360
|
+
export const selectionMetadataRule = {
|
|
361
|
+
id: 'theme/selection-metadata',
|
|
362
|
+
summary: 'best_for / auto_pick / min_images are values the backend picker can use',
|
|
363
|
+
kind: 'static',
|
|
364
|
+
run(context) {
|
|
365
|
+
const variants = (context.manifest?.variants ?? {});
|
|
366
|
+
const findings = [];
|
|
367
|
+
for (const [scope, list] of Object.entries(variants)) {
|
|
368
|
+
for (const variant of list) {
|
|
369
|
+
const label = `${scope}.${String(variant.id)}`;
|
|
370
|
+
const at = `${context.env.root}manifest.ts`;
|
|
371
|
+
const add = (found, fix) => {
|
|
372
|
+
findings.push(finding(context, 'theme/selection-metadata', 'reject', { where: at, found: `${label}: ${found}`, fix, docs: `${context.env.docs}#selection-metadata-best_for--auto_pick--min_images` }));
|
|
373
|
+
};
|
|
374
|
+
const bestFor = variant.best_for;
|
|
375
|
+
if (bestFor !== undefined) {
|
|
376
|
+
const invalid = bestFor.filter((bucket) => !VALID_BUCKETS.includes(bucket));
|
|
377
|
+
if (invalid.length > 0)
|
|
378
|
+
add(`unknown best_for bucket(s) [${invalid.join(', ')}]`, `Use one of: ${VALID_BUCKETS.join(', ')}. An unknown bucket silently never matches, so the variant is never auto-picked.`);
|
|
379
|
+
}
|
|
380
|
+
if (variant.auto_pick !== undefined && typeof variant.auto_pick !== 'boolean') {
|
|
381
|
+
add(`auto_pick is ${typeof variant.auto_pick}`, 'auto_pick must be a boolean.');
|
|
382
|
+
}
|
|
383
|
+
const minImages = variant.min_images;
|
|
384
|
+
if (minImages !== undefined && (typeof minImages !== 'number' || !Number.isInteger(minImages) || minImages < 1)) {
|
|
385
|
+
add(`min_images is ${JSON.stringify(minImages)}`, 'min_images must be a positive integer — it gates the variant on how many real photos a vendor has.');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return findings;
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
export const demoCompletenessRule = {
|
|
393
|
+
id: 'theme/demo-completeness',
|
|
394
|
+
summary: 'Every auto-pickable variant is demonstrated in demo.json (and, advisably, in every alternative store)',
|
|
395
|
+
kind: 'static',
|
|
396
|
+
run(context) {
|
|
397
|
+
if (!context.manifest)
|
|
398
|
+
return [];
|
|
399
|
+
const variants = (context.manifest.variants ?? {});
|
|
400
|
+
const findings = [];
|
|
401
|
+
for (const store of context.demos) {
|
|
402
|
+
if (!store.data)
|
|
403
|
+
continue;
|
|
404
|
+
const pages = store.data.pages ?? {};
|
|
405
|
+
const list = Array.isArray(pages) ? pages : Object.values(pages);
|
|
406
|
+
const demonstrated = new Set();
|
|
407
|
+
for (const page of list) {
|
|
408
|
+
for (const section of page?.content ?? []) {
|
|
409
|
+
if (typeof section?.type === 'string')
|
|
410
|
+
demonstrated.add(`${section.type}.${section.variant ?? 'default'}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const missing = [];
|
|
414
|
+
for (const scope of Object.keys(variants).filter((name) => !CHROME_SCOPES.includes(name))) {
|
|
415
|
+
for (const variant of variants[scope] ?? []) {
|
|
416
|
+
if (variant.auto_pick === false)
|
|
417
|
+
continue;
|
|
418
|
+
const id = String(variant.id);
|
|
419
|
+
if (!demonstrated.has(`${scope}.${id}`))
|
|
420
|
+
missing.push(`${scope}.${id}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (missing.length === 0)
|
|
424
|
+
continue;
|
|
425
|
+
// The primary is what the capture pipeline thumbnails and what the
|
|
426
|
+
// builder takes variant art from, so a hole there is a hole for every
|
|
427
|
+
// vendor. An alternative store that skips a variant only means the
|
|
428
|
+
// builder falls back to the primary's art for it — worth knowing, not
|
|
429
|
+
// worth blocking.
|
|
430
|
+
const primary = store.id === PRIMARY_DEMO_ID;
|
|
431
|
+
findings.push(finding(context, 'theme/demo-completeness', primary ? 'reject' : 'warn', {
|
|
432
|
+
where: `${context.env.root}${store.file}`,
|
|
433
|
+
found: `auto-pickable variant(s) never shown: ${missing.join(', ')}`,
|
|
434
|
+
fix: primary
|
|
435
|
+
? 'Add a section using each to demo.json. The demo is what a vendor previews AND what the capture pipeline turns into section-library thumbnails — an undemonstrated variant is one a vendor is offered but has never seen.'
|
|
436
|
+
: `Add a section using each to ${store.file}, so a store built from it gets this store's art for every variant instead of the primary's.`,
|
|
437
|
+
docs: `${context.env.docs}#homepage-requirement-mandatory`,
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
return findings;
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
export const subscribeScopeRule = {
|
|
444
|
+
id: 'theme/subscribe-scope',
|
|
445
|
+
summary: 'Declares a subscribe scope with exactly one default variant',
|
|
446
|
+
kind: 'static',
|
|
447
|
+
run(context) {
|
|
448
|
+
if (!context.manifest)
|
|
449
|
+
return [];
|
|
450
|
+
const variants = (context.manifest.variants ?? {}).subscribe ?? [];
|
|
451
|
+
const at = `${context.env.root}manifest.ts`;
|
|
452
|
+
const add = (found, fix) => finding(context, 'theme/subscribe-scope', 'reject', { where: at, found, fix, docs: `${context.env.docs}#manifest` });
|
|
453
|
+
if (variants.length === 0) {
|
|
454
|
+
return [add('no subscribe scope', 'Declare `variants.subscribe`. A theme that omits it silently loses the newsletter app with no error anywhere — the backend falls back to a default variant that does not exist.')];
|
|
455
|
+
}
|
|
456
|
+
const findings = [];
|
|
457
|
+
const defaults = variants.filter((variant) => variant.default === true);
|
|
458
|
+
if (defaults.length !== 1) {
|
|
459
|
+
findings.push(add(`subscribe declares ${defaults.length} default variants`, 'Exactly one must be `default: true` — it is what the backend resolves to when a vendor has not chosen, and on a theme switch.'));
|
|
460
|
+
}
|
|
461
|
+
const ids = variants.map((variant) => String(variant.id));
|
|
462
|
+
if (new Set(ids).size !== ids.length) {
|
|
463
|
+
findings.push(add('duplicate subscribe variant ids', 'Ids must be unique within a scope.'));
|
|
464
|
+
}
|
|
465
|
+
return findings;
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* Block types core can actually render — the same list as `BlockType` in
|
|
470
|
+
* @usequeek/theme-kit/types/block, which PageRenderer switches on. Anything else falls
|
|
471
|
+
* to its `default: return null`.
|
|
472
|
+
*/
|
|
473
|
+
const VALID_BLOCK_TYPES = ["content", "image", "gallery", "video", "table", "button", "embed", "divider", "callout", "quote", "products", "categories", "contact", "reviews", "faq", "product_qa", "blog"];
|
|
474
|
+
export const demoBlockTypesRule = {
|
|
475
|
+
id: 'theme/demo-block-types',
|
|
476
|
+
summary: 'Every section in every demo store is a block type core can render',
|
|
477
|
+
kind: 'static',
|
|
478
|
+
run(context) {
|
|
479
|
+
const findings = [];
|
|
480
|
+
for (const store of context.demos) {
|
|
481
|
+
if (!store.data)
|
|
482
|
+
continue;
|
|
483
|
+
const pages = store.data.pages ?? {};
|
|
484
|
+
const list = Array.isArray(pages)
|
|
485
|
+
? pages
|
|
486
|
+
: Object.entries(pages).map(([slug, page]) => ({ slug, ...page }));
|
|
487
|
+
for (const page of list) {
|
|
488
|
+
for (const section of page?.content ?? []) {
|
|
489
|
+
const type = section?.type;
|
|
490
|
+
if (typeof type !== 'string' || VALID_BLOCK_TYPES.includes(type))
|
|
491
|
+
continue;
|
|
492
|
+
findings.push(finding(context, 'theme/demo-block-types', 'reject', {
|
|
493
|
+
where: `${context.env.root}${store.file} → pages.${page.slug ?? '?'}`,
|
|
494
|
+
found: `section type "${type}" is not a block core renders`,
|
|
495
|
+
fix: `PageRenderer returns null for an unknown type, so the section is invisible in the preview AND in every store built from this page — page_compositions publishes the type verbatim. Use one of: ${VALID_BLOCK_TYPES.join(', ')}.`,
|
|
496
|
+
docs: `${context.env.docs}#framework-owned-blocks`,
|
|
497
|
+
}));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return findings;
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
export const identityRule = {
|
|
505
|
+
id: 'theme/identity',
|
|
506
|
+
summary: 'manifest, config, demo and directory all agree on the slug',
|
|
507
|
+
kind: 'static',
|
|
508
|
+
run(context) {
|
|
509
|
+
const manifestSlug = context.manifest?.slug;
|
|
510
|
+
const configSlug = (context.read('theme.config.ts') ?? '').match(/slug:\s*'([a-z0-9-]+)'/)?.[1];
|
|
511
|
+
const layout = context.read('layout.tsx') ?? '';
|
|
512
|
+
const rootClass = layout.match(/className="theme-([a-z0-9-]+)"/)?.[1];
|
|
513
|
+
// Every store claims the theme — a link inside `/roast~foods` is built
|
|
514
|
+
// from the URL, not from profile.slug, so there is no reason for an
|
|
515
|
+
// alternative store to name a different theme and one good reason not
|
|
516
|
+
// to: the API headers the preview sends carry it.
|
|
517
|
+
const stores = context.demos.flatMap((store) => {
|
|
518
|
+
if (!store.data)
|
|
519
|
+
return [];
|
|
520
|
+
return [
|
|
521
|
+
[`${store.file} profile.slug`, (store.data.profile ?? {}).slug],
|
|
522
|
+
[`${store.file} config.theme`, (store.data.config ?? {}).theme],
|
|
523
|
+
];
|
|
524
|
+
});
|
|
525
|
+
const disagree = [
|
|
526
|
+
['layout.tsx root class `theme-…`', rootClass],
|
|
527
|
+
['manifest.ts slug', manifestSlug],
|
|
528
|
+
["theme.config.ts slug", configSlug],
|
|
529
|
+
...stores,
|
|
530
|
+
]
|
|
531
|
+
.filter(([, value]) => value !== undefined && value !== context.slug);
|
|
532
|
+
if (disagree.length === 0)
|
|
533
|
+
return [];
|
|
534
|
+
return disagree.map(([where, value]) => finding(context, 'theme/identity', 'reject', {
|
|
535
|
+
where: `${context.env.root}`,
|
|
536
|
+
found: `${where} is "${value}", but the theme directory is "${context.slug}"`,
|
|
537
|
+
fix: `Set it to "${context.slug}". Your whole stylesheet is scoped to \`.theme-${context.slug}\`, the registry keys entries on manifest.slug (and only WARNS on a mismatch, so a disagreement ships two entries claiming one slug), and the backend syncs by slug. These four have to be one name.`,
|
|
538
|
+
docs: `${context.env.docs}#registration`,
|
|
539
|
+
}));
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
export const productMetafieldsRule = {
|
|
543
|
+
id: 'theme/product-page-places-metafields',
|
|
544
|
+
summary: 'Every product page places the core-owned <ProductMetafields /> section',
|
|
545
|
+
kind: 'static',
|
|
546
|
+
run(context) {
|
|
547
|
+
const source = context.read('pages/product.tsx');
|
|
548
|
+
// No product page (themes/default renders product details through its
|
|
549
|
+
// product modal) — there is nowhere to place the section.
|
|
550
|
+
if (source === null)
|
|
551
|
+
return [];
|
|
552
|
+
if (/<ProductMetafields[\s>]/.test(stripComments(source)))
|
|
553
|
+
return [];
|
|
554
|
+
return [finding(context, 'theme/product-page-places-metafields', 'reject', {
|
|
555
|
+
where: `${context.env.root}pages/product.tsx`,
|
|
556
|
+
found: 'product page does not place <ProductMetafields />',
|
|
557
|
+
fix: "Import { ProductMetafields } from '@usequeek/theme-kit/components/product-metafields' and render <ProductMetafields product={product} definitions={metafieldDefinitions} /> below the description. Themes never re-implement metafield rendering.",
|
|
558
|
+
docs: `${context.env.docs}#custom-data-metafields--metaobjects`,
|
|
559
|
+
})];
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
export const poweredByRule = {
|
|
563
|
+
id: 'theme/footer-shows-powered-by',
|
|
564
|
+
summary: 'Every footer variant renders the core-owned <PoweredByQueek /> attribution',
|
|
565
|
+
kind: 'static',
|
|
566
|
+
run(context) {
|
|
567
|
+
// Footer implementations live in footers/*.tsx; themes without that dir
|
|
568
|
+
// (default, _bare, the starter) render footer.tsx instead. footer.tsx is
|
|
569
|
+
// always checked too — several themes keep a full second implementation
|
|
570
|
+
// there rather than a re-export of one variant.
|
|
571
|
+
const candidates = [];
|
|
572
|
+
if (context.exists('footers')) {
|
|
573
|
+
let entries;
|
|
574
|
+
try {
|
|
575
|
+
entries = readdirSync(join(context.dir, 'footers'));
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
for (const entry of entries) {
|
|
581
|
+
if (entry.endsWith('.tsx'))
|
|
582
|
+
candidates.push(`footers/${entry}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (context.exists('footer.tsx'))
|
|
586
|
+
candidates.push('footer.tsx');
|
|
587
|
+
candidates.sort();
|
|
588
|
+
const memo = new Map();
|
|
589
|
+
const passes = (file, trail) => {
|
|
590
|
+
const hit = memo.get(file);
|
|
591
|
+
if (hit !== undefined)
|
|
592
|
+
return hit;
|
|
593
|
+
// Import cycle: judged on its own top-level evaluation, not as a failure.
|
|
594
|
+
if (trail.includes(file))
|
|
595
|
+
return true;
|
|
596
|
+
const source = context.read(file);
|
|
597
|
+
if (source === null)
|
|
598
|
+
return true;
|
|
599
|
+
const code = stripComments(source);
|
|
600
|
+
if (/<PoweredByQueek[\s>]/.test(code)) {
|
|
601
|
+
memo.set(file, true);
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
// A file that renders its own <footer> without the component fails here.
|
|
605
|
+
// Helpers (icon sets) and delegating shims (re-export or forward props
|
|
606
|
+
// to another footer file) pass when every footer file they import passes.
|
|
607
|
+
if (/<footer[\s>]/.test(code)) {
|
|
608
|
+
memo.set(file, false);
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
const ok = localFooterImports(code, file).every((target) => passes(target, [...trail, file]));
|
|
612
|
+
memo.set(file, ok);
|
|
613
|
+
return ok;
|
|
614
|
+
};
|
|
615
|
+
return candidates.filter((file) => !passes(file, [])).map((file) => finding(context, 'theme/footer-shows-powered-by', 'reject', {
|
|
616
|
+
where: `${context.env.root}${file}`,
|
|
617
|
+
found: 'footer variant does not render <PoweredByQueek />',
|
|
618
|
+
fix: `Import { PoweredByQueek } from '@usequeek/theme-kit/components/powered-by-queek' and render <PoweredByQueek /> in the footer, keeping the theme's own placement and spacing. Style only the core-powered-by classes. Themes never re-implement the attribution markup.`,
|
|
619
|
+
docs: `${context.env.docs}#attribution`,
|
|
620
|
+
}));
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
/** Relative imports that resolve to another footer file (`footer.tsx`, `footers/*`). */
|
|
624
|
+
function localFooterImports(code, file) {
|
|
625
|
+
const base = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.';
|
|
626
|
+
const out = [];
|
|
627
|
+
for (const match of code.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
|
|
628
|
+
const parts = (base === '.' ? [] : base.split('/')).concat(match[1].split('/'));
|
|
629
|
+
const norm = [];
|
|
630
|
+
for (const part of parts) {
|
|
631
|
+
if (part === '' || part === '.')
|
|
632
|
+
continue;
|
|
633
|
+
if (part === '..')
|
|
634
|
+
norm.pop();
|
|
635
|
+
else
|
|
636
|
+
norm.push(part);
|
|
637
|
+
}
|
|
638
|
+
let rel = norm.join('/');
|
|
639
|
+
if (!/\.(tsx?|jsx?)$/.test(rel))
|
|
640
|
+
rel += '.tsx';
|
|
641
|
+
if (rel === 'footer.tsx' || rel.startsWith('footers/'))
|
|
642
|
+
out.push(rel);
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
/* ── Templates: the registry contract the backend builds a store from ─────
|
|
647
|
+
(queek_backend .agent/TASKS/frontend/storefront-theme-templates-contract.md)
|
|
648
|
+
Every demo store is a template a merchant — or the AI on their behalf — can
|
|
649
|
+
pick. The backend builds from the one picked, so what it publishes has to
|
|
650
|
+
be complete and true. */
|
|
651
|
+
/** Every published template, the primary first, with the description it declares. */
|
|
652
|
+
function templatesOf(context) {
|
|
653
|
+
const config = `${context.env.root}theme.config.ts`;
|
|
654
|
+
return [
|
|
655
|
+
{ id: PRIMARY_DEMO_ID, description: context.defaultDescription, where: `${config} → default_demo.description` },
|
|
656
|
+
...(context.declaredDemos ?? []).map((demo) => ({ id: demo.id, description: typeof demo.description === 'string' ? demo.description : null, where: `${config} → demos[${demo.id}].description` })),
|
|
657
|
+
];
|
|
658
|
+
}
|
|
659
|
+
export const templateDescriptionRule = {
|
|
660
|
+
id: 'theme/template-description',
|
|
661
|
+
summary: 'Every template carries a description an AI can choose it by (≤ 300 chars)',
|
|
662
|
+
kind: 'static',
|
|
663
|
+
run(context) {
|
|
664
|
+
if (context.retired || context.declaredDemos === null)
|
|
665
|
+
return [];
|
|
666
|
+
return templatesOf(context).flatMap(({ id, description, where }) => {
|
|
667
|
+
const text = description?.trim() ?? '';
|
|
668
|
+
const placeholder = text.startsWith(TEMPLATE_DESCRIPTION_PLACEHOLDER);
|
|
669
|
+
if (text.length > 0 && text.length <= TEMPLATE_DESCRIPTION_MAX && !placeholder)
|
|
670
|
+
return [];
|
|
671
|
+
return [finding(context, 'theme/template-description', 'reject', {
|
|
672
|
+
where,
|
|
673
|
+
found: placeholder
|
|
674
|
+
? `template "${id}" description is still the scaffold's placeholder`
|
|
675
|
+
: text.length === 0 ? `template "${id}" has no description` : `template "${id}" description is ${text.length} chars (max ${TEMPLATE_DESCRIPTION_MAX})`,
|
|
676
|
+
fix: `Write one for a model choosing on a merchant's behalf, at most ${TEMPLATE_DESCRIPTION_MAX} characters: who it fits · the look · the signature sections · what material it needs to look right. The primary's goes in \`default_demo: { description }\`, the others on their \`demos[]\` entry.`,
|
|
677
|
+
docs: `${context.env.docs}#templates`,
|
|
678
|
+
})];
|
|
679
|
+
});
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
export const templateScreenshotRule = {
|
|
683
|
+
id: 'theme/template-screenshot',
|
|
684
|
+
summary: 'Every template has a 1280×800 screenshot, uploaded to R2',
|
|
685
|
+
kind: 'static',
|
|
686
|
+
run(context) {
|
|
687
|
+
if (context.retired || context.declaredDemos === null)
|
|
688
|
+
return [];
|
|
689
|
+
const lock = (() => {
|
|
690
|
+
try {
|
|
691
|
+
return JSON.parse(context.read(SCREENSHOT_LOCK) ?? '{}');
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
return {};
|
|
695
|
+
}
|
|
696
|
+
})();
|
|
697
|
+
return templatesOf(context).flatMap(({ id }) => {
|
|
698
|
+
const file = screenshotFile(id, context.exists);
|
|
699
|
+
const where = `${context.env.root}${file}`;
|
|
700
|
+
if (!context.exists(file)) {
|
|
701
|
+
return [finding(context, 'theme/template-screenshot', 'reject', {
|
|
702
|
+
where,
|
|
703
|
+
found: `template "${id}" has no screenshot`,
|
|
704
|
+
fix: `Capture the template's first screen at 1280×800 (${context.env.preview(id)}) and save it here. The AI looks at this image before committing to a template — a missing one means a blind pick.`,
|
|
705
|
+
docs: `${context.env.docs}#templates`,
|
|
706
|
+
})];
|
|
707
|
+
}
|
|
708
|
+
// Uploading it is Queek's side of submission; locally the file existing is the contract.
|
|
709
|
+
if (!context.env.submission)
|
|
710
|
+
return [];
|
|
711
|
+
const url = screenshotUrl(context.slug, readFileSync(context.file(file)), file);
|
|
712
|
+
if (lock[file] === url)
|
|
713
|
+
return [];
|
|
714
|
+
return [finding(context, 'theme/template-screenshot', 'reject', {
|
|
715
|
+
where,
|
|
716
|
+
found: lock[file] ? 'screenshot changed since it was uploaded — the registry would publish a URL that does not exist yet' : 'screenshot has never been uploaded to R2',
|
|
717
|
+
fix: `Run \`yarn theme:rehost-images --theme ${context.slug}\` (or \`yarn theme:check ${context.slug} --fix\`) and commit ${SCREENSHOT_LOCK}. The registry publishes the screenshot as an absolute media.usequeek.com URL derived from its bytes.`,
|
|
718
|
+
fixable: true,
|
|
719
|
+
docs: `${context.env.docs}#templates`,
|
|
720
|
+
})];
|
|
721
|
+
});
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
export const templateChromeRule = {
|
|
725
|
+
id: 'theme/template-chrome',
|
|
726
|
+
summary: "A template's header and footer are variants the theme implements",
|
|
727
|
+
kind: 'static',
|
|
728
|
+
run(context) {
|
|
729
|
+
if (context.retired || !context.manifest)
|
|
730
|
+
return [];
|
|
731
|
+
const variants = (context.manifest.variants ?? {});
|
|
732
|
+
const findings = [];
|
|
733
|
+
for (const store of context.demos) {
|
|
734
|
+
const config = (store.data?.config ?? {});
|
|
735
|
+
for (const scope of ['header', 'footer']) {
|
|
736
|
+
const ids = (variants[scope] ?? []).map((variant) => variant.id);
|
|
737
|
+
const named = config[scope]?.variant;
|
|
738
|
+
// A theme with no variants of the scope (the single-page default) has
|
|
739
|
+
// no chrome to choose; the registry publishes null for it.
|
|
740
|
+
if (ids.length === 0 || typeof named !== 'string' || ids.includes(named))
|
|
741
|
+
continue;
|
|
742
|
+
findings.push(finding(context, 'theme/template-chrome', 'reject', {
|
|
743
|
+
where: `${context.env.root}${store.file} → config.${scope}.variant`,
|
|
744
|
+
found: `template "${store.id}" names ${scope} "${named}", which the theme does not implement (${ids.join(', ')})`,
|
|
745
|
+
fix: `Use one of ${ids.join(', ')}. The backend applies a template's ${scope} to the store built from it; an unimplemented one would publish as null and the store would not match its preview.`,
|
|
746
|
+
docs: `${context.env.docs}#templates`,
|
|
747
|
+
}));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return findings;
|
|
751
|
+
},
|
|
752
|
+
};
|
|
753
|
+
/** Keys the renderer supplies, never a variant's own field. */
|
|
754
|
+
const FRAMEWORK_KEYS = new Set(['bg_color', 'bg_image', 'bg_overlay', 'anchor_id']);
|
|
755
|
+
export const templateStyleRule = {
|
|
756
|
+
id: 'theme/template-style',
|
|
757
|
+
summary: "A section's style keys are fields its variant declares",
|
|
758
|
+
kind: 'static',
|
|
759
|
+
run(context) {
|
|
760
|
+
if (context.retired || !context.manifest)
|
|
761
|
+
return [];
|
|
762
|
+
const variants = (context.manifest.variants ?? {});
|
|
763
|
+
// The theme's style vocabulary: every presentational field any of its
|
|
764
|
+
// variants declares. A section setting one its own variant does not
|
|
765
|
+
// declare is style the backend would copy onto a section that ignores it.
|
|
766
|
+
const vocabulary = new Set();
|
|
767
|
+
for (const list of Object.values(variants)) {
|
|
768
|
+
for (const variant of list ?? []) {
|
|
769
|
+
for (const [name, spec] of Object.entries(variant.fields ?? {}))
|
|
770
|
+
if (isPresentationalField(name, spec))
|
|
771
|
+
vocabulary.add(name);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const findings = [];
|
|
775
|
+
for (const store of context.demos) {
|
|
776
|
+
const pages = store.data?.pages;
|
|
777
|
+
const pageList = Array.isArray(pages)
|
|
778
|
+
? pages.map((page, i) => [String(i), page])
|
|
779
|
+
: Object.entries((pages ?? {}));
|
|
780
|
+
for (const [pageKey, page] of pageList) {
|
|
781
|
+
(page?.content ?? []).forEach((section, index) => {
|
|
782
|
+
if (typeof section?.type !== 'string' || !section.data || typeof section.data !== 'object')
|
|
783
|
+
return;
|
|
784
|
+
const declared = variants[section.type]?.find((variant) => variant.id === (section.variant ?? 'default'));
|
|
785
|
+
if (!declared)
|
|
786
|
+
return; // framework-owned block or a variant other rules report
|
|
787
|
+
const fields = declared.fields ?? {};
|
|
788
|
+
const strays = Object.keys(section.data).filter((key) => vocabulary.has(key) && !FRAMEWORK_KEYS.has(key) && !(key in fields));
|
|
789
|
+
if (strays.length === 0)
|
|
790
|
+
return;
|
|
791
|
+
findings.push(finding(context, 'theme/template-style', 'reject', {
|
|
792
|
+
where: `${context.env.root}${store.file} → pages.${pageKey}.content[${index}] (${section.type}.${section.variant ?? 'default'})`,
|
|
793
|
+
found: `sets ${strays.join(', ')}, which ${section.type}.${section.variant ?? 'default'} does not declare`,
|
|
794
|
+
fix: 'Remove the key, or declare the field on the variant and make its component read it. A template\'s style is copied onto every store built from it; a key the variant ignores changes nothing and misleads whoever reads the registry.',
|
|
795
|
+
docs: `${context.env.docs}#templates`,
|
|
796
|
+
}));
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return findings;
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
function pagesOf(store) {
|
|
804
|
+
const pages = store.data?.pages;
|
|
805
|
+
return pages && typeof pages === 'object' && !Array.isArray(pages) ? pages : {};
|
|
806
|
+
}
|
|
807
|
+
export const templateBusinessRule = {
|
|
808
|
+
id: 'theme/template-business',
|
|
809
|
+
summary: "Every template names its own business in the platform's vocabulary",
|
|
810
|
+
kind: 'static',
|
|
811
|
+
run(context) {
|
|
812
|
+
if (context.retired || context.declaredDemos === null)
|
|
813
|
+
return [];
|
|
814
|
+
const config = `${context.env.root}theme.config.ts`;
|
|
815
|
+
const templates = [
|
|
816
|
+
{ id: PRIMARY_DEMO_ID, keys: context.defaultFor, where: `${config} → default_demo.for` },
|
|
817
|
+
...context.declaredDemos.map((demo) => ({ id: demo.id, keys: demo.for, where: `${config} → demos[${demo.id}].for` })),
|
|
818
|
+
];
|
|
819
|
+
const findings = [];
|
|
820
|
+
for (const { id, keys, where } of templates) {
|
|
821
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
822
|
+
// demos[] without one is already rejected by theme/demo-stores.
|
|
823
|
+
if (id !== PRIMARY_DEMO_ID)
|
|
824
|
+
continue;
|
|
825
|
+
findings.push(finding(context, 'theme/template-business', 'reject', {
|
|
826
|
+
where,
|
|
827
|
+
found: 'the primary template names no business',
|
|
828
|
+
fix: "Add `for` to `default_demo`, naming the business demo.json is dressed as (medley's beauty store: makeup, skincare, fragrance, beauty-personal-care, beauty-cosmetics). Without it the primary claimed every business the theme serves, so a food vendor was offered a beauty store.",
|
|
829
|
+
docs: `${context.env.docs}#templates`,
|
|
830
|
+
}));
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
const unknown = keys.filter((key) => typeof key !== 'string' || !isBusinessKey(key));
|
|
834
|
+
if (unknown.length === 0)
|
|
835
|
+
continue;
|
|
836
|
+
findings.push(finding(context, 'theme/template-business', 'reject', {
|
|
837
|
+
where,
|
|
838
|
+
found: `template "${id}" is for ${unknown.map((key) => JSON.stringify(key)).join(', ')}, not in the business vocabulary`,
|
|
839
|
+
fix: `Use service slugs or catalogue keys from ${context.env.vocabulary} (${BUSINESS_KEYS.size} keys), most specific first. The backend matches vendors on these keys only; any other key matches no one.`,
|
|
840
|
+
docs: `${context.env.docs}#templates`,
|
|
841
|
+
}));
|
|
842
|
+
}
|
|
843
|
+
return findings;
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
/** `food-2` → `food`; null for an id that is not a numbered version. */
|
|
847
|
+
function versionBase(id) {
|
|
848
|
+
const match = /^(.+)-([2-9])$/.exec(id);
|
|
849
|
+
return match ? match[1] : null;
|
|
850
|
+
}
|
|
851
|
+
export const templateVersionsRule = {
|
|
852
|
+
id: 'theme/template-versions',
|
|
853
|
+
summary: 'Every template has its own home design, and a version serves its original\'s business',
|
|
854
|
+
kind: 'static',
|
|
855
|
+
run(context) {
|
|
856
|
+
if (context.retired)
|
|
857
|
+
return [];
|
|
858
|
+
const findings = [];
|
|
859
|
+
const sequence = (store) => (pagesOf(store).home?.content ?? []).map((section) => `${section?.type}/${section?.variant ?? 'default'}`).join(' → ');
|
|
860
|
+
const seen = new Map();
|
|
861
|
+
// The single-page default theme draws one fixed layout whatever its home
|
|
862
|
+
// lists, so its templates differ by business, not composition.
|
|
863
|
+
for (const store of context.pageBased ? context.demos : []) {
|
|
864
|
+
const home = sequence(store);
|
|
865
|
+
if (!home)
|
|
866
|
+
continue;
|
|
867
|
+
const first = seen.get(home);
|
|
868
|
+
if (first) {
|
|
869
|
+
findings.push(finding(context, 'theme/template-versions', 'reject', {
|
|
870
|
+
where: `${context.env.root}${store.file} → pages.home`,
|
|
871
|
+
found: `template "${store.id}" has the same home sections, in the same order, as "${first}"`,
|
|
872
|
+
fix: 'A template must be its own design, not a recolour: change the order and the section variants (and the header, footer and palette where the theme allows). Vendors are spread across versions; two identical ones make that a coin toss between the same page.',
|
|
873
|
+
docs: `${context.env.docs}#templates`,
|
|
874
|
+
}));
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
seen.set(home, store.id);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const declared = context.declaredDemos ?? [];
|
|
881
|
+
const forOf = new Map(declared.map((demo) => [demo.id, JSON.stringify(demo.for ?? [])]));
|
|
882
|
+
for (const demo of declared) {
|
|
883
|
+
const base = versionBase(demo.id);
|
|
884
|
+
if (!base || !forOf.has(base) || forOf.get(base) === forOf.get(demo.id))
|
|
885
|
+
continue;
|
|
886
|
+
findings.push(finding(context, 'theme/template-versions', 'reject', {
|
|
887
|
+
where: `${context.env.root}theme.config.ts → demos[${demo.id}].for`,
|
|
888
|
+
found: `version "${demo.id}" is for ${forOf.get(demo.id)}, "${base}" for ${forOf.get(base)}`,
|
|
889
|
+
fix: `Give "${demo.id}" exactly the \`for\` of "${base}". Versions are the same business in different designs; the backend spreads matching vendors across them.`,
|
|
890
|
+
docs: `${context.env.docs}#templates`,
|
|
891
|
+
}));
|
|
892
|
+
}
|
|
893
|
+
return findings;
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
/** The designed pages every template ships (contract R2.3); versions `-2`… count. */
|
|
897
|
+
export const TEMPLATE_PAGES = ['about', 'sales', 'landing'];
|
|
898
|
+
export const templatePagesRule = {
|
|
899
|
+
id: 'theme/template-pages',
|
|
900
|
+
summary: 'Every template ships about, sales and landing pages; sales and landing sell products',
|
|
901
|
+
kind: 'static',
|
|
902
|
+
run(context) {
|
|
903
|
+
// The single-page default theme renders one page whatever the URL.
|
|
904
|
+
if (context.retired || !context.pageBased)
|
|
905
|
+
return [];
|
|
906
|
+
const findings = [];
|
|
907
|
+
for (const store of context.demos) {
|
|
908
|
+
const pages = pagesOf(store);
|
|
909
|
+
const where = `${context.env.root}${store.file} → pages`;
|
|
910
|
+
for (const slug of TEMPLATE_PAGES) {
|
|
911
|
+
if (Object.keys(pages).some((key) => key === slug || versionBase(key) === slug))
|
|
912
|
+
continue;
|
|
913
|
+
findings.push(finding(context, 'theme/template-pages', 'reject', {
|
|
914
|
+
where,
|
|
915
|
+
found: `template "${store.id}" has no \`${slug}\` page`,
|
|
916
|
+
fix: slug === 'about'
|
|
917
|
+
? 'Ship an `about` page whose every section a vendor can fill from real facts or photos (founder story, team, values, timeline, press). Qee clones it for the vendor.'
|
|
918
|
+
: `Ship a \`${slug}\` page designed for 1–3 products: a products section near the top (a spotlight/featured section takes one product each, a list takes them all), the first hero dressed with their photos, closing on a contact card. Qee clones it and binds the merchant's products.`,
|
|
919
|
+
docs: `${context.env.docs}#templates`,
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
922
|
+
for (const [key, page] of Object.entries(pages)) {
|
|
923
|
+
const kind = TEMPLATE_PAGES.find((slug) => slug !== 'about' && (key === slug || versionBase(key) === slug));
|
|
924
|
+
if (!kind)
|
|
925
|
+
continue;
|
|
926
|
+
const content = page?.content ?? [];
|
|
927
|
+
const first = content.findIndex((section) => section?.type === 'products');
|
|
928
|
+
if (first === -1) {
|
|
929
|
+
findings.push(finding(context, 'theme/template-pages', 'reject', {
|
|
930
|
+
where: `${context.env.root}${store.file} → pages.${key}`,
|
|
931
|
+
found: `the ${kind} page has no products section`,
|
|
932
|
+
fix: `The backend binds the merchant's named products to every products section on a ${kind} page; with none, the page sells nothing. Put one near the top.`,
|
|
933
|
+
docs: `${context.env.docs}#templates`,
|
|
934
|
+
}));
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (first > 2) {
|
|
938
|
+
findings.push(finding(context, 'theme/template-pages', 'warn', {
|
|
939
|
+
where: `${context.env.root}${store.file} → pages.${key}.content[${first}]`,
|
|
940
|
+
found: `the ${kind} page's first products section is section ${first + 1}`,
|
|
941
|
+
fix: 'Move a products section into the first three, so the product is on the first screen.',
|
|
942
|
+
docs: `${context.env.docs}#templates`,
|
|
943
|
+
}));
|
|
944
|
+
}
|
|
945
|
+
if (content.at(-1)?.type !== 'contact') {
|
|
946
|
+
findings.push(finding(context, 'theme/template-pages', 'warn', {
|
|
947
|
+
where: `${context.env.root}${store.file} → pages.${key}`,
|
|
948
|
+
found: `the ${kind} page does not close on a contact section`,
|
|
949
|
+
fix: 'End it on a contact card: a buyer who is not ready to order needs a way to ask.',
|
|
950
|
+
docs: `${context.env.docs}#templates`,
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return findings;
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
export const STATIC_RULES = [moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, identityRule, productMetafieldsRule, poweredByRule,
|
|
959
|
+
templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule,
|
|
960
|
+
templateBusinessRule, templateVersionsRule, templatePagesRule,
|
|
961
|
+
];
|