@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.
@@ -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
+ }