@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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme templates — the registry contract the backend builds from
|
|
3
|
+
* (queek_backend `.agent/TASKS/frontend/storefront-theme-templates-contract.md`).
|
|
4
|
+
*
|
|
5
|
+
* A template is a demo store: `demo.json` (id `default`) or `demos/<id>.json`.
|
|
6
|
+
* The backend builds a vendor's store from ONE template's layout, keeps its
|
|
7
|
+
* declared variants, applies its chrome, dials and per-section `style`, and
|
|
8
|
+
* fills every content slot with the vendor's own material. Everything here is
|
|
9
|
+
* the shared reading of that contract — the registry generator, the registry
|
|
10
|
+
* API route, the rehost script, theme-check and the tests all call it, so the
|
|
11
|
+
* five of them cannot disagree about what a template publishes.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { PRIMARY_DEMO_ID } from './theme-demos.js';
|
|
15
|
+
/** Longest `description` a template may carry — written for a model choosing on a merchant's behalf. */
|
|
16
|
+
export const TEMPLATE_DESCRIPTION_MAX = 300;
|
|
17
|
+
/** How `themes/_bare`'s description starts — a new theme must replace it before it publishes. */
|
|
18
|
+
export const TEMPLATE_DESCRIPTION_PLACEHOLDER = 'Replace before publishing.';
|
|
19
|
+
/** Where rehosted theme imagery is served — the host the backend passes through untouched. */
|
|
20
|
+
export const THEME_ASSET_BASE = 'https://media.usequeek.com/theme-assets';
|
|
21
|
+
/** Per-theme record of the screenshots the rehost script has uploaded. */
|
|
22
|
+
export const SCREENSHOT_LOCK = 'screenshots.json';
|
|
23
|
+
/* ── screenshots ─────────────────────────────────────────────────────── */
|
|
24
|
+
/**
|
|
25
|
+
* The screenshot file a template is judged by, relative to the theme dir:
|
|
26
|
+
* `theme.jpg` (or `theme.png`) for the primary, `demos/<id>.jpg` for the rest.
|
|
27
|
+
* `exists` decides between jpg and png for the primary, so callers pass their
|
|
28
|
+
* own file check (fs, or theme-check's context).
|
|
29
|
+
*/
|
|
30
|
+
export function screenshotFile(demoId, exists) {
|
|
31
|
+
if (demoId !== PRIMARY_DEMO_ID)
|
|
32
|
+
return `demos/${demoId}.jpg`;
|
|
33
|
+
return exists('theme.jpg') || !exists('theme.png') ? 'theme.jpg' : 'theme.png';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Content-addressed, like the section art: `theme-assets/{theme}/{sha256[..16]}.{ext}`.
|
|
37
|
+
* A re-captured screenshot gets a new URL, so no cache anywhere keeps showing
|
|
38
|
+
* the old picture — and the URL is known from the bytes alone, offline.
|
|
39
|
+
*/
|
|
40
|
+
export function screenshotKey(theme, bytes, file) {
|
|
41
|
+
const extension = file.toLowerCase().endsWith('.png') ? 'png' : 'jpg';
|
|
42
|
+
return `theme-assets/${theme}/${createHash('sha256').update(bytes).digest('hex').slice(0, 16)}.${extension}`;
|
|
43
|
+
}
|
|
44
|
+
export function screenshotUrl(theme, bytes, file) {
|
|
45
|
+
return `${THEME_ASSET_BASE}/${screenshotKey(theme, bytes, file).slice('theme-assets/'.length)}`;
|
|
46
|
+
}
|
|
47
|
+
const COLOR_VAR = /var\(\s*--brand-(?:bg|text|text-muted|primary|accent|accent-soft|on-accent|surface|surface-muted|border|button-bg|button-text)\b/;
|
|
48
|
+
const FONT_VAR = /var\(\s*--font-(?:heading|body)\b/;
|
|
49
|
+
/**
|
|
50
|
+
* Which token groups a theme actually renders from. A theme that hard-codes
|
|
51
|
+
* its palette (roast, glow, carat) publishes tokens without `color`, so the
|
|
52
|
+
* backend never promises a merchant a colour change the theme will ignore.
|
|
53
|
+
* Comments are stripped first — glow mentions `--brand-text` only in one.
|
|
54
|
+
*/
|
|
55
|
+
export function tokenReach(css) {
|
|
56
|
+
const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
57
|
+
return { colors: COLOR_VAR.test(code), fonts: FONT_VAR.test(code) };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A template's tokens as the backend should apply them: the dials always
|
|
61
|
+
* (sizes, weights, spacing, radius, motion, image treatment), the palette and
|
|
62
|
+
* the faces only when the theme reads them. Undefined when nothing is left.
|
|
63
|
+
*/
|
|
64
|
+
export function templateTokens(tokens, reach) {
|
|
65
|
+
if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens))
|
|
66
|
+
return undefined;
|
|
67
|
+
const out = JSON.parse(JSON.stringify(tokens));
|
|
68
|
+
if (!reach.colors)
|
|
69
|
+
delete out.color;
|
|
70
|
+
if (!reach.fonts && out.type && typeof out.type === 'object') {
|
|
71
|
+
const type = out.type;
|
|
72
|
+
delete type.heading_font;
|
|
73
|
+
delete type.body_font;
|
|
74
|
+
if (Object.keys(type).length === 0)
|
|
75
|
+
delete out.type;
|
|
76
|
+
}
|
|
77
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
78
|
+
}
|
|
79
|
+
/* ── chrome ──────────────────────────────────────────────────────────── */
|
|
80
|
+
/**
|
|
81
|
+
* The header (or footer) variant a template names, when the theme implements
|
|
82
|
+
* one of that scope. A theme with no variants of the scope (the single-page
|
|
83
|
+
* default theme) publishes null; a name the theme does not implement also
|
|
84
|
+
* publishes null here and is a theme-check rejection, never a silent fallback.
|
|
85
|
+
*/
|
|
86
|
+
export function templateChrome(demo, scope, implemented) {
|
|
87
|
+
const config = demo?.config;
|
|
88
|
+
const variant = config?.[scope]?.variant;
|
|
89
|
+
return typeof variant === 'string' && implemented.includes(variant) ? variant : null;
|
|
90
|
+
}
|
|
91
|
+
/* ── per-section style ───────────────────────────────────────────────── */
|
|
92
|
+
/**
|
|
93
|
+
* Query fields decide WHICH content fills a section, not how it looks; they
|
|
94
|
+
* are enums in several manifests (`sort: latest|popular|…`) but are never
|
|
95
|
+
* style. The vendor's own content drives them.
|
|
96
|
+
*/
|
|
97
|
+
const QUERY_FIELDS = new Set(['sort', 'collection', 'category', 'ids', 'limit', 'parent', 'tabs', 'trigger']);
|
|
98
|
+
const ENUM_DESCRIPTOR = /^\s*[a-z0-9_-]+(?:\s*\|\s*[a-z0-9_-]+)+(?=\s|$|\(|,|—)/i;
|
|
99
|
+
const BOOL_DESCRIPTOR = /^\s*bool(?:ean)?\b/i;
|
|
100
|
+
/**
|
|
101
|
+
* Whether a declared field is presentational — how the section looks, not
|
|
102
|
+
* what it says: a structured `enum` / `boolean` / `color` field, or a string
|
|
103
|
+
* descriptor that is an `a|b|c` enum or a `bool`. Copy, images, ids and links
|
|
104
|
+
* never qualify; neither do the query fields.
|
|
105
|
+
*/
|
|
106
|
+
export function isPresentationalField(name, spec) {
|
|
107
|
+
if (QUERY_FIELDS.has(name))
|
|
108
|
+
return false;
|
|
109
|
+
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
|
|
110
|
+
const type = spec.type;
|
|
111
|
+
return type === 'enum' || type === 'boolean' || type === 'color';
|
|
112
|
+
}
|
|
113
|
+
if (typeof spec === 'string')
|
|
114
|
+
return ENUM_DESCRIPTOR.test(spec) || BOOL_DESCRIPTOR.test(spec);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
/** The presentational field names a variant declares. */
|
|
118
|
+
export function presentationalFields(fields) {
|
|
119
|
+
return Object.entries(fields ?? {}).filter(([name, spec]) => isPresentationalField(name, spec)).map(([name]) => name);
|
|
120
|
+
}
|
|
121
|
+
/** `{type: {variantId: fields}}` from a manifest's (or registry entry's) `variants`. */
|
|
122
|
+
export function declaredFieldsByVariant(variants) {
|
|
123
|
+
const out = {};
|
|
124
|
+
if (!variants || typeof variants !== 'object')
|
|
125
|
+
return out;
|
|
126
|
+
for (const [scope, list] of Object.entries(variants)) {
|
|
127
|
+
if (!Array.isArray(list))
|
|
128
|
+
continue;
|
|
129
|
+
out[scope] = Object.fromEntries(list.filter((v) => v && typeof v.id === 'string').map((v) => [v.id, v.fields ?? {}]));
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A section's `style`: the presentational fields its variant declares, with
|
|
135
|
+
* the values the template set. Only primitives — never copy, images, ids or
|
|
136
|
+
* links, which the vendor's content fills. Undefined when there are none.
|
|
137
|
+
*/
|
|
138
|
+
export function sectionStyle(section, declared) {
|
|
139
|
+
if (typeof section.type !== 'string')
|
|
140
|
+
return undefined;
|
|
141
|
+
const fields = declared[section.type]?.[typeof section.variant === 'string' ? section.variant : 'default'];
|
|
142
|
+
const data = section.data;
|
|
143
|
+
if (!fields || !data || typeof data !== 'object' || Array.isArray(data))
|
|
144
|
+
return undefined;
|
|
145
|
+
const style = {};
|
|
146
|
+
for (const name of presentationalFields(fields)) {
|
|
147
|
+
const value = data[name];
|
|
148
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
|
149
|
+
style[name] = value;
|
|
150
|
+
}
|
|
151
|
+
return Object.keys(style).length > 0 ? style : undefined;
|
|
152
|
+
}
|
|
153
|
+
/* ── per-section copy (contract R2.4) ──────────────────────────────────── */
|
|
154
|
+
/** Field types whose value is words a vendor could keep or rewrite. */
|
|
155
|
+
const TEXT_TYPES = new Set(['string', 'text', 'markdown']);
|
|
156
|
+
/** A string descriptor that declares words: `string`, `markdown string`, `text — …`, `string[]`. */
|
|
157
|
+
const TEXT_DESCRIPTOR = /^\s*(?:markdown\s+)?(?:string|text|markdown)(\[\])?(?=\s|$|\(|,|—)/i;
|
|
158
|
+
/** `[{url,title,subtitle,…}]` inside a descriptor: a list of entries and their keys. */
|
|
159
|
+
const ENTRY_KEYS = /\[\{([^}\]]+)\}\]/;
|
|
160
|
+
/**
|
|
161
|
+
* Keys of a list entry that carry words. Everything else in an entry — url,
|
|
162
|
+
* link, cta_url, alt (it describes that one photo), product_ids, price,
|
|
163
|
+
* hotspot coordinates, icons — belongs to the demo store, not the design.
|
|
164
|
+
*/
|
|
165
|
+
const ENTRY_TEXT_KEYS = new Set(['title', 'subtitle', 'caption', 'text', 'heading', 'eyebrow', 'label', 'cta_label', 'question', 'answer', 'quote', 'author', 'name', 'role', 'body', 'description', 'content', 'kicker']);
|
|
166
|
+
/** Links, references, prices, media, alt text (it describes one photo) and icon names are never copy, whatever their declared type. */
|
|
167
|
+
const NOT_COPY = /(^|_)(url|link|href|slug|id|ids|price|amount|currency|video|image|icon|alt)$/;
|
|
168
|
+
/** `[the balm](/products/balm)` → `the balm`: copy keeps a link's words, never where it pointed. */
|
|
169
|
+
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
|
170
|
+
function textOf(value) {
|
|
171
|
+
return typeof value === 'string' && value.trim() !== '' ? value.replace(MARKDOWN_LINK, '$1') : undefined;
|
|
172
|
+
}
|
|
173
|
+
function textsOf(value) {
|
|
174
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === 'string') ? value.map((v) => textOf(v) ?? v) : undefined;
|
|
175
|
+
}
|
|
176
|
+
function entriesCopy(value, keep) {
|
|
177
|
+
if (!Array.isArray(value))
|
|
178
|
+
return undefined;
|
|
179
|
+
const entries = value.map((entry) => {
|
|
180
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
181
|
+
return {};
|
|
182
|
+
return Object.fromEntries(Object.entries(entry).flatMap(([key, v]) => {
|
|
183
|
+
const text = keep(key) ? textOf(v) : undefined;
|
|
184
|
+
return text ? [[key, text]] : [];
|
|
185
|
+
}));
|
|
186
|
+
});
|
|
187
|
+
return entries.some((entry) => Object.keys(entry).length > 0) ? entries : undefined;
|
|
188
|
+
}
|
|
189
|
+
/** One declared field's copy from the demo value, or undefined when it holds none. */
|
|
190
|
+
function fieldCopy(name, spec, value) {
|
|
191
|
+
if (NOT_COPY.test(name) || isPresentationalField(name, spec) || QUERY_FIELDS.has(name))
|
|
192
|
+
return undefined;
|
|
193
|
+
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
|
|
194
|
+
const { type, of } = spec;
|
|
195
|
+
if (typeof type === 'string' && TEXT_TYPES.has(type))
|
|
196
|
+
return textOf(value);
|
|
197
|
+
if (type === 'string[]')
|
|
198
|
+
return textsOf(value);
|
|
199
|
+
if (type === 'object[]' && of) {
|
|
200
|
+
return entriesCopy(value, (key) => {
|
|
201
|
+
const sub = of[key];
|
|
202
|
+
return !!sub && typeof sub.type === 'string' && TEXT_TYPES.has(sub.type) && !NOT_COPY.test(key) && !isPresentationalField(key, sub);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
if (typeof spec !== 'string')
|
|
208
|
+
return undefined;
|
|
209
|
+
const entryKeys = ENTRY_KEYS.exec(spec);
|
|
210
|
+
if (entryKeys) {
|
|
211
|
+
const declared = new Set(entryKeys[1].split(',').map((key) => key.trim()));
|
|
212
|
+
return entriesCopy(value, (key) => declared.has(key) && ENTRY_TEXT_KEYS.has(key));
|
|
213
|
+
}
|
|
214
|
+
const text = TEXT_DESCRIPTOR.exec(spec);
|
|
215
|
+
if (!text)
|
|
216
|
+
return undefined;
|
|
217
|
+
if (text[1])
|
|
218
|
+
return textsOf(value);
|
|
219
|
+
return textOf(value);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A section's `copy`: the words the template set in the fields its variant
|
|
223
|
+
* declares as text — headings, body, markdown, button labels, and the text
|
|
224
|
+
* keys of its list entries (steps, slides, FAQ items). Never images, links,
|
|
225
|
+
* ids, prices or product and collection references. The backend builds a
|
|
226
|
+
* section with no vendor facts behind it (a how-to, a statement) from this,
|
|
227
|
+
* and qee rewrites it for the vendor. Undefined when there are none.
|
|
228
|
+
*/
|
|
229
|
+
export function sectionCopy(section, declared) {
|
|
230
|
+
if (typeof section.type !== 'string')
|
|
231
|
+
return undefined;
|
|
232
|
+
const fields = declared[section.type]?.[typeof section.variant === 'string' ? section.variant : 'default'];
|
|
233
|
+
const data = section.data;
|
|
234
|
+
if (!fields || !data || typeof data !== 'object' || Array.isArray(data))
|
|
235
|
+
return undefined;
|
|
236
|
+
const copy = {};
|
|
237
|
+
for (const [name, spec] of Object.entries(fields)) {
|
|
238
|
+
const value = fieldCopy(name, spec, data[name]);
|
|
239
|
+
if (value !== undefined)
|
|
240
|
+
copy[name] = value;
|
|
241
|
+
}
|
|
242
|
+
return Object.keys(copy).length > 0 ? copy : undefined;
|
|
243
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usequeek/theme-check",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The rules a Queek storefront theme is checked against, as a library.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"!dist/**/*.map"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.12.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"jiti": "^2.7.0",
|
|
23
|
+
"typescript": "~5.9.3"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@usequeek/theme-kit": ">=0.1.8"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"queek",
|
|
30
|
+
"theme",
|
|
31
|
+
"storefront",
|
|
32
|
+
"linter",
|
|
33
|
+
"validator"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/usequeek/theme-tools.git",
|
|
38
|
+
"directory": "packages/theme-check"
|
|
39
|
+
},
|
|
40
|
+
"bugs": "https://github.com/usequeek/theme-tools/issues",
|
|
41
|
+
"homepage": "https://github.com/usequeek/theme-tools/tree/main/packages/theme-check#readme",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"provenance": true
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@usequeek/theme-kit": {
|
|
48
|
+
"optional": false
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/utils/business-vocabulary.json','dist/utils/business-vocabulary.json')\"",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
54
|
+
}
|
|
55
|
+
}
|