@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Queek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @usequeek/theme-check
|
|
2
|
+
|
|
3
|
+
The rules a [Queek](https://usequeek.com) storefront theme is checked against, as a library.
|
|
4
|
+
Most people want the command line instead: `npx queek-theme check` from
|
|
5
|
+
[`@usequeek/theme-cli`](https://www.npmjs.com/package/@usequeek/theme-cli).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @usequeek/theme-check
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { checkTheme, formatStylish, rejects } from '@usequeek/theme-check';
|
|
13
|
+
|
|
14
|
+
const result = await checkTheme('theme'); // the folder with manifest.ts
|
|
15
|
+
console.log(formatStylish(result, { color: true }));
|
|
16
|
+
process.exitCode = rejects(result.findings).length > 0 ? 1 : 0;
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Each finding: `rule` (a stable id), `severity` (`reject` blocks submission, `warn` is advice),
|
|
20
|
+
`where`, `found`, `fix` and `docs` (a link into the
|
|
21
|
+
[theme contract](https://github.com/usequeek/theme-starter/blob/main/docs/THEME.md)).
|
|
22
|
+
`AT_SUBMISSION` lists the checks only Queek can run — against its other themes, with a server
|
|
23
|
+
render, or on its storage.
|
|
24
|
+
|
|
25
|
+
Theme modules (`theme.config.ts`, `manifest.ts`) load through jiti from the theme's own
|
|
26
|
+
project, so the project must have `@usequeek/theme-kit` installed.
|
|
27
|
+
|
|
28
|
+
## License
|
|
29
|
+
|
|
30
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CheckEnv, ThemeContext } from './types.js';
|
|
2
|
+
/** The contract every theme is checked against, as published with the starter. */
|
|
3
|
+
export declare const CONTRACT_URL = "https://github.com/usequeek/theme-starter/blob/main/docs/THEME.md";
|
|
4
|
+
/** The environment a developer's own repo gets: paths relative to where they run, public links. */
|
|
5
|
+
export declare function localEnv(dir: string, cwd?: string): CheckEnv;
|
|
6
|
+
/**
|
|
7
|
+
* Everything the rules read about one theme folder. Its TypeScript modules
|
|
8
|
+
* (theme.config.ts, manifest.ts) load through jiti, resolving imports from
|
|
9
|
+
* the theme's own project — the kit the developer installed, not ours.
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadContext(themeDir: string, env?: Partial<CheckEnv>): Promise<ThemeContext>;
|
|
12
|
+
/** Every .ts/.tsx under a theme, for the whole-tree scans. */
|
|
13
|
+
export declare function themeSourceFiles(dir: string): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Load a module of the kit the theme's project installed. The kit ships
|
|
16
|
+
* TypeScript source, which Node will not execute from node_modules, so it goes
|
|
17
|
+
* through jiti — resolved from the theme, never bundled with this package.
|
|
18
|
+
*/
|
|
19
|
+
export declare function importKit<T>(themeDir: string, specifier: string): Promise<T>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { createJiti } from 'jiti';
|
|
4
|
+
import { demoFilesOf } from './utils/theme-demos.js';
|
|
5
|
+
/** The contract every theme is checked against, as published with the starter. */
|
|
6
|
+
export const CONTRACT_URL = 'https://github.com/usequeek/theme-starter/blob/main/docs/THEME.md';
|
|
7
|
+
/**
|
|
8
|
+
* Page-block scopes. A theme declaring none of them is chrome-only — it renders
|
|
9
|
+
* one page and lets core supply the rest. The page-based structure
|
|
10
|
+
* requirements do not apply to it, derived from what the theme declares.
|
|
11
|
+
*/
|
|
12
|
+
const PAGE_BLOCK_SCOPES = ['gallery', 'products', 'categories', 'content'];
|
|
13
|
+
/** The environment a developer's own repo gets: paths relative to where they run, public links. */
|
|
14
|
+
export function localEnv(dir, cwd = process.cwd()) {
|
|
15
|
+
const shown = relative(cwd, dir).replace(/\\/g, '/');
|
|
16
|
+
return {
|
|
17
|
+
root: shown === '' ? '' : `${shown.startsWith('..') ? dir.replace(/\\/g, '/') : shown}/`,
|
|
18
|
+
docs: CONTRACT_URL,
|
|
19
|
+
vocabulary: 'the business vocabulary (docs/business-vocabulary.json in the starter)',
|
|
20
|
+
scaffold: '`npm create @usequeek/theme`',
|
|
21
|
+
preview: (templateId) => `http://localhost:3000/${templateId}`,
|
|
22
|
+
submission: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Everything the rules read about one theme folder. Its TypeScript modules
|
|
27
|
+
* (theme.config.ts, manifest.ts) load through jiti, resolving imports from
|
|
28
|
+
* the theme's own project — the kit the developer installed, not ours.
|
|
29
|
+
*/
|
|
30
|
+
export async function loadContext(themeDir, env = {}) {
|
|
31
|
+
const dir = resolve(themeDir);
|
|
32
|
+
const file = (path) => join(dir, path);
|
|
33
|
+
const exists = (path) => existsSync(file(path));
|
|
34
|
+
const read = (path) => (exists(path) ? readFileSync(file(path), 'utf8') : null);
|
|
35
|
+
const jiti = createJiti(file('theme.config.ts'), { moduleCache: false, fsCache: false, interopDefault: true });
|
|
36
|
+
// A file that will not parse is kept, with null data, so the demo-store rule
|
|
37
|
+
// can name it instead of the theme looking like it has one store fewer.
|
|
38
|
+
const demos = demoFilesOf(dir).map(({ id, path }) => {
|
|
39
|
+
const shown = relative(dir, path).replace(/\\/g, '/');
|
|
40
|
+
try {
|
|
41
|
+
return { id, file: shown, data: JSON.parse(readFileSync(path, 'utf8')) };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { id, file: shown, data: null };
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
let config = null;
|
|
48
|
+
try {
|
|
49
|
+
config = await jiti.import(file('theme.config.ts'), { default: true });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
config = null;
|
|
53
|
+
}
|
|
54
|
+
let manifest = null;
|
|
55
|
+
try {
|
|
56
|
+
manifest = await jiti.import(file('manifest.ts'), { default: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
manifest = null;
|
|
60
|
+
}
|
|
61
|
+
const variants = (manifest?.variants ?? {});
|
|
62
|
+
const declared = config?.demos;
|
|
63
|
+
const primary = config?.default_demo;
|
|
64
|
+
return {
|
|
65
|
+
env: { ...localEnv(dir), ...env },
|
|
66
|
+
slug: typeof config?.slug === 'string' ? config.slug : basename(dir),
|
|
67
|
+
dir,
|
|
68
|
+
retired: config?.active === false,
|
|
69
|
+
demo: demos.find((store) => store.id === 'default')?.data ?? null,
|
|
70
|
+
demos,
|
|
71
|
+
declaredDemos: config === null ? null : Array.isArray(declared) ? declared : [],
|
|
72
|
+
defaultDescription: typeof primary?.description === 'string' ? primary.description : null,
|
|
73
|
+
defaultFor: primary?.for ?? null,
|
|
74
|
+
manifest,
|
|
75
|
+
pageBased: PAGE_BLOCK_SCOPES.some((scope) => (variants[scope] ?? []).length > 0),
|
|
76
|
+
file,
|
|
77
|
+
exists,
|
|
78
|
+
read,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Every .ts/.tsx under a theme, for the whole-tree scans. */
|
|
82
|
+
export function themeSourceFiles(dir) {
|
|
83
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
84
|
+
const full = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory())
|
|
86
|
+
return entry.name === 'node_modules' ? [] : themeSourceFiles(full);
|
|
87
|
+
return /\.tsx?$/.test(entry.name) ? [full] : [];
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Load a module of the kit the theme's project installed. The kit ships
|
|
92
|
+
* TypeScript source, which Node will not execute from node_modules, so it goes
|
|
93
|
+
* through jiti — resolved from the theme, never bundled with this package.
|
|
94
|
+
*/
|
|
95
|
+
export async function importKit(themeDir, specifier) {
|
|
96
|
+
const jiti = createJiti(join(resolve(themeDir), 'theme.config.ts'), { interopDefault: true });
|
|
97
|
+
return jiti.import(specifier);
|
|
98
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CheckResult } from './run.js';
|
|
2
|
+
import type { Finding } from './types.js';
|
|
3
|
+
/** Severity as developers' tools spell it (ESLint, SARIF, GitHub). */
|
|
4
|
+
export type Level = 'error' | 'warning';
|
|
5
|
+
export declare const levelOf: (finding: Finding) => Level;
|
|
6
|
+
/** The file part of a finding's location: `theme/demo.json → pages.home` → `theme/demo.json`. */
|
|
7
|
+
export declare function fileOf(finding: Finding): string | undefined;
|
|
8
|
+
export interface Summary {
|
|
9
|
+
errors: number;
|
|
10
|
+
warnings: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function summarize(findings: Finding[]): Summary;
|
|
13
|
+
/** Machine-readable output: stable keys, one object per run. */
|
|
14
|
+
export declare function formatJson(result: CheckResult): string;
|
|
15
|
+
/** GitHub Actions workflow commands — annotations on the pull request's diff. */
|
|
16
|
+
export declare function formatGithubActions(result: CheckResult): string;
|
|
17
|
+
/** The default, for people: findings grouped by file, each with its fix and docs. */
|
|
18
|
+
export declare function formatStylish(result: CheckResult, options: {
|
|
19
|
+
color: boolean;
|
|
20
|
+
}): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AT_SUBMISSION } from './run.js';
|
|
2
|
+
export const levelOf = (finding) => (finding.severity === 'reject' ? 'error' : 'warning');
|
|
3
|
+
/** The file part of a finding's location: `theme/demo.json → pages.home` → `theme/demo.json`. */
|
|
4
|
+
export function fileOf(finding) {
|
|
5
|
+
const where = finding.where?.split(' → ')[0]?.trim();
|
|
6
|
+
return where ? where.replace(/:\d+$/, '') : undefined;
|
|
7
|
+
}
|
|
8
|
+
export function summarize(findings) {
|
|
9
|
+
return {
|
|
10
|
+
errors: findings.filter((finding) => levelOf(finding) === 'error').length,
|
|
11
|
+
warnings: findings.filter((finding) => levelOf(finding) === 'warning').length,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Machine-readable output: stable keys, one object per run. */
|
|
15
|
+
export function formatJson(result) {
|
|
16
|
+
return JSON.stringify({
|
|
17
|
+
theme: result.context.slug,
|
|
18
|
+
summary: summarize(result.findings),
|
|
19
|
+
findings: result.findings.map((finding) => ({
|
|
20
|
+
rule: finding.rule,
|
|
21
|
+
level: levelOf(finding),
|
|
22
|
+
file: fileOf(finding) ?? null,
|
|
23
|
+
where: finding.where ?? null,
|
|
24
|
+
message: finding.found,
|
|
25
|
+
fix: finding.fix,
|
|
26
|
+
docs: finding.docs ?? null,
|
|
27
|
+
fixable: finding.fixable ?? false,
|
|
28
|
+
})),
|
|
29
|
+
atSubmission: AT_SUBMISSION,
|
|
30
|
+
}, null, 2);
|
|
31
|
+
}
|
|
32
|
+
/** GitHub Actions workflow commands — annotations on the pull request's diff. */
|
|
33
|
+
export function formatGithubActions(result) {
|
|
34
|
+
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
|
|
35
|
+
const data = (text) => text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
36
|
+
const property = (text) => data(text).replace(/:/g, '%3A').replace(/,/g, '%2C');
|
|
37
|
+
return result.findings.map((finding) => {
|
|
38
|
+
const file = fileOf(finding);
|
|
39
|
+
const props = [file ? `file=${property(file)}` : null, `title=${property(finding.rule)}`].filter(Boolean).join(',');
|
|
40
|
+
return `::${levelOf(finding)} ${props}::${data(`${finding.found} — ${finding.fix}${finding.docs ? ` (${finding.docs})` : ''}`)}`;
|
|
41
|
+
}).join('\n');
|
|
42
|
+
}
|
|
43
|
+
const paint = (on) => (code, text) => (on ? `\u001B[${code}m${text}\u001B[0m` : text);
|
|
44
|
+
/** The default, for people: findings grouped by file, each with its fix and docs. */
|
|
45
|
+
export function formatStylish(result, options) {
|
|
46
|
+
const c = paint(options.color);
|
|
47
|
+
const byFile = new Map();
|
|
48
|
+
for (const finding of result.findings) {
|
|
49
|
+
const key = fileOf(finding) ?? result.context.env.root ?? '.';
|
|
50
|
+
byFile.set(key, [...(byFile.get(key) ?? []), finding]);
|
|
51
|
+
}
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (const [file, findings] of byFile) {
|
|
54
|
+
lines.push('', c(4, file));
|
|
55
|
+
for (const finding of findings) {
|
|
56
|
+
const level = levelOf(finding);
|
|
57
|
+
const detail = finding.where && finding.where.includes(' → ') ? ` ${c(2, finding.where.split(' → ').slice(1).join(' → '))}` : '';
|
|
58
|
+
lines.push(` ${level === 'error' ? c(31, 'error ') : c(33, 'warning')} ${finding.found}${detail} ${c(2, finding.rule)}`);
|
|
59
|
+
lines.push(` ${c(2, 'Fix:')} ${finding.fix}`);
|
|
60
|
+
if (finding.docs)
|
|
61
|
+
lines.push(` ${c(2, 'Docs:')} ${finding.docs}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const { errors, warnings } = summarize(result.findings);
|
|
65
|
+
lines.push('');
|
|
66
|
+
if (errors + warnings === 0) {
|
|
67
|
+
lines.push(c(32, `✔ ${result.context.slug}: no problems.`));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const parts = [`${errors} error${errors === 1 ? '' : 's'}`, `${warnings} warning${warnings === 1 ? '' : 's'}`];
|
|
71
|
+
lines.push(c(errors > 0 ? 31 : 33, `${errors > 0 ? '✖' : '!'} ${result.context.slug}: ${parts.join(', ')}.`) + (errors > 0 ? ' Errors block submission.' : ''));
|
|
72
|
+
}
|
|
73
|
+
lines.push(c(2, `Also checked when you submit: ${AT_SUBMISSION.map((check) => check.summary).join('; ')}.`));
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @usequeek/theme-check — the rules a Queek storefront theme is checked
|
|
3
|
+
* against, as a library. The `queek-theme check` command of
|
|
4
|
+
* @usequeek/theme-cli is its command-line front end.
|
|
5
|
+
*/
|
|
6
|
+
export { checkTheme, rejects, RULES, AT_SUBMISSION, type CheckOptions, type CheckResult } from './run.js';
|
|
7
|
+
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
8
|
+
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf, type Level, type Summary } from './format.js';
|
|
9
|
+
export type { CheckEnv, Finding, Rule, Severity, ThemeContext, DemoStore, DeclaredDemo } from './types.js';
|
|
10
|
+
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
11
|
+
export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
|
|
12
|
+
export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @usequeek/theme-check — the rules a Queek storefront theme is checked
|
|
3
|
+
* against, as a library. The `queek-theme check` command of
|
|
4
|
+
* @usequeek/theme-cli is its command-line front end.
|
|
5
|
+
*/
|
|
6
|
+
export { checkTheme, rejects, RULES, AT_SUBMISSION } from './run.js';
|
|
7
|
+
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
8
|
+
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf } from './format.js';
|
|
9
|
+
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
10
|
+
export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
|
|
11
|
+
export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ManifestVariants } from './variant-parity.js';
|
|
2
|
+
export interface FieldParityViolation {
|
|
3
|
+
variant: string;
|
|
4
|
+
reason: string;
|
|
5
|
+
}
|
|
6
|
+
/** Extracts direct field reads from one concrete variant component. */
|
|
7
|
+
export declare function extractComponentFieldReads(source: string, componentName: string, fileName: string): string[];
|
|
8
|
+
/** Enforces equality after removing the explicit fields rendered by PageRenderer. */
|
|
9
|
+
export declare function validateFieldParity(themeSlug: string, variantKey: string, declaredFields: string[], componentReads: string[]): void;
|
|
10
|
+
export declare function findThemeFieldParityViolations(themeSlug: string, manifestVariants: ManifestVariants, indexSource: string, indexFile: string): FieldParityViolation[];
|
|
11
|
+
export declare function assertThemeFieldParity(themeSlug: string, manifestVariants: ManifestVariants, indexSource: string, indexFile: string): void;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { parseVariantImplementations } from './variant-parity.js';
|
|
5
|
+
/** Rendered by PageRenderer/preview hydration, never merchant-authored block data. */
|
|
6
|
+
const FRAMEWORK_PROVIDED_FIELDS = new Set([
|
|
7
|
+
'bg_color', 'bg_image', 'bg_overlay', 'anchor_id',
|
|
8
|
+
'products', 'categories', 'posts',
|
|
9
|
+
]);
|
|
10
|
+
/** `fields` is merchant-authored page-block data, written per section. */
|
|
11
|
+
const PAGE_BLOCK_SCOPES = new Set(['content', 'products', 'categories', 'gallery', 'contact', 'faq', 'table', 'blog']);
|
|
12
|
+
/**
|
|
13
|
+
* Theme chrome — set once per store through `manage_theme`, not per page block.
|
|
14
|
+
* A different write path, but the same contract: a chrome variant declares
|
|
15
|
+
* exactly what its component renders, so ManageThemeTool's per-variant gate
|
|
16
|
+
* (`chromeSupports()`, queek_backend) refuses an edit that would render nothing
|
|
17
|
+
* instead of confirming a silent no-op.
|
|
18
|
+
*/
|
|
19
|
+
const CHROME_SCOPES = new Set(['header', 'footer', 'subscribe']);
|
|
20
|
+
/**
|
|
21
|
+
* Variants whose configured values never arrive as component props, so a
|
|
22
|
+
* props-read scan cannot see them. Each set is the field list read from the
|
|
23
|
+
* real source, verified against the renderer named beside it — never a guess.
|
|
24
|
+
*/
|
|
25
|
+
const NON_PROP_VARIANT_READS = {
|
|
26
|
+
// PageRenderer routes the default content block to CoreContentDefaultBlock.
|
|
27
|
+
'content.default': ['markdown'],
|
|
28
|
+
// vendor-shell.tsx mounts the core subscribe app from `config.apps.subscribe`.
|
|
29
|
+
'subscribe.inline': ['heading', 'tagline', 'cta'],
|
|
30
|
+
'subscribe.floating': ['heading', 'tagline', 'cta'],
|
|
31
|
+
'subscribe.modal': ['heading', 'tagline', 'cta', 'trigger'],
|
|
32
|
+
// Theme-bespoke subscribe panels live INSIDE the theme's own footer component
|
|
33
|
+
// and read the same `config.apps.subscribe` off context rather than props —
|
|
34
|
+
// see themes/allure/footers/atelier.tsx and themes/glow/footers/columns.tsx.
|
|
35
|
+
'subscribe.atelier-hero': ['heading', 'tagline', 'cta'],
|
|
36
|
+
'subscribe.glow-benefits': ['heading', 'tagline', 'cta'],
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Read-side prop names that resolve to a canonical snake_case manifest field.
|
|
40
|
+
* Manifest fields are snake_case everywhere (they name the stored config/block
|
|
41
|
+
* key), and `HeaderProps` (@usequeek/theme-kit/types/theme) re-exposes
|
|
42
|
+
* `config.header.show_*` as camelCase React props — so every theme header reads
|
|
43
|
+
* `showCart` for the field the merchant and the backend both call `show_cart`.
|
|
44
|
+
*
|
|
45
|
+
* The `cta*` pair is migration debt, not a boundary: allure's
|
|
46
|
+
* `gallery/tab-collage` stored `ctaLabel`/`ctaLink` before the snake_case
|
|
47
|
+
* rename, and its component still reads both so already-saved blocks keep
|
|
48
|
+
* their button. Drop those two entries once that stored data is migrated.
|
|
49
|
+
*/
|
|
50
|
+
const FIELD_READ_ALIASES = {
|
|
51
|
+
showSearch: 'show_search',
|
|
52
|
+
showCart: 'show_cart',
|
|
53
|
+
showAccount: 'show_account',
|
|
54
|
+
showOffers: 'show_offers',
|
|
55
|
+
ctaLabel: 'cta_label',
|
|
56
|
+
ctaLink: 'cta_link',
|
|
57
|
+
};
|
|
58
|
+
function componentError(fileName, componentName, detail) {
|
|
59
|
+
return new Error(`${fileName}: ${componentName} ${detail}`);
|
|
60
|
+
}
|
|
61
|
+
function propertyName(name) {
|
|
62
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))
|
|
63
|
+
return name.text;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function findComponent(sourceFile, componentName) {
|
|
67
|
+
for (const statement of sourceFile.statements) {
|
|
68
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === componentName)
|
|
69
|
+
return statement;
|
|
70
|
+
if (!ts.isVariableStatement(statement))
|
|
71
|
+
continue;
|
|
72
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
73
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== componentName)
|
|
74
|
+
continue;
|
|
75
|
+
if (declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) {
|
|
76
|
+
return declaration.initializer;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw componentError(sourceFile.fileName, componentName, 'must be declared as a function or arrow function in its imported module');
|
|
81
|
+
}
|
|
82
|
+
function fieldsFromBindingPattern(pattern, fileName, componentName) {
|
|
83
|
+
const fields = [];
|
|
84
|
+
for (const element of pattern.elements) {
|
|
85
|
+
if (element.dotDotDotToken) {
|
|
86
|
+
throw componentError(fileName, componentName, 'uses a rest props binding; field reads are not statically provable');
|
|
87
|
+
}
|
|
88
|
+
const field = element.propertyName
|
|
89
|
+
? propertyName(element.propertyName)
|
|
90
|
+
: ts.isIdentifier(element.name) ? element.name.text : null;
|
|
91
|
+
if (!field || !ts.isIdentifier(element.name)) {
|
|
92
|
+
throw componentError(fileName, componentName, 'uses an unsupported props binding; field reads are not statically provable');
|
|
93
|
+
}
|
|
94
|
+
fields.push(field);
|
|
95
|
+
}
|
|
96
|
+
return fields;
|
|
97
|
+
}
|
|
98
|
+
/** Extracts direct field reads from one concrete variant component. */
|
|
99
|
+
export function extractComponentFieldReads(source, componentName, fileName) {
|
|
100
|
+
const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
101
|
+
const component = findComponent(sourceFile, componentName);
|
|
102
|
+
const firstParam = component.parameters[0];
|
|
103
|
+
if (!firstParam)
|
|
104
|
+
return [];
|
|
105
|
+
const fields = new Set();
|
|
106
|
+
let propsIdentifier = null;
|
|
107
|
+
if (ts.isObjectBindingPattern(firstParam.name)) {
|
|
108
|
+
for (const field of fieldsFromBindingPattern(firstParam.name, fileName, componentName))
|
|
109
|
+
fields.add(field);
|
|
110
|
+
}
|
|
111
|
+
else if (ts.isIdentifier(firstParam.name)) {
|
|
112
|
+
propsIdentifier = firstParam.name.text;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
throw componentError(fileName, componentName, 'uses an unsupported props parameter; field reads are not statically provable');
|
|
116
|
+
}
|
|
117
|
+
if (!propsIdentifier || !component.body)
|
|
118
|
+
return [...fields].sort();
|
|
119
|
+
const visit = (node) => {
|
|
120
|
+
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer && ts.isIdentifier(node.initializer) && node.initializer.text === propsIdentifier) {
|
|
121
|
+
for (const field of fieldsFromBindingPattern(node.name, fileName, componentName))
|
|
122
|
+
fields.add(field);
|
|
123
|
+
}
|
|
124
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === propsIdentifier) {
|
|
125
|
+
fields.add(node.name.text);
|
|
126
|
+
}
|
|
127
|
+
if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === propsIdentifier) {
|
|
128
|
+
if (!node.argumentExpression || !ts.isStringLiteral(node.argumentExpression)) {
|
|
129
|
+
throw componentError(fileName, componentName, 'uses dynamic props access; field reads are not statically provable');
|
|
130
|
+
}
|
|
131
|
+
fields.add(node.argumentExpression.text);
|
|
132
|
+
}
|
|
133
|
+
if ((ts.isJsxSpreadAttribute(node) || ts.isSpreadElement(node))
|
|
134
|
+
&& ts.isIdentifier(node.expression)
|
|
135
|
+
&& node.expression.text === propsIdentifier) {
|
|
136
|
+
throw componentError(fileName, componentName, 'forwards props through a spread; field reads are not statically provable');
|
|
137
|
+
}
|
|
138
|
+
ts.forEachChild(node, visit);
|
|
139
|
+
};
|
|
140
|
+
ts.forEachChild(component.body, visit);
|
|
141
|
+
return [...fields].sort();
|
|
142
|
+
}
|
|
143
|
+
/** Enforces equality after removing the explicit fields rendered by PageRenderer. */
|
|
144
|
+
export function validateFieldParity(themeSlug, variantKey, declaredFields, componentReads) {
|
|
145
|
+
const declared = new Set(declaredFields.filter((field) => !FRAMEWORK_PROVIDED_FIELDS.has(field)));
|
|
146
|
+
const reads = new Set(componentReads
|
|
147
|
+
.map((field) => FIELD_READ_ALIASES[field] ?? field)
|
|
148
|
+
.filter((field) => !FRAMEWORK_PROVIDED_FIELDS.has(field)));
|
|
149
|
+
const declaredOnly = [...declared].filter((field) => !reads.has(field)).sort();
|
|
150
|
+
const readOnly = [...reads].filter((field) => !declared.has(field)).sort();
|
|
151
|
+
if (declaredOnly.length > 0 || readOnly.length > 0) {
|
|
152
|
+
const reasons = [
|
|
153
|
+
declaredOnly.length > 0 ? `declares ${declaredOnly.join(', ')} but the component never reads ${declaredOnly.length === 1 ? 'it' : 'them'}` : null,
|
|
154
|
+
readOnly.length > 0 ? `reads ${readOnly.join(', ')} but the manifest does not declare ${readOnly.length === 1 ? 'it' : 'them'}` : null,
|
|
155
|
+
].filter(Boolean).join('; ');
|
|
156
|
+
throw new Error(`Theme "${themeSlug}" ${variantKey} ${reasons}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** The nearest folder above `from` with a tsconfig.json or package.json — what `@/` means in a theme's project. */
|
|
160
|
+
function projectRootOf(from) {
|
|
161
|
+
let dir = from;
|
|
162
|
+
while (dirname(dir) !== dir) {
|
|
163
|
+
if (existsSync(resolve(dir, 'tsconfig.json')) || existsSync(resolve(dir, 'package.json')))
|
|
164
|
+
return dir;
|
|
165
|
+
dir = dirname(dir);
|
|
166
|
+
}
|
|
167
|
+
return from;
|
|
168
|
+
}
|
|
169
|
+
function resolveModule(importPath, indexFile) {
|
|
170
|
+
const projectRoot = projectRootOf(dirname(dirname(indexFile)));
|
|
171
|
+
const base = importPath.startsWith('.')
|
|
172
|
+
? resolve(dirname(indexFile), importPath)
|
|
173
|
+
: importPath.startsWith('@/')
|
|
174
|
+
? resolve(projectRoot, importPath.slice(2))
|
|
175
|
+
: null;
|
|
176
|
+
if (!base)
|
|
177
|
+
throw new Error(`${indexFile}: cannot statically resolve component import ${importPath}`);
|
|
178
|
+
for (const candidate of [base, `${base}.ts`, `${base}.tsx`, resolve(base, 'index.ts'), resolve(base, 'index.tsx')]) {
|
|
179
|
+
if (existsSync(candidate))
|
|
180
|
+
return candidate;
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`${indexFile}: cannot find component module ${importPath}`);
|
|
183
|
+
}
|
|
184
|
+
function parseComponentImports(indexSource, indexFile) {
|
|
185
|
+
const sourceFile = ts.createSourceFile(indexFile, indexSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
186
|
+
const imports = new Map();
|
|
187
|
+
for (const statement of sourceFile.statements) {
|
|
188
|
+
if (!ts.isImportDeclaration(statement) || !statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
189
|
+
continue;
|
|
190
|
+
if (!statement.moduleSpecifier.text.startsWith('.') && !statement.moduleSpecifier.text.startsWith('@/'))
|
|
191
|
+
continue;
|
|
192
|
+
const bindings = statement.importClause.namedBindings;
|
|
193
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
194
|
+
continue;
|
|
195
|
+
const moduleFile = resolveModule(statement.moduleSpecifier.text, indexFile);
|
|
196
|
+
for (const specifier of bindings.elements)
|
|
197
|
+
imports.set(specifier.name.text, moduleFile);
|
|
198
|
+
}
|
|
199
|
+
return imports;
|
|
200
|
+
}
|
|
201
|
+
export function findThemeFieldParityViolations(themeSlug, manifestVariants, indexSource, indexFile) {
|
|
202
|
+
const implementations = parseVariantImplementations(indexSource, indexFile);
|
|
203
|
+
const imports = parseComponentImports(indexSource, indexFile);
|
|
204
|
+
const violations = [];
|
|
205
|
+
for (const [scope, variants] of Object.entries(manifestVariants)) {
|
|
206
|
+
if (!PAGE_BLOCK_SCOPES.has(scope) && !CHROME_SCOPES.has(scope))
|
|
207
|
+
continue;
|
|
208
|
+
for (const variant of variants) {
|
|
209
|
+
const variantKey = `${scope}.${variant.id}`;
|
|
210
|
+
const nonPropReads = NON_PROP_VARIANT_READS[variantKey];
|
|
211
|
+
const componentName = implementations[scope]?.[variant.id];
|
|
212
|
+
try {
|
|
213
|
+
const reads = nonPropReads ?? (() => {
|
|
214
|
+
if (!componentName) {
|
|
215
|
+
throw new Error(`${indexFile}: ${variantKey} has no declared non-prop read-set or mapped component`);
|
|
216
|
+
}
|
|
217
|
+
const componentFile = imports.get(componentName);
|
|
218
|
+
if (!componentFile) {
|
|
219
|
+
throw new Error(`${indexFile}: cannot resolve mapped component ${componentName} for ${variantKey}`);
|
|
220
|
+
}
|
|
221
|
+
return extractComponentFieldReads(readFileSync(componentFile, 'utf8'), componentName, componentFile);
|
|
222
|
+
})();
|
|
223
|
+
validateFieldParity(themeSlug, variantKey, Object.keys(variant.fields ?? {}), reads);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
violations.push({
|
|
227
|
+
variant: variantKey,
|
|
228
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return violations;
|
|
234
|
+
}
|
|
235
|
+
export function assertThemeFieldParity(themeSlug, manifestVariants, indexSource, indexFile) {
|
|
236
|
+
const violations = findThemeFieldParityViolations(themeSlug, manifestVariants, indexSource, indexFile);
|
|
237
|
+
if (violations.length > 0) {
|
|
238
|
+
throw new Error(`Theme "${themeSlug}" field parity failed:\n${violations.map(({ variant, reason }) => `- ${variant}: ${reason}`).join('\n')}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `subscribe` variants rendered by vendor-shell.tsx itself (theme-agnostic,
|
|
3
|
+
* every theme gets them for free) rather than by a per-theme component in
|
|
4
|
+
* that theme's own `variantImplementations` — see subscribe-{inline,
|
|
5
|
+
* floating,modal}. A theme's OWN bespoke subscribe variant (e.g. glow's
|
|
6
|
+
* `glow-benefits`) is NOT framework-owned; it must appear in that theme's
|
|
7
|
+
* `variantImplementations.subscribe` map instead. One shared list so the
|
|
8
|
+
* registry generator and the parity test can't drift out of sync.
|
|
9
|
+
*/
|
|
10
|
+
export declare const FRAMEWORK_OWNED_SUBSCRIBE_VARIANTS: string[];
|
|
11
|
+
export type ParsedVariantImplementations = Record<string, Record<string, string>>;
|
|
12
|
+
export type ManifestVariants = Record<string, Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
fields?: Record<string, unknown>;
|
|
15
|
+
}>>;
|
|
16
|
+
export type ImplementedVariants = Record<string, string[]>;
|
|
17
|
+
export type ContentFieldContract = 'structured' | undefined;
|
|
18
|
+
export interface ContentFieldContractReport {
|
|
19
|
+
legacyFieldCount: number;
|
|
20
|
+
structuredFieldCount: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function parseVariantImplementations(source: string, fileName: string): ParsedVariantImplementations;
|
|
23
|
+
export declare function validateVariantParity(themeSlug: string, manifestVariants: ManifestVariants, implementations: ParsedVariantImplementations, frameworkOwned: Record<string, string[]>): ImplementedVariants;
|
|
24
|
+
/**
|
|
25
|
+
* Checks the content-field migration independently of runtime variant parity.
|
|
26
|
+
* Legacy strings remain accepted until a theme sets `content_fields: 'structured'`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function validateContentFieldContract(themeSlug: string, manifestVariants: ManifestVariants, contract?: ContentFieldContract): ContentFieldContractReport;
|