@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
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CheckEnv, Finding, Rule, ThemeContext } from './types.js';
|
|
2
|
+
/** Every rule that runs on a developer's machine, in the order they report. */
|
|
3
|
+
export declare const RULES: Rule[];
|
|
4
|
+
/**
|
|
5
|
+
* Checks that only Queek can run, when a theme is submitted: they need Queek's
|
|
6
|
+
* other themes, a server render, or Queek's storage. Listed so a clean local
|
|
7
|
+
* run never reads as "nothing else can fail".
|
|
8
|
+
*/
|
|
9
|
+
export declare const AT_SUBMISSION: ReadonlyArray<{
|
|
10
|
+
id: string;
|
|
11
|
+
summary: string;
|
|
12
|
+
}>;
|
|
13
|
+
export interface CheckOptions {
|
|
14
|
+
/** Override how findings name files and where they link (see CheckEnv). */
|
|
15
|
+
env?: Partial<CheckEnv>;
|
|
16
|
+
/** Run only these rule ids. */
|
|
17
|
+
only?: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface CheckResult {
|
|
20
|
+
context: ThemeContext;
|
|
21
|
+
findings: Finding[];
|
|
22
|
+
}
|
|
23
|
+
/** Every finding for the theme in `themeDir`. No `reject` finding means it will pass these checks on submission. */
|
|
24
|
+
export declare function checkTheme(themeDir: string, options?: CheckOptions): Promise<CheckResult>;
|
|
25
|
+
export declare const rejects: (findings: Finding[]) => Finding[];
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { loadContext } from './context.js';
|
|
2
|
+
import { ANALYSIS_RULES } from './rules/analysis.js';
|
|
3
|
+
import { STATIC_RULES } from './rules/static.js';
|
|
4
|
+
/** Every rule that runs on a developer's machine, in the order they report. */
|
|
5
|
+
export const RULES = [...STATIC_RULES, ...ANALYSIS_RULES];
|
|
6
|
+
/**
|
|
7
|
+
* Checks that only Queek can run, when a theme is submitted: they need Queek's
|
|
8
|
+
* other themes, a server render, or Queek's storage. Listed so a clean local
|
|
9
|
+
* run never reads as "nothing else can fail".
|
|
10
|
+
*/
|
|
11
|
+
export const AT_SUBMISSION = [
|
|
12
|
+
{ id: 'theme/divergence', summary: 'the design is its own, not a copy of an existing Queek theme' },
|
|
13
|
+
{ id: 'theme/renders', summary: 'every declared variant renders visible content with sample data' },
|
|
14
|
+
{ id: 'theme/price-range-signal', summary: 'a product priced as a range shows "From", not its floor as the price' },
|
|
15
|
+
{ id: 'theme/demo-art-hosting', summary: 'demo images are moved onto Queek’s CDN (done for you)' },
|
|
16
|
+
{ id: 'theme/template-screenshot', summary: 'template screenshots are uploaded (done for you)' },
|
|
17
|
+
];
|
|
18
|
+
/** Every finding for the theme in `themeDir`. No `reject` finding means it will pass these checks on submission. */
|
|
19
|
+
export async function checkTheme(themeDir, options = {}) {
|
|
20
|
+
const context = await loadContext(themeDir, options.env);
|
|
21
|
+
const rules = RULES.filter((rule) => !options.only || options.only.includes(rule.id));
|
|
22
|
+
const findings = [];
|
|
23
|
+
for (const rule of rules) {
|
|
24
|
+
try {
|
|
25
|
+
findings.push(...(await rule.run(context)));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
// A rule that throws is itself a failure signal — a theme whose module
|
|
29
|
+
// graph will not load cannot be published, and silently skipping the
|
|
30
|
+
// rule would report that theme as clean.
|
|
31
|
+
findings.push({
|
|
32
|
+
rule: rule.id,
|
|
33
|
+
severity: 'reject',
|
|
34
|
+
theme: context.slug,
|
|
35
|
+
found: `the check could not run: ${error.message}`,
|
|
36
|
+
fix: 'Usually a theme module that fails to load. Run `npx tsc --noEmit` and fix the error first.',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { context, findings };
|
|
41
|
+
}
|
|
42
|
+
export const rejects = (findings) => findings.filter((finding) => finding.severity === 'reject');
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "is this theme valid", shared by three consumers: the
|
|
3
|
+
* author's `yarn theme:check`, this repo's CI, and (later) the pull/publish
|
|
4
|
+
* command. The moment validity has two implementations they drift, and an
|
|
5
|
+
* author gets a green locally and a rejection here.
|
|
6
|
+
*
|
|
7
|
+
* Rules therefore live in this library and the tests assert over it — not the
|
|
8
|
+
* other way round, which is where five of these gates started.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The parts of a theme's manifest (and of @usequeek/theme-kit's ThemeManifest)
|
|
12
|
+
* the rules read. Structural, so this package does not compile the kit's
|
|
13
|
+
* source — the kit ships TypeScript, not declarations.
|
|
14
|
+
*/
|
|
15
|
+
export interface ThemeManifest {
|
|
16
|
+
slug?: string;
|
|
17
|
+
variants?: Record<string, unknown[]>;
|
|
18
|
+
tokens?: DesignTokens;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** The design-token dimensions the design-tokens rule requires (the kit's DesignTokens). */
|
|
22
|
+
export interface DesignTokens {
|
|
23
|
+
color?: Record<string, unknown>;
|
|
24
|
+
type?: {
|
|
25
|
+
heading_font?: string;
|
|
26
|
+
body_font?: string;
|
|
27
|
+
scale_ratio?: number;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
};
|
|
30
|
+
space?: {
|
|
31
|
+
density?: string;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
};
|
|
34
|
+
shape?: {
|
|
35
|
+
radius?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
};
|
|
38
|
+
elevation?: string;
|
|
39
|
+
motion?: string;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
/** `reject` blocks publication. `warn` is advice a theme can ship with. */
|
|
43
|
+
export type Severity = 'reject' | 'warn';
|
|
44
|
+
export interface Finding {
|
|
45
|
+
/** Stable id — an agent keys its fix loop on this, so never rename casually. */
|
|
46
|
+
rule: string;
|
|
47
|
+
severity: Severity;
|
|
48
|
+
theme: string;
|
|
49
|
+
/** `path/in/theme.tsx:line` where known. */
|
|
50
|
+
where?: string;
|
|
51
|
+
/** What is actually there. */
|
|
52
|
+
found: string;
|
|
53
|
+
/** What to do about it, concretely enough to act on without reading docs. */
|
|
54
|
+
fix: string;
|
|
55
|
+
/** True when `--fix` can resolve it without a human decision. */
|
|
56
|
+
fixable?: boolean;
|
|
57
|
+
docs?: string;
|
|
58
|
+
}
|
|
59
|
+
/** One demo store on disk: `demo.json` (id `default`) or `demos/<id>.json`. */
|
|
60
|
+
export interface DemoStore {
|
|
61
|
+
id: string;
|
|
62
|
+
/** Relative to the theme directory. */
|
|
63
|
+
file: string;
|
|
64
|
+
/** Null when the file is missing or not valid JSON. */
|
|
65
|
+
data: Record<string, unknown> | null;
|
|
66
|
+
}
|
|
67
|
+
/** A `demos[]` entry in theme.config.ts. */
|
|
68
|
+
export interface DeclaredDemo {
|
|
69
|
+
id: string;
|
|
70
|
+
label: string;
|
|
71
|
+
/** Business slugs (the vendor's service_slug/service_type vocabulary). */
|
|
72
|
+
for: string[];
|
|
73
|
+
/** What an AI reads to choose this template for a merchant (≤ 300 chars). */
|
|
74
|
+
description?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Where the rules are running, so every finding names things the reader can
|
|
78
|
+
* act on. A developer's repo and Queek's submission pipeline see the same
|
|
79
|
+
* theme through different paths and commands; the rules are the same.
|
|
80
|
+
*/
|
|
81
|
+
export interface CheckEnv {
|
|
82
|
+
/** How a file of the theme is written in a finding, e.g. `theme/` — always ends with `/`. */
|
|
83
|
+
root: string;
|
|
84
|
+
/** The contract, as a URL (findings link `<docs>#<section>`). */
|
|
85
|
+
docs: string;
|
|
86
|
+
/** Where the business vocabulary is, as the reader would find it. */
|
|
87
|
+
vocabulary: string;
|
|
88
|
+
/** The command that scaffolds a complete theme. */
|
|
89
|
+
scaffold: string;
|
|
90
|
+
/** Where a template previews, by template id (`default` is the primary). */
|
|
91
|
+
preview: (templateId: string) => string;
|
|
92
|
+
/**
|
|
93
|
+
* True when Queek is checking a submission. Two checks only mean anything
|
|
94
|
+
* then: demo art on Queek's CDN, and screenshots uploaded — both done by the
|
|
95
|
+
* submission pipeline itself, never by the developer.
|
|
96
|
+
*/
|
|
97
|
+
submission: boolean;
|
|
98
|
+
}
|
|
99
|
+
export interface ThemeContext {
|
|
100
|
+
env: CheckEnv;
|
|
101
|
+
slug: string;
|
|
102
|
+
dir: string;
|
|
103
|
+
/** `active: false` in theme.config.ts — retired, exempt from publish gates. */
|
|
104
|
+
retired: boolean;
|
|
105
|
+
/** The primary store — `demos[0].data`. Kept for rules that only ever read it. */
|
|
106
|
+
demo: Record<string, unknown> | null;
|
|
107
|
+
/** Every store on disk, the primary first. */
|
|
108
|
+
demos: DemoStore[];
|
|
109
|
+
/** `demos` from theme.config.ts; null when the config could not be loaded. */
|
|
110
|
+
declaredDemos: DeclaredDemo[] | null;
|
|
111
|
+
/** `default_demo.description` from theme.config.ts — the primary template's description. */
|
|
112
|
+
defaultDescription: string | null;
|
|
113
|
+
/** `default_demo.for` from theme.config.ts, unvalidated — the primary template's business. */
|
|
114
|
+
defaultFor: unknown;
|
|
115
|
+
manifest: ThemeManifest | null;
|
|
116
|
+
/** Declares page-block variants, so the page-based structure applies. */
|
|
117
|
+
pageBased: boolean;
|
|
118
|
+
file: (relative: string) => string;
|
|
119
|
+
exists: (relative: string) => boolean;
|
|
120
|
+
read: (relative: string) => string | null;
|
|
121
|
+
}
|
|
122
|
+
export interface Rule {
|
|
123
|
+
id: string;
|
|
124
|
+
/** One line, shown by `--list`. */
|
|
125
|
+
summary: string;
|
|
126
|
+
/** Render rules import theme modules and are slow — `--static` skips them. */
|
|
127
|
+
kind: 'static' | 'render';
|
|
128
|
+
run: (context: ThemeContext) => Finding[] | Promise<Finding[]>;
|
|
129
|
+
}
|
|
130
|
+
export declare function finding(context: ThemeContext, rule: string, severity: Severity, parts: Omit<Finding, 'rule' | 'severity' | 'theme'>): Finding;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "is this theme valid", shared by three consumers: the
|
|
3
|
+
* author's `yarn theme:check`, this repo's CI, and (later) the pull/publish
|
|
4
|
+
* command. The moment validity has two implementations they drift, and an
|
|
5
|
+
* author gets a green locally and a rejection here.
|
|
6
|
+
*
|
|
7
|
+
* Rules therefore live in this library and the tests assert over it — not the
|
|
8
|
+
* other way round, which is where five of these gates started.
|
|
9
|
+
*/
|
|
10
|
+
export function finding(context, rule, severity, parts) {
|
|
11
|
+
return { rule, severity, theme: context.slug, ...parts };
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const SERVICE_SLUGS: readonly string[];
|
|
2
|
+
export declare const CATALOGUE: Readonly<Record<string, readonly string[]>>;
|
|
3
|
+
export declare const BUSINESS_KEYS: ReadonlySet<string>;
|
|
4
|
+
export declare function isBusinessKey(key: string): boolean;
|
|
5
|
+
/** A catalogue branch's root (`wigs-extensions-hair-accessories` → `beauty-personal-care`); any other key is its own. */
|
|
6
|
+
export declare function businessRoot(key: string): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The business vocabulary a template's `for` speaks (business-vocabulary.json):
|
|
3
|
+
* the service slugs a vendor registers as, plus the marketplace catalogue's
|
|
4
|
+
* roots and branches — what tells a wig seller from a makeup seller when both
|
|
5
|
+
* register as beauty. Agreed with the backend; theme-check rejects any other key.
|
|
6
|
+
*/
|
|
7
|
+
import vocabulary from './business-vocabulary.json' with { type: 'json' };
|
|
8
|
+
export const SERVICE_SLUGS = vocabulary.services;
|
|
9
|
+
export const CATALOGUE = vocabulary.catalogue;
|
|
10
|
+
const ROOT_OF = new Map(Object.entries(CATALOGUE).flatMap(([root, branches]) => [[root, root], ...branches.map((branch) => [branch, root])]));
|
|
11
|
+
export const BUSINESS_KEYS = new Set([...SERVICE_SLUGS, ...ROOT_OF.keys()]);
|
|
12
|
+
export function isBusinessKey(key) {
|
|
13
|
+
return BUSINESS_KEYS.has(key);
|
|
14
|
+
}
|
|
15
|
+
/** A catalogue branch's root (`wigs-extensions-hair-accessories` → `beauty-personal-care`); any other key is its own. */
|
|
16
|
+
export function businessRoot(key) {
|
|
17
|
+
return ROOT_OF.get(key) ?? key;
|
|
18
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "The only keys a template's `for` may use (theme.config.ts default_demo.for / demos[].for). `services` are the slugs a vendor registers as; `catalogue` is the marketplace product taxonomy, root -> branches, each key the Str::slug of the category name. The backend reads a vendor's business from what they actually sell and ranks a specific (branch) match above a broad one. Agreed with queek_backend 24/9/26 (storefront-theme-templates-contract.md, R2.1); change it only together with the backend.",
|
|
3
|
+
"services": [
|
|
4
|
+
"foods", "local-meals", "shawarma-pizza-snacks", "suya", "boli", "fruits-fresh-natural",
|
|
5
|
+
"meat-fish", "supermarket", "groceries", "shop-groceries", "local-market", "pharmacy",
|
|
6
|
+
"health-wellness-store", "fashion", "beauty-cosmetics", "electronics", "phones-accessories",
|
|
7
|
+
"home-living", "baby-kids", "office-school", "shop", "laundry", "gas-refill", "home-cleaning",
|
|
8
|
+
"car-wash"
|
|
9
|
+
],
|
|
10
|
+
"catalogue": {
|
|
11
|
+
"fashion": ["womens-fashion", "mens-fashion", "kids-fashion", "shoes", "bags-accessories", "modest-occasion-wear"],
|
|
12
|
+
"beauty-personal-care": ["makeup", "skincare", "hair-care-styling", "fragrance", "wigs-extensions-hair-accessories", "personal-care", "beauty-tools-devices"],
|
|
13
|
+
"electronics": ["computers-laptops", "tv-home-entertainment", "gaming-consoles", "cameras-drones", "smart-home-security", "power-solar"],
|
|
14
|
+
"phones-tablets": ["smartphones", "tablets-e-readers", "smartwatches-wearables", "chargers-power-banks", "earbuds-headsets", "cases-screen-protection", "phone-repair-replacement"],
|
|
15
|
+
"home-living": ["furniture", "home-decor", "kitchen-dining", "bedding-bath", "lighting-electrical", "storage-organization"],
|
|
16
|
+
"baby-kids": ["baby-gear", "feeding-nursing", "diapering-potty", "baby-clothing", "kids-clothing", "toys-games", "school-essentials", "nursery-safety"],
|
|
17
|
+
"health-wellness": ["vitamins-supplements", "personal-care-hygiene", "fitness-recovery", "sexual-wellness", "medical-supplies", "health-devices", "healthy-living"],
|
|
18
|
+
"office-school": ["stationery-writing", "office-supplies", "school-supplies", "bags-lunch-gear", "office-furniture", "printers-accessories", "tech-for-school-office"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image references inside a theme's `demo.json`.
|
|
3
|
+
*
|
|
4
|
+
* Three consumers must never disagree about what counts as an image: the
|
|
5
|
+
* rehost command (what to upload and rewrite), `/api/theme-registry` (what to
|
|
6
|
+
* publish as `variant_images`, and what to refuse to serve), and the CI
|
|
7
|
+
* assertion that keeps foreign art out of the tree. They all read from here.
|
|
8
|
+
*
|
|
9
|
+
* Collection walks VALUES, not field names — 26 references in
|
|
10
|
+
* `themes/default/demo.json` are bare elements of `products[].media.gallery`
|
|
11
|
+
* with no field name at all. But a value walk alone over-collects: `url`
|
|
12
|
+
* carries section art (342 of them) AND social links
|
|
13
|
+
* (`https://instagram.com/theglowedit`), so every value is classified rather
|
|
14
|
+
* than assumed to be an image.
|
|
15
|
+
*/
|
|
16
|
+
/** The prefix rehosted theme art lives under, inside an owned host. */
|
|
17
|
+
export declare const THEME_ASSET_PREFIX = "theme-assets/";
|
|
18
|
+
export type RefKind = 'image' | 'link' | 'unknown';
|
|
19
|
+
export interface DemoRef {
|
|
20
|
+
value: string;
|
|
21
|
+
kind: RefKind;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `null` for anything that is not a reference at all — prose, ids, markdown.
|
|
25
|
+
* `unknown` is deliberate and load-bearing: a URL on a host we have not
|
|
26
|
+
* classified is reported rather than silently treated as copy, so a new CDN
|
|
27
|
+
* cannot slip past the rehost command unnoticed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function classifyRef(value: unknown): RefKind | null;
|
|
30
|
+
/** Every classified reference in `node`, in document order, duplicates kept. */
|
|
31
|
+
export declare function collectRefs(node: unknown): DemoRef[];
|
|
32
|
+
/** Image references in document order. Duplicates kept — order is the contract. */
|
|
33
|
+
export declare function collectImageRefs(node: unknown): string[];
|
|
34
|
+
/** URLs on hosts we cannot classify. The rehost command warns on these. */
|
|
35
|
+
export declare function collectUnknownRefs(node: unknown): string[];
|
|
36
|
+
/** True for an image already served from a Queek host under `theme-assets/`. */
|
|
37
|
+
export declare function isOwnedImageRef(ref: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Image references that must not ship: a foreign host, or a local file that
|
|
40
|
+
* exists only in a contributor's checkout.
|
|
41
|
+
*/
|
|
42
|
+
export declare function foreignImageRefs(node: unknown): string[];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image references inside a theme's `demo.json`.
|
|
3
|
+
*
|
|
4
|
+
* Three consumers must never disagree about what counts as an image: the
|
|
5
|
+
* rehost command (what to upload and rewrite), `/api/theme-registry` (what to
|
|
6
|
+
* publish as `variant_images`, and what to refuse to serve), and the CI
|
|
7
|
+
* assertion that keeps foreign art out of the tree. They all read from here.
|
|
8
|
+
*
|
|
9
|
+
* Collection walks VALUES, not field names — 26 references in
|
|
10
|
+
* `themes/default/demo.json` are bare elements of `products[].media.gallery`
|
|
11
|
+
* with no field name at all. But a value walk alone over-collects: `url`
|
|
12
|
+
* carries section art (342 of them) AND social links
|
|
13
|
+
* (`https://instagram.com/theglowedit`), so every value is classified rather
|
|
14
|
+
* than assumed to be an image.
|
|
15
|
+
*/
|
|
16
|
+
/** Hosts Queek serves its own media from. `R2_URL` extends this when set. */
|
|
17
|
+
const OWNED_IMAGE_HOSTS = new Set(['media.usequeek.com']);
|
|
18
|
+
/** The prefix rehosted theme art lives under, inside an owned host. */
|
|
19
|
+
export const THEME_ASSET_PREFIX = 'theme-assets/';
|
|
20
|
+
const IMAGE_EXTENSION = /\.(?:jpe?g|png|webp|avif|gif)$/i;
|
|
21
|
+
/** Image CDNs that serve extensionless URLs (Unsplash: `/photo-1441986…?w=`). */
|
|
22
|
+
const IMAGE_HOSTS = new Set([
|
|
23
|
+
'images.unsplash.com',
|
|
24
|
+
'plus.unsplash.com',
|
|
25
|
+
'source.unsplash.com',
|
|
26
|
+
'res.cloudinary.com',
|
|
27
|
+
'cdn.shopify.com',
|
|
28
|
+
'images.pexels.com',
|
|
29
|
+
]);
|
|
30
|
+
/** Hosts a demo links TO. Never fetched, never rewritten, never gated. */
|
|
31
|
+
const LINK_HOSTS = new Set([
|
|
32
|
+
'instagram.com', 'www.instagram.com',
|
|
33
|
+
'x.com', 'www.x.com', 'twitter.com', 'www.twitter.com',
|
|
34
|
+
'facebook.com', 'www.facebook.com',
|
|
35
|
+
'tiktok.com', 'www.tiktok.com',
|
|
36
|
+
'pinterest.com', 'www.pinterest.com',
|
|
37
|
+
'linkedin.com', 'www.linkedin.com',
|
|
38
|
+
'threads.net', 'www.threads.net',
|
|
39
|
+
'snapchat.com', 't.me',
|
|
40
|
+
'wa.me', 'api.whatsapp.com',
|
|
41
|
+
'youtube.com', 'www.youtube.com', 'youtu.be',
|
|
42
|
+
'vimeo.com', 'player.vimeo.com',
|
|
43
|
+
'maps.google.com', 'maps.app.goo.gl', 'goo.gl',
|
|
44
|
+
]);
|
|
45
|
+
function ownedHosts() {
|
|
46
|
+
const hosts = new Set(OWNED_IMAGE_HOSTS);
|
|
47
|
+
const configured = process.env.R2_URL;
|
|
48
|
+
if (configured) {
|
|
49
|
+
try {
|
|
50
|
+
hosts.add(new URL(configured).hostname.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A malformed R2_URL is the rehost command's problem, not the gate's.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return hosts;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `null` for anything that is not a reference at all — prose, ids, markdown.
|
|
60
|
+
* `unknown` is deliberate and load-bearing: a URL on a host we have not
|
|
61
|
+
* classified is reported rather than silently treated as copy, so a new CDN
|
|
62
|
+
* cannot slip past the rehost command unnoticed.
|
|
63
|
+
*/
|
|
64
|
+
export function classifyRef(value) {
|
|
65
|
+
if (typeof value !== 'string')
|
|
66
|
+
return null;
|
|
67
|
+
const trimmed = value.trim();
|
|
68
|
+
if (!trimmed)
|
|
69
|
+
return null;
|
|
70
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(trimmed);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const host = url.hostname.toLowerCase();
|
|
79
|
+
if (LINK_HOSTS.has(host))
|
|
80
|
+
return 'link';
|
|
81
|
+
if (IMAGE_HOSTS.has(host) || IMAGE_EXTENSION.test(url.pathname))
|
|
82
|
+
return 'image';
|
|
83
|
+
return 'unknown';
|
|
84
|
+
}
|
|
85
|
+
// A local file a theme builder dropped in the folder. Requires an extension,
|
|
86
|
+
// so prose containing a slash is never mistaken for a path.
|
|
87
|
+
if (/^\.{0,2}\//.test(trimmed) || /^[\w.-]+\//.test(trimmed)) {
|
|
88
|
+
return IMAGE_EXTENSION.test(trimmed.split(/[?#]/)[0]) ? 'image' : null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** Every classified reference in `node`, in document order, duplicates kept. */
|
|
93
|
+
export function collectRefs(node) {
|
|
94
|
+
const found = [];
|
|
95
|
+
const walk = (value) => {
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
const kind = classifyRef(value);
|
|
98
|
+
if (kind)
|
|
99
|
+
found.push({ value: value.trim(), kind });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
value.forEach(walk);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (value && typeof value === 'object') {
|
|
107
|
+
Object.values(value).forEach(walk);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(node);
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
/** Image references in document order. Duplicates kept — order is the contract. */
|
|
114
|
+
export function collectImageRefs(node) {
|
|
115
|
+
return collectRefs(node).filter((ref) => ref.kind === 'image').map((ref) => ref.value);
|
|
116
|
+
}
|
|
117
|
+
/** URLs on hosts we cannot classify. The rehost command warns on these. */
|
|
118
|
+
export function collectUnknownRefs(node) {
|
|
119
|
+
return [...new Set(collectRefs(node).filter((ref) => ref.kind === 'unknown').map((ref) => ref.value))];
|
|
120
|
+
}
|
|
121
|
+
/** True for an image already served from a Queek host under `theme-assets/`. */
|
|
122
|
+
export function isOwnedImageRef(ref) {
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(ref);
|
|
125
|
+
return ownedHosts().has(url.hostname.toLowerCase())
|
|
126
|
+
&& url.pathname.replace(/^\//, '').startsWith(THEME_ASSET_PREFIX);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Image references that must not ship: a foreign host, or a local file that
|
|
134
|
+
* exists only in a contributor's checkout.
|
|
135
|
+
*/
|
|
136
|
+
export function foreignImageRefs(node) {
|
|
137
|
+
return [...new Set(collectImageRefs(node).filter((ref) => !isOwnedImageRef(ref)))];
|
|
138
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const DEMO_SEPARATOR = "~";
|
|
2
|
+
/** The id `demo.json` goes by. Reserved — never a file under `demos/`. */
|
|
3
|
+
export declare const PRIMARY_DEMO_ID = "default";
|
|
4
|
+
/** Same shape as a theme slug: it lands in a URL and a filename. */
|
|
5
|
+
export declare const DEMO_ID_FORMAT: RegExp;
|
|
6
|
+
export interface PreviewTheme {
|
|
7
|
+
slug: string;
|
|
8
|
+
demoId: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* `roast` → the primary; `roast~foods` → the `foods` store. Null for anything
|
|
12
|
+
* else, including `roast~default` — one canonical URL per store.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parsePreviewTheme(param: string): PreviewTheme | null;
|
|
15
|
+
/** The theme segment for a store: `roast`, or `roast~foods`. */
|
|
16
|
+
export declare function previewThemeParam(slug: string, demoId: string): string;
|
|
17
|
+
export interface DemoFile {
|
|
18
|
+
id: string;
|
|
19
|
+
/** Absolute path. */
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function demoFilePath(themeDir: string, demoId: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Every demo store on disk: the primary first, then `demos/*.json` by name.
|
|
25
|
+
* Files whose name is not a valid id are listed too — the identity rule is
|
|
26
|
+
* what rejects them, and it can only do that if it sees them.
|
|
27
|
+
*/
|
|
28
|
+
export declare function demoFilesOf(themeDir: string): DemoFile[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A theme's demo stores.
|
|
3
|
+
*
|
|
4
|
+
* `demo.json` is the primary — what the thumbnail, the section-library capture
|
|
5
|
+
* and the registry's top-level compositions come from. `demos/<id>.json` are
|
|
6
|
+
* alternatives (the same store shape, different business): roast previewed as
|
|
7
|
+
* a restaurant instead of a coffee house. Each is declared in `theme.config.ts`
|
|
8
|
+
* (`demos: [{ id, label, for }]`) so the registry can publish it and the
|
|
9
|
+
* backend can offer a food vendor the food demo.
|
|
10
|
+
*
|
|
11
|
+
* The preview URL carries the demo in the theme segment — `roast~foods` —
|
|
12
|
+
* because that is the one place every route, link and the proxy already
|
|
13
|
+
* agree on. `~` is URL-unreserved and cannot appear in a slug, so the split
|
|
14
|
+
* is unambiguous. Four consumers must never disagree about this: the preview
|
|
15
|
+
* routes, theme-check, the registry generator and the rehost command. They
|
|
16
|
+
* all read from here.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
export const DEMO_SEPARATOR = '~';
|
|
21
|
+
/** The id `demo.json` goes by. Reserved — never a file under `demos/`. */
|
|
22
|
+
export const PRIMARY_DEMO_ID = 'default';
|
|
23
|
+
/** Same shape as a theme slug: it lands in a URL and a filename. */
|
|
24
|
+
export const DEMO_ID_FORMAT = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
|
+
/**
|
|
26
|
+
* `roast` → the primary; `roast~foods` → the `foods` store. Null for anything
|
|
27
|
+
* else, including `roast~default` — one canonical URL per store.
|
|
28
|
+
*/
|
|
29
|
+
export function parsePreviewTheme(param) {
|
|
30
|
+
const parts = param.split(DEMO_SEPARATOR);
|
|
31
|
+
if (parts.length > 2)
|
|
32
|
+
return null;
|
|
33
|
+
const [slug, demoId = PRIMARY_DEMO_ID] = parts;
|
|
34
|
+
if (!DEMO_ID_FORMAT.test(slug) || !DEMO_ID_FORMAT.test(demoId))
|
|
35
|
+
return null;
|
|
36
|
+
if (parts.length === 2 && demoId === PRIMARY_DEMO_ID)
|
|
37
|
+
return null;
|
|
38
|
+
return { slug, demoId };
|
|
39
|
+
}
|
|
40
|
+
/** The theme segment for a store: `roast`, or `roast~foods`. */
|
|
41
|
+
export function previewThemeParam(slug, demoId) {
|
|
42
|
+
return demoId === PRIMARY_DEMO_ID ? slug : `${slug}${DEMO_SEPARATOR}${demoId}`;
|
|
43
|
+
}
|
|
44
|
+
export function demoFilePath(themeDir, demoId) {
|
|
45
|
+
return demoId === PRIMARY_DEMO_ID ? join(themeDir, 'demo.json') : join(themeDir, 'demos', `${demoId}.json`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Every demo store on disk: the primary first, then `demos/*.json` by name.
|
|
49
|
+
* Files whose name is not a valid id are listed too — the identity rule is
|
|
50
|
+
* what rejects them, and it can only do that if it sees them.
|
|
51
|
+
*/
|
|
52
|
+
export function demoFilesOf(themeDir) {
|
|
53
|
+
const files = [];
|
|
54
|
+
const primary = demoFilePath(themeDir, PRIMARY_DEMO_ID);
|
|
55
|
+
if (existsSync(primary))
|
|
56
|
+
files.push({ id: PRIMARY_DEMO_ID, path: primary });
|
|
57
|
+
const dir = join(themeDir, 'demos');
|
|
58
|
+
if (!existsSync(dir))
|
|
59
|
+
return files;
|
|
60
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
61
|
+
if (!entry.endsWith('.json'))
|
|
62
|
+
continue;
|
|
63
|
+
files.push({ id: entry.slice(0, -'.json'.length), path: join(dir, entry) });
|
|
64
|
+
}
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
/** Longest `description` a template may carry — written for a model choosing on a merchant's behalf. */
|
|
14
|
+
export declare const TEMPLATE_DESCRIPTION_MAX = 300;
|
|
15
|
+
/** How `themes/_bare`'s description starts — a new theme must replace it before it publishes. */
|
|
16
|
+
export declare const TEMPLATE_DESCRIPTION_PLACEHOLDER = "Replace before publishing.";
|
|
17
|
+
/** Where rehosted theme imagery is served — the host the backend passes through untouched. */
|
|
18
|
+
export declare const THEME_ASSET_BASE = "https://media.usequeek.com/theme-assets";
|
|
19
|
+
/** Per-theme record of the screenshots the rehost script has uploaded. */
|
|
20
|
+
export declare const SCREENSHOT_LOCK = "screenshots.json";
|
|
21
|
+
/**
|
|
22
|
+
* The screenshot file a template is judged by, relative to the theme dir:
|
|
23
|
+
* `theme.jpg` (or `theme.png`) for the primary, `demos/<id>.jpg` for the rest.
|
|
24
|
+
* `exists` decides between jpg and png for the primary, so callers pass their
|
|
25
|
+
* own file check (fs, or theme-check's context).
|
|
26
|
+
*/
|
|
27
|
+
export declare function screenshotFile(demoId: string, exists: (relative: string) => boolean): string;
|
|
28
|
+
/**
|
|
29
|
+
* Content-addressed, like the section art: `theme-assets/{theme}/{sha256[..16]}.{ext}`.
|
|
30
|
+
* A re-captured screenshot gets a new URL, so no cache anywhere keeps showing
|
|
31
|
+
* the old picture — and the URL is known from the bytes alone, offline.
|
|
32
|
+
*/
|
|
33
|
+
export declare function screenshotKey(theme: string, bytes: Uint8Array, file: string): string;
|
|
34
|
+
export declare function screenshotUrl(theme: string, bytes: Uint8Array, file: string): string;
|
|
35
|
+
export interface TokenReach {
|
|
36
|
+
/** The theme's CSS reads the kit's colour vars (`--brand-bg`, `--brand-accent`, …). */
|
|
37
|
+
colors: boolean;
|
|
38
|
+
/** The theme's CSS reads the kit's face vars (`--font-heading` / `--font-body`). */
|
|
39
|
+
fonts: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Which token groups a theme actually renders from. A theme that hard-codes
|
|
43
|
+
* its palette (roast, glow, carat) publishes tokens without `color`, so the
|
|
44
|
+
* backend never promises a merchant a colour change the theme will ignore.
|
|
45
|
+
* Comments are stripped first — glow mentions `--brand-text` only in one.
|
|
46
|
+
*/
|
|
47
|
+
export declare function tokenReach(css: string): TokenReach;
|
|
48
|
+
/**
|
|
49
|
+
* A template's tokens as the backend should apply them: the dials always
|
|
50
|
+
* (sizes, weights, spacing, radius, motion, image treatment), the palette and
|
|
51
|
+
* the faces only when the theme reads them. Undefined when nothing is left.
|
|
52
|
+
*/
|
|
53
|
+
export declare function templateTokens(tokens: unknown, reach: TokenReach): Record<string, unknown> | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* The header (or footer) variant a template names, when the theme implements
|
|
56
|
+
* one of that scope. A theme with no variants of the scope (the single-page
|
|
57
|
+
* default theme) publishes null; a name the theme does not implement also
|
|
58
|
+
* publishes null here and is a theme-check rejection, never a silent fallback.
|
|
59
|
+
*/
|
|
60
|
+
export declare function templateChrome(demo: unknown, scope: 'header' | 'footer', implemented: string[]): string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Whether a declared field is presentational — how the section looks, not
|
|
63
|
+
* what it says: a structured `enum` / `boolean` / `color` field, or a string
|
|
64
|
+
* descriptor that is an `a|b|c` enum or a `bool`. Copy, images, ids and links
|
|
65
|
+
* never qualify; neither do the query fields.
|
|
66
|
+
*/
|
|
67
|
+
export declare function isPresentationalField(name: string, spec: unknown): boolean;
|
|
68
|
+
/** The presentational field names a variant declares. */
|
|
69
|
+
export declare function presentationalFields(fields: Record<string, unknown> | undefined): string[];
|
|
70
|
+
/** `{type: {variantId: fields}}` from a manifest's (or registry entry's) `variants`. */
|
|
71
|
+
export declare function declaredFieldsByVariant(variants: unknown): Record<string, Record<string, Record<string, unknown>>>;
|
|
72
|
+
/**
|
|
73
|
+
* A section's `style`: the presentational fields its variant declares, with
|
|
74
|
+
* the values the template set. Only primitives — never copy, images, ids or
|
|
75
|
+
* links, which the vendor's content fills. Undefined when there are none.
|
|
76
|
+
*/
|
|
77
|
+
export declare function sectionStyle(section: {
|
|
78
|
+
type?: unknown;
|
|
79
|
+
variant?: unknown;
|
|
80
|
+
data?: unknown;
|
|
81
|
+
}, declared: Record<string, Record<string, Record<string, unknown>>>): Record<string, string | number | boolean> | undefined;
|
|
82
|
+
type Copy = Record<string, unknown>;
|
|
83
|
+
/**
|
|
84
|
+
* A section's `copy`: the words the template set in the fields its variant
|
|
85
|
+
* declares as text — headings, body, markdown, button labels, and the text
|
|
86
|
+
* keys of its list entries (steps, slides, FAQ items). Never images, links,
|
|
87
|
+
* ids, prices or product and collection references. The backend builds a
|
|
88
|
+
* section with no vendor facts behind it (a how-to, a statement) from this,
|
|
89
|
+
* and qee rewrites it for the vendor. Undefined when there are none.
|
|
90
|
+
*/
|
|
91
|
+
export declare function sectionCopy(section: {
|
|
92
|
+
type?: unknown;
|
|
93
|
+
variant?: unknown;
|
|
94
|
+
data?: unknown;
|
|
95
|
+
}, declared: Record<string, Record<string, Record<string, unknown>>>): Copy | undefined;
|
|
96
|
+
export {};
|