@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,167 @@
1
+ import ts from 'typescript';
2
+ /**
3
+ * `subscribe` variants rendered by vendor-shell.tsx itself (theme-agnostic,
4
+ * every theme gets them for free) rather than by a per-theme component in
5
+ * that theme's own `variantImplementations` — see subscribe-{inline,
6
+ * floating,modal}. A theme's OWN bespoke subscribe variant (e.g. glow's
7
+ * `glow-benefits`) is NOT framework-owned; it must appear in that theme's
8
+ * `variantImplementations.subscribe` map instead. One shared list so the
9
+ * registry generator and the parity test can't drift out of sync.
10
+ */
11
+ export const FRAMEWORK_OWNED_SUBSCRIBE_VARIANTS = ['inline', 'floating', 'modal'];
12
+ const MAP_NAME = 'variantImplementations';
13
+ const SHAPE_ERROR = `${MAP_NAME} must be a plain nested object literal with component identifier leaves`;
14
+ const CONTENT_FIELD_TYPES = new Set([
15
+ 'string', 'text', 'markdown', 'image', 'url', 'int', 'number', 'boolean',
16
+ 'color', 'string[]', 'image[]', 'object', 'object[]', 'enum',
17
+ ]);
18
+ function unwrapExpression(expression) {
19
+ let current = expression;
20
+ while (ts.isParenthesizedExpression(current) ||
21
+ ts.isAsExpression(current) ||
22
+ ts.isSatisfiesExpression(current)) {
23
+ current = current.expression;
24
+ }
25
+ return current;
26
+ }
27
+ function literalPropertyName(name) {
28
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
29
+ return name.text;
30
+ }
31
+ return null;
32
+ }
33
+ function isDirectlyExported(statement) {
34
+ return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
35
+ }
36
+ function shapeError(fileName) {
37
+ return new Error(`${fileName}: ${SHAPE_ERROR}`);
38
+ }
39
+ export function parseVariantImplementations(source, fileName) {
40
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
41
+ let initializer = null;
42
+ for (const statement of sourceFile.statements) {
43
+ if (!ts.isVariableStatement(statement) || !isDirectlyExported(statement))
44
+ continue;
45
+ for (const declaration of statement.declarationList.declarations) {
46
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === MAP_NAME) {
47
+ initializer = declaration.initializer ?? null;
48
+ }
49
+ }
50
+ }
51
+ if (!initializer) {
52
+ throw new Error(`${fileName}: must directly export const ${MAP_NAME}`);
53
+ }
54
+ const root = unwrapExpression(initializer);
55
+ if (!ts.isObjectLiteralExpression(root))
56
+ throw shapeError(fileName);
57
+ const parsed = {};
58
+ for (const scopeProperty of root.properties) {
59
+ if (!ts.isPropertyAssignment(scopeProperty))
60
+ throw shapeError(fileName);
61
+ const scope = literalPropertyName(scopeProperty.name);
62
+ const scopeValue = unwrapExpression(scopeProperty.initializer);
63
+ if (!scope || !ts.isObjectLiteralExpression(scopeValue) || parsed[scope])
64
+ throw shapeError(fileName);
65
+ const variants = {};
66
+ const registeredComponents = new Map();
67
+ for (const variantProperty of scopeValue.properties) {
68
+ if (!ts.isPropertyAssignment(variantProperty))
69
+ throw shapeError(fileName);
70
+ const variantId = literalPropertyName(variantProperty.name);
71
+ const component = unwrapExpression(variantProperty.initializer);
72
+ if (!variantId || variants[variantId] || !ts.isIdentifier(component))
73
+ throw shapeError(fileName);
74
+ const previousVariant = registeredComponents.get(component.text);
75
+ if (previousVariant) {
76
+ throw new Error(`${fileName}: ${scope}.${variantId} reuses ${component.text} already registered by ${scope}.${previousVariant}`);
77
+ }
78
+ variants[variantId] = component.text;
79
+ registeredComponents.set(component.text, variantId);
80
+ }
81
+ parsed[scope] = variants;
82
+ }
83
+ return parsed;
84
+ }
85
+ export function validateVariantParity(themeSlug, manifestVariants, implementations, frameworkOwned) {
86
+ const manifestKeys = new Set();
87
+ for (const [scope, variants] of Object.entries(manifestVariants)) {
88
+ const ids = new Set();
89
+ for (const variant of variants) {
90
+ if (ids.has(variant.id)) {
91
+ throw new Error(`Theme "${themeSlug}" manifest has duplicate variant id ${scope}.${variant.id}`);
92
+ }
93
+ ids.add(variant.id);
94
+ manifestKeys.add(`${scope}.${variant.id}`);
95
+ }
96
+ }
97
+ const implementedKeys = new Set();
98
+ for (const [scope, variants] of Object.entries(implementations)) {
99
+ for (const id of Object.keys(variants))
100
+ implementedKeys.add(`${scope}.${id}`);
101
+ }
102
+ for (const [scope, ids] of Object.entries(frameworkOwned)) {
103
+ const declaredIds = new Set(manifestVariants[scope]?.map((variant) => variant.id) ?? []);
104
+ for (const id of ids) {
105
+ if (declaredIds.has(id))
106
+ implementedKeys.add(`${scope}.${id}`);
107
+ }
108
+ }
109
+ const manifestOnly = [...manifestKeys].filter((key) => !implementedKeys.has(key)).sort();
110
+ const implementationOnly = [...implementedKeys].filter((key) => !manifestKeys.has(key)).sort();
111
+ if (manifestOnly.length > 0 || implementationOnly.length > 0) {
112
+ throw new Error(`Theme "${themeSlug}" variant parity failed: manifest-only [${manifestOnly.join(', ')}]; implementation-only [${implementationOnly.join(', ')}]`);
113
+ }
114
+ const evidence = {};
115
+ for (const scope of Object.keys(manifestVariants).sort()) {
116
+ evidence[scope] = manifestVariants[scope].map((variant) => variant.id).sort();
117
+ }
118
+ return evidence;
119
+ }
120
+ function isPlainObject(value) {
121
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
122
+ }
123
+ function validateStructuredContentField(value, path) {
124
+ if (typeof value.type !== 'string' || !CONTENT_FIELD_TYPES.has(value.type)) {
125
+ throw new Error(`${path} has unknown type "${String(value.type)}"`);
126
+ }
127
+ if (value.type === 'enum' && (!Array.isArray(value.options) || value.options.length === 0 || value.options.some((option) => typeof option !== 'string'))) {
128
+ throw new Error(`${path} enum fields require non-empty options`);
129
+ }
130
+ if (value.type === 'object[]') {
131
+ if (!isPlainObject(value.of) || Object.keys(value.of).length === 0) {
132
+ throw new Error(`${path} object[] fields require an of object`);
133
+ }
134
+ return 1 + Object.entries(value.of).reduce((count, [key, nested]) => {
135
+ if (!isPlainObject(nested)) {
136
+ throw new Error(`${path}.of.${key} must be a structured field object`);
137
+ }
138
+ return count + validateStructuredContentField(nested, `${path}.of.${key}`);
139
+ }, 0);
140
+ }
141
+ return 1;
142
+ }
143
+ /**
144
+ * Checks the content-field migration independently of runtime variant parity.
145
+ * Legacy strings remain accepted until a theme sets `content_fields: 'structured'`.
146
+ */
147
+ export function validateContentFieldContract(themeSlug, manifestVariants, contract = undefined) {
148
+ let legacyFieldCount = 0;
149
+ let structuredFieldCount = 0;
150
+ for (const variant of manifestVariants.content ?? []) {
151
+ for (const [fieldName, field] of Object.entries(variant.fields ?? {})) {
152
+ const path = `content.${variant.id}.${fieldName}`;
153
+ if (typeof field === 'string') {
154
+ if (contract === 'structured') {
155
+ throw new Error(`Theme "${themeSlug}" content fields are marked structured but ${path} is still legacy`);
156
+ }
157
+ legacyFieldCount += 1;
158
+ continue;
159
+ }
160
+ if (!isPlainObject(field)) {
161
+ throw new Error(`Theme "${themeSlug}" ${path} must be a legacy string or structured field object`);
162
+ }
163
+ structuredFieldCount += validateStructuredContentField(field, path);
164
+ }
165
+ }
166
+ return { legacyFieldCount, structuredFieldCount };
167
+ }
@@ -0,0 +1,5 @@
1
+ import { type Rule } from '../types.js';
2
+ export declare const variantParityRule: Rule;
3
+ export declare const fieldParityRule: Rule;
4
+ export declare const designTokensRule: Rule;
5
+ export declare const ANALYSIS_RULES: Rule[];
@@ -0,0 +1,105 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { importKit } from '../context.js';
4
+ import { FRAMEWORK_OWNED_SUBSCRIBE_VARIANTS, parseVariantImplementations, validateContentFieldContract, validateVariantParity, } from '../parity/variant-parity.js';
5
+ import { findThemeFieldParityViolations } from '../parity/field-parity.js';
6
+ import { finding } from '../types.js';
7
+ /**
8
+ * Rules that read the theme's source with the TypeScript compiler rather than
9
+ * rendering it: the manifest against the components, and the design tokens
10
+ * against the CSS.
11
+ */
12
+ function indexPath(context) {
13
+ return ['index.ts', 'index.tsx'].map((name) => join(context.dir, name)).find(existsSync) ?? null;
14
+ }
15
+ export const variantParityRule = {
16
+ id: 'theme/variant-parity',
17
+ summary: 'Every manifest variant has a renderer, and vice versa',
18
+ kind: 'render',
19
+ run(context) {
20
+ if (context.retired)
21
+ return [];
22
+ const path = indexPath(context);
23
+ if (!path || !context.manifest)
24
+ return [];
25
+ const manifest = context.manifest;
26
+ try {
27
+ const parsed = parseVariantImplementations(readFileSync(path, 'utf8'), path);
28
+ validateVariantParity(context.slug, manifest.variants, parsed, {
29
+ content: ['default'],
30
+ subscribe: FRAMEWORK_OWNED_SUBSCRIBE_VARIANTS,
31
+ });
32
+ validateContentFieldContract(context.slug, manifest.variants, manifest.fields_contract?.content);
33
+ return [];
34
+ }
35
+ catch (error) {
36
+ return [finding(context, 'theme/variant-parity', 'reject', {
37
+ where: `${context.env.root}manifest.ts`,
38
+ found: error.message,
39
+ fix: 'A variant the manifest declares but nothing renders shows a vendor an option that does nothing; a renderer the manifest omits can never be chosen. Both lists must match exactly.',
40
+ docs: `${context.env.docs}#manifest`,
41
+ })];
42
+ }
43
+ },
44
+ };
45
+ export const fieldParityRule = {
46
+ id: 'theme/field-parity',
47
+ summary: 'Each variant declares exactly the fields its renderer reads',
48
+ kind: 'render',
49
+ run(context) {
50
+ if (context.retired)
51
+ return [];
52
+ const path = indexPath(context);
53
+ if (!path || !context.manifest)
54
+ return [];
55
+ const manifest = context.manifest;
56
+ return findThemeFieldParityViolations(context.slug, manifest.variants, readFileSync(path, 'utf8'), path).map((violation) => finding(context, 'theme/field-parity', 'reject', {
57
+ where: `${context.env.root}manifest.ts`,
58
+ found: `${violation.variant}: ${violation.reason}`,
59
+ fix: 'A declared field the renderer never reads is a control that does nothing when a merchant edits it. A field read but not declared can never be set. Align the manifest with the component.',
60
+ docs: `${context.env.docs}#manifest`,
61
+ }));
62
+ },
63
+ };
64
+ export const designTokensRule = {
65
+ id: 'theme/design-tokens',
66
+ summary: 'Declares a complete token set, and its CSS actually consumes it',
67
+ kind: 'render',
68
+ async run(context) {
69
+ if (context.retired)
70
+ return [];
71
+ const tokens = context.manifest?.tokens;
72
+ const at = `${context.env.root}manifest.ts`;
73
+ const add = (found, fix, where = at) => finding(context, 'theme/design-tokens', 'reject', { where, found, fix, docs: `${context.env.docs}#manifest` });
74
+ if (!tokens) {
75
+ return [add('no design-token block', 'Declare `tokens` in manifest.ts. Without it a vendor cannot recolour or restyle the theme at all — it is the whole customisation surface.')];
76
+ }
77
+ const findings = [];
78
+ const missing = [
79
+ ['color', tokens.color],
80
+ ['type.heading_font', tokens.type?.heading_font],
81
+ ['type.scale_ratio', tokens.type?.scale_ratio],
82
+ ['space.density', tokens.space?.density],
83
+ ['shape.radius', tokens.shape?.radius],
84
+ ['elevation', tokens.elevation],
85
+ ['motion', tokens.motion],
86
+ ].filter(([, value]) => !value).map(([name]) => name);
87
+ if (missing.length > 0) {
88
+ findings.push(add(`tokens missing: ${missing.join(', ')}`, 'Every dimension must be declared — each one is a dial in the vendor’s theme editor.'));
89
+ }
90
+ const { expandDesignTokens } = await importKit(context.dir, '@usequeek/theme-kit/utils/brand');
91
+ const vars = expandDesignTokens(tokens);
92
+ const unexpanded = ['--brand-primary', '--brand-bg', '--brand-text', '--fs-base', '--fs-3xl', '--space-4', '--radius-md', '--shadow-2', '--duration-base', '--font-heading', '--font-body']
93
+ .filter((key) => !vars[key]);
94
+ if (unexpanded.length > 0) {
95
+ findings.push(add(`tokens do not expand to: ${unexpanded.join(', ')}`, 'Usually a malformed token value. Check it against the starter’s manifest.'));
96
+ }
97
+ const css = context.read('theme.css') ?? '';
98
+ const references = (css.match(/var\(--(fs|space|radius|shadow|duration|fw|tracking|leading|case)-/g) ?? []).length;
99
+ if (references <= 50) {
100
+ findings.push(add(`theme.css references design tokens only ${references} time(s)`, 'Hardcoded sizes and colours mean the vendor’s edits never reach the page. Style from var(--fs-*), var(--space-*), var(--radius-*) and friends.', `${context.env.root}theme.css`));
101
+ }
102
+ return findings;
103
+ },
104
+ };
105
+ export const ANALYSIS_RULES = [variantParityRule, fieldParityRule, designTokensRule];
@@ -0,0 +1,41 @@
1
+ import { type Rule } from '../types.js';
2
+ export declare const structureRule: Rule;
3
+ export declare const demoStoreRule: Rule;
4
+ /**
5
+ * The declaration in theme.config.ts and the files under demos/ are one list.
6
+ * A declared store with no file 404s in the preview the registry advertises;
7
+ * a file nobody declared is previewable but never offered; an id that is not
8
+ * a slug cannot be a URL segment; two stores on one profile.id share a cart.
9
+ */
10
+ export declare const demoStoresRule: Rule;
11
+ export declare const demoArtRule: Rule;
12
+ export declare const codeQualityRule: Rule;
13
+ export declare const sdkBoundaryRule: Rule;
14
+ /**
15
+ * The ThemeModule shape (Layout, Header, Footer, blocks, getBlock, pages…) is
16
+ * enforced by TypeScript, because a theme writes `const theme: ThemeModule`.
17
+ * The one thing the compiler cannot catch is a theme that drops the annotation
18
+ * — then nothing checks the shape at all, and core discovers the missing slot
19
+ * at render time in a vendor's store.
20
+ *
21
+ * Checked by reading the source rather than importing it: index.ts pulls the
22
+ * whole theme graph, which reaches the SDK and will not load outside a bundler.
23
+ */
24
+ export declare const moduleContractRule: Rule;
25
+ export declare const selectionMetadataRule: Rule;
26
+ export declare const demoCompletenessRule: Rule;
27
+ export declare const subscribeScopeRule: Rule;
28
+ export declare const demoBlockTypesRule: Rule;
29
+ export declare const identityRule: Rule;
30
+ export declare const productMetafieldsRule: Rule;
31
+ export declare const poweredByRule: Rule;
32
+ export declare const templateDescriptionRule: Rule;
33
+ export declare const templateScreenshotRule: Rule;
34
+ export declare const templateChromeRule: Rule;
35
+ export declare const templateStyleRule: Rule;
36
+ export declare const templateBusinessRule: Rule;
37
+ export declare const templateVersionsRule: Rule;
38
+ /** The designed pages every template ships (contract R2.3); versions `-2`… count. */
39
+ export declare const TEMPLATE_PAGES: readonly ["about", "sales", "landing"];
40
+ export declare const templatePagesRule: Rule;
41
+ export declare const STATIC_RULES: Rule[];