@usequeek/theme-check 0.5.0 → 0.6.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/README.md +20 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +140 -0
- package/dist/context.d.ts +1 -1
- package/dist/context.js +5 -4
- package/dist/format.d.ts +21 -0
- package/dist/format.js +8 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/rules/static.d.ts +8 -0
- package/dist/rules/static.js +97 -4
- package/dist/run.d.ts +2 -0
- package/dist/run.js +4 -0
- package/dist/types.d.ts +2 -0
- package/dist/utils/closest-id.d.ts +4 -0
- package/dist/utils/closest-id.js +27 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -25,6 +25,26 @@ render, or on its storage.
|
|
|
25
25
|
Theme modules (`theme.config.ts`, `manifest.ts`) load through jiti from the theme's own
|
|
26
26
|
project, so the project must have `@usequeek/theme-kit` installed.
|
|
27
27
|
|
|
28
|
+
New rule: `theme/markdown-html` (reject) — `renderMarkdown` returns React elements, not an
|
|
29
|
+
HTML string, so passing it to `dangerouslySetInnerHTML` renders "[object Object]".
|
|
30
|
+
|
|
31
|
+
## Project config
|
|
32
|
+
|
|
33
|
+
`loadProjectConfig(root)` reads `.queek-theme.yml` at the project root (`null` when there
|
|
34
|
+
is no file; `ConfigError` when invalid), and `applyProjectConfig(findings, config)` applies
|
|
35
|
+
it. The config changes warnings only. Errors are the contract Queek checks when you submit,
|
|
36
|
+
so nothing turns them off. If an error is wrong for your theme, open an issue:
|
|
37
|
+
https://github.com/usequeek/theme-tools/issues
|
|
38
|
+
|
|
39
|
+
`rules.<id>` is `off` (drop that rule's warnings), `warning` (the default) or `error` (turn
|
|
40
|
+
them into rejects); `ignore` globs, relative to the project root, drop warnings in those
|
|
41
|
+
files. Reject findings are never changed. `checkTheme()` never reads the config.
|
|
42
|
+
|
|
43
|
+
## Machine-readable report
|
|
44
|
+
|
|
45
|
+
`jsonReport(result)` builds the object `formatJson(result)` prints (same bytes):
|
|
46
|
+
`{ theme, summary, vocabulary, findings, atSubmission }`.
|
|
47
|
+
|
|
28
48
|
## License
|
|
29
49
|
|
|
30
50
|
MIT
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Finding } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The project config: what a theme's own repo may tune. It changes warnings
|
|
4
|
+
* only — errors are the contract Queek checks on submission, so nothing turns
|
|
5
|
+
* them off. `checkTheme()` never reads it (the submission check must never
|
|
6
|
+
* see a developer's config); the CLI `check`/`package` commands load and
|
|
7
|
+
* apply it.
|
|
8
|
+
*/
|
|
9
|
+
/** The file at the project root (beside package.json). */
|
|
10
|
+
export declare const CONFIG_FILE_NAME = ".queek-theme.yml";
|
|
11
|
+
/** What a rule entry may say: `warning` is the default, a no-op. */
|
|
12
|
+
export type ConfigRuleLevel = 'off' | 'warning' | 'error';
|
|
13
|
+
export interface ProjectConfig {
|
|
14
|
+
/** The project root the file was read from (globs are relative to it). */
|
|
15
|
+
root: string;
|
|
16
|
+
/** The config file itself. */
|
|
17
|
+
path: string;
|
|
18
|
+
/** Only `off` and `error` entries are kept — `warning` changes nothing. */
|
|
19
|
+
rules: Partial<Record<string, Exclude<ConfigRuleLevel, 'warning'>>>;
|
|
20
|
+
/** Warning findings in these files are not reported. */
|
|
21
|
+
ignore: string[];
|
|
22
|
+
}
|
|
23
|
+
/** Thrown when the config file exists but is invalid — the CLI exits 2. */
|
|
24
|
+
export declare class ConfigError extends Error {
|
|
25
|
+
constructor(message: string);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read the project config, or null when there is no file. Throws ConfigError
|
|
29
|
+
* (naming the file and the key) when it is invalid.
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadProjectConfig(root: string): ProjectConfig | null;
|
|
32
|
+
/**
|
|
33
|
+
* Apply the config to findings: `off` drops a rule's warn findings, `error`
|
|
34
|
+
* turns them into rejects, and `ignore` globs (relative to the project root,
|
|
35
|
+
* matched against the file part of `where`) drop warn findings in those
|
|
36
|
+
* files. A rule's reject findings are never changed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function applyProjectConfig(findings: Finding[], config: ProjectConfig, cwd?: string): Finding[];
|
|
39
|
+
/**
|
|
40
|
+
* Rule ids that can emit a warning — the only ones a config (or
|
|
41
|
+
* `check --init`) may name — in RULES order.
|
|
42
|
+
*/
|
|
43
|
+
export declare function warningRuleIds(): string[];
|
|
44
|
+
/** The policy, verbatim: also the first comment of what `check --init` writes. */
|
|
45
|
+
export declare const CONFIG_POLICY = "The config changes warnings only. Errors are the contract Queek checks when you submit, so nothing turns them off. If an error is wrong for your theme, open an issue: https://github.com/usequeek/theme-tools/issues";
|
|
46
|
+
/** What `check --init` writes: the policy, every warnable rule id commented out, and a sample ignore. */
|
|
47
|
+
export declare function renderInitConfig(): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
import picomatch from 'picomatch';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { RULES } from './run.js';
|
|
6
|
+
import { closestRuleId } from './utils/closest-id.js';
|
|
7
|
+
/**
|
|
8
|
+
* The project config: what a theme's own repo may tune. It changes warnings
|
|
9
|
+
* only — errors are the contract Queek checks on submission, so nothing turns
|
|
10
|
+
* them off. `checkTheme()` never reads it (the submission check must never
|
|
11
|
+
* see a developer's config); the CLI `check`/`package` commands load and
|
|
12
|
+
* apply it.
|
|
13
|
+
*/
|
|
14
|
+
/** The file at the project root (beside package.json). */
|
|
15
|
+
export const CONFIG_FILE_NAME = '.queek-theme.yml';
|
|
16
|
+
/** Thrown when the config file exists but is invalid — the CLI exits 2. */
|
|
17
|
+
export class ConfigError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ConfigError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const KNOWN_TOP_LEVEL = ['rules', 'ignore'];
|
|
24
|
+
/**
|
|
25
|
+
* Read the project config, or null when there is no file. Throws ConfigError
|
|
26
|
+
* (naming the file and the key) when it is invalid.
|
|
27
|
+
*/
|
|
28
|
+
export function loadProjectConfig(root) {
|
|
29
|
+
const path = join(root, CONFIG_FILE_NAME);
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return null;
|
|
32
|
+
let data;
|
|
33
|
+
try {
|
|
34
|
+
data = YAML.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new ConfigError(`Invalid ${CONFIG_FILE_NAME}: the YAML won't parse (${error.message}).`);
|
|
38
|
+
}
|
|
39
|
+
if (data === null || data === undefined)
|
|
40
|
+
return { root, path, rules: {}, ignore: [] };
|
|
41
|
+
if (typeof data !== 'object' || Array.isArray(data)) {
|
|
42
|
+
throw new ConfigError(`Invalid ${CONFIG_FILE_NAME}: the file must be a mapping with "rules" and/or "ignore".`);
|
|
43
|
+
}
|
|
44
|
+
const record = data;
|
|
45
|
+
for (const key of Object.keys(record)) {
|
|
46
|
+
if (!KNOWN_TOP_LEVEL.includes(key)) {
|
|
47
|
+
throw new ConfigError(`Invalid ${CONFIG_FILE_NAME}: unknown top-level key "${key}" (expected "rules" and/or "ignore").`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const rules = {};
|
|
51
|
+
const rawRules = record.rules ?? {};
|
|
52
|
+
if (rawRules === null) {
|
|
53
|
+
// An empty `rules:` (as `check --init` writes) means no overrides.
|
|
54
|
+
}
|
|
55
|
+
else if (typeof rawRules !== 'object' || Array.isArray(rawRules)) {
|
|
56
|
+
throw new ConfigError(`Invalid ${CONFIG_FILE_NAME}: "rules" must be a mapping of rule id to off, warning or error.`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const known = new Set(RULES.map((rule) => rule.id));
|
|
60
|
+
for (const [id, value] of Object.entries(rawRules)) {
|
|
61
|
+
if (!known.has(id)) {
|
|
62
|
+
const hint = closestRuleId(id, known);
|
|
63
|
+
throw new ConfigError(`Invalid ${CONFIG_FILE_NAME}: unknown rule "${id}" in "rules"${hint ? ` (did you mean "${hint}"?)` : ''}.`);
|
|
64
|
+
}
|
|
65
|
+
if (value !== 'off' && value !== 'warning' && value !== 'error') {
|
|
66
|
+
throw new ConfigError(`Invalid ${CONFIG_FILE_NAME}: "rules"."${id}" must be off, warning or error (got ${JSON.stringify(value) ?? String(value)}).`);
|
|
67
|
+
}
|
|
68
|
+
if (value !== 'warning')
|
|
69
|
+
rules[id] = value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const rawIgnore = record.ignore ?? [];
|
|
73
|
+
let ignore = [];
|
|
74
|
+
if (rawIgnore === null) {
|
|
75
|
+
// An empty `ignore:` (as `check --init` writes) means no patterns.
|
|
76
|
+
}
|
|
77
|
+
else if (!Array.isArray(rawIgnore) || rawIgnore.some((entry) => typeof entry !== 'string')) {
|
|
78
|
+
throw new ConfigError(`Invalid ${CONFIG_FILE_NAME}: "ignore" must be an array of strings (glob patterns relative to the project root).`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
ignore = [...rawIgnore];
|
|
82
|
+
}
|
|
83
|
+
return { root, path, rules, ignore };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Apply the config to findings: `off` drops a rule's warn findings, `error`
|
|
87
|
+
* turns them into rejects, and `ignore` globs (relative to the project root,
|
|
88
|
+
* matched against the file part of `where`) drop warn findings in those
|
|
89
|
+
* files. A rule's reject findings are never changed.
|
|
90
|
+
*/
|
|
91
|
+
export function applyProjectConfig(findings, config, cwd = process.cwd()) {
|
|
92
|
+
const matchers = config.ignore.map((pattern) => picomatch(pattern, { dot: true }));
|
|
93
|
+
// A finding's file starts with env.root, which is relative to where `check`
|
|
94
|
+
// ran — so it only matches project-root-relative globs after resolving it
|
|
95
|
+
// against that directory and making it relative to the project root again.
|
|
96
|
+
const ignored = (where) => {
|
|
97
|
+
const file = where.split(' → ')[0]?.trim().replace(/:\d+$/, '') ?? '';
|
|
98
|
+
if (!file)
|
|
99
|
+
return false;
|
|
100
|
+
const rel = relative(config.root, isAbsolute(file) ? file : resolve(cwd, file)).replace(/\\/g, '/');
|
|
101
|
+
return matchers.some((matches) => matches(rel));
|
|
102
|
+
};
|
|
103
|
+
return findings.flatMap((finding) => {
|
|
104
|
+
if (finding.severity !== 'warn')
|
|
105
|
+
return [finding];
|
|
106
|
+
const override = config.rules[finding.rule];
|
|
107
|
+
if (override === 'off')
|
|
108
|
+
return [];
|
|
109
|
+
if (override === 'error')
|
|
110
|
+
return [{ ...finding, severity: 'reject' }];
|
|
111
|
+
if (finding.where && matchers.length > 0 && ignored(finding.where))
|
|
112
|
+
return [];
|
|
113
|
+
return [finding];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Rule ids that can emit a warning — the only ones a config (or
|
|
118
|
+
* `check --init`) may name — in RULES order.
|
|
119
|
+
*/
|
|
120
|
+
export function warningRuleIds() {
|
|
121
|
+
const warnable = new Set([
|
|
122
|
+
'theme/demo-completeness',
|
|
123
|
+
'theme/template-business',
|
|
124
|
+
'theme/template-pages',
|
|
125
|
+
]);
|
|
126
|
+
return RULES.map((rule) => rule.id).filter((id) => warnable.has(id));
|
|
127
|
+
}
|
|
128
|
+
/** The policy, verbatim: also the first comment of what `check --init` writes. */
|
|
129
|
+
export const CONFIG_POLICY = "The config changes warnings only. Errors are the contract Queek checks when you submit, so nothing turns them off. If an error is wrong for your theme, open an issue: https://github.com/usequeek/theme-tools/issues";
|
|
130
|
+
/** What `check --init` writes: the policy, every warnable rule id commented out, and a sample ignore. */
|
|
131
|
+
export function renderInitConfig() {
|
|
132
|
+
return [
|
|
133
|
+
`# ${CONFIG_POLICY}`,
|
|
134
|
+
'rules:',
|
|
135
|
+
...warningRuleIds().map((id) => ` # ${id}: off`),
|
|
136
|
+
'ignore:',
|
|
137
|
+
' # - theme/vendor/**',
|
|
138
|
+
'',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
package/dist/context.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare function localEnv(dir: string, cwd?: string): CheckEnv;
|
|
|
9
9
|
* the theme's own project — the kit the developer installed, not ours.
|
|
10
10
|
*/
|
|
11
11
|
export declare function loadContext(themeDir: string, env?: Partial<CheckEnv>): Promise<ThemeContext>;
|
|
12
|
-
/** Every
|
|
12
|
+
/** Every JS/TS source file under a theme, for the whole-tree scans. */
|
|
13
13
|
export declare function themeSourceFiles(dir: string): string[];
|
|
14
14
|
/**
|
|
15
15
|
* Load a module of the kit the theme's project installed. The kit ships
|
package/dist/context.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { createJiti } from 'jiti';
|
|
4
|
-
import { demoFilesOf } from './utils/theme-demos.js';
|
|
4
|
+
import { PRIMARY_DEMO_ID, demoFilesOf } from './utils/theme-demos.js';
|
|
5
5
|
/** The contract every theme is checked against, as published with the starter. */
|
|
6
6
|
export const CONTRACT_URL = 'https://github.com/usequeek/theme-starter/blob/main/docs/THEME.md';
|
|
7
7
|
/**
|
|
@@ -18,7 +18,8 @@ export function localEnv(dir, cwd = process.cwd()) {
|
|
|
18
18
|
docs: CONTRACT_URL,
|
|
19
19
|
vocabulary: 'the business vocabulary (docs/business-vocabulary.json in the starter)',
|
|
20
20
|
scaffold: '`npm create @usequeek/theme`',
|
|
21
|
-
preview: (templateId) => `http://localhost:
|
|
21
|
+
preview: (templateId) => `http://localhost:7833/${templateId}`,
|
|
22
|
+
capture: (designId) => designId === PRIMARY_DEMO_ID ? 'npx queek-theme screenshot' : `npx queek-theme screenshot ${designId}`,
|
|
22
23
|
submission: false,
|
|
23
24
|
};
|
|
24
25
|
}
|
|
@@ -80,13 +81,13 @@ export async function loadContext(themeDir, env = {}) {
|
|
|
80
81
|
read,
|
|
81
82
|
};
|
|
82
83
|
}
|
|
83
|
-
/** Every
|
|
84
|
+
/** Every JS/TS source file under a theme, for the whole-tree scans. */
|
|
84
85
|
export function themeSourceFiles(dir) {
|
|
85
86
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
86
87
|
const full = join(dir, entry.name);
|
|
87
88
|
if (entry.isDirectory())
|
|
88
89
|
return entry.name === 'node_modules' ? [] : themeSourceFiles(full);
|
|
89
|
-
return /\.
|
|
90
|
+
return /\.[mc]?[jt]sx?$/.test(entry.name) ? [full] : [];
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
93
|
/**
|
package/dist/format.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CheckResult } from './run.js';
|
|
2
|
+
import { AT_SUBMISSION } from './run.js';
|
|
2
3
|
import type { Finding } from './types.js';
|
|
3
4
|
/** Severity as developers' tools spell it (ESLint, SARIF, GitHub). */
|
|
4
5
|
export type Level = 'error' | 'warning';
|
|
@@ -10,6 +11,26 @@ export interface Summary {
|
|
|
10
11
|
warnings: number;
|
|
11
12
|
}
|
|
12
13
|
export declare function summarize(findings: Finding[]): Summary;
|
|
14
|
+
export interface JsonFinding {
|
|
15
|
+
rule: string;
|
|
16
|
+
level: Level;
|
|
17
|
+
file: string | null;
|
|
18
|
+
where: string | null;
|
|
19
|
+
message: string;
|
|
20
|
+
fix: string;
|
|
21
|
+
docs: string | null;
|
|
22
|
+
fixable: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Machine-readable report: stable keys, one object per run. */
|
|
25
|
+
export interface JsonReport {
|
|
26
|
+
theme: string;
|
|
27
|
+
summary: Summary;
|
|
28
|
+
vocabulary: CheckResult['vocabulary'];
|
|
29
|
+
findings: JsonFinding[];
|
|
30
|
+
atSubmission: typeof AT_SUBMISSION;
|
|
31
|
+
}
|
|
32
|
+
/** The object the machine-readable output is built from. */
|
|
33
|
+
export declare function jsonReport(result: CheckResult): JsonReport;
|
|
13
34
|
/** Machine-readable output: stable keys, one object per run. */
|
|
14
35
|
export declare function formatJson(result: CheckResult): string;
|
|
15
36
|
/** GitHub Actions workflow commands — annotations on the pull request's diff. */
|
package/dist/format.js
CHANGED
|
@@ -11,9 +11,9 @@ export function summarize(findings) {
|
|
|
11
11
|
warnings: findings.filter((finding) => levelOf(finding) === 'warning').length,
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
15
|
-
export function
|
|
16
|
-
return
|
|
14
|
+
/** The object the machine-readable output is built from. */
|
|
15
|
+
export function jsonReport(result) {
|
|
16
|
+
return {
|
|
17
17
|
theme: result.context.slug,
|
|
18
18
|
summary: summarize(result.findings),
|
|
19
19
|
vocabulary: result.vocabulary,
|
|
@@ -28,7 +28,11 @@ export function formatJson(result) {
|
|
|
28
28
|
fixable: finding.fixable ?? false,
|
|
29
29
|
})),
|
|
30
30
|
atSubmission: AT_SUBMISSION,
|
|
31
|
-
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Machine-readable output: stable keys, one object per run. */
|
|
34
|
+
export function formatJson(result) {
|
|
35
|
+
return JSON.stringify(jsonReport(result), null, 2);
|
|
32
36
|
}
|
|
33
37
|
/** GitHub Actions workflow commands — annotations on the pull request's diff. */
|
|
34
38
|
export function formatGithubActions(result) {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
export { checkTheme, rejects, RULES, AT_SUBMISSION, type CheckOptions, type CheckResult, type CheckVocabulary } from './run.js';
|
|
7
7
|
export { resolveVocabulary, vocabularyCacheDir, readCachedVocabulary, bundledVocabulary, bundledVersion, parseEndpointPayload, parseVocabularyData, VOCABULARY_URL, VOCABULARY_CACHE_TTL_MS, type BusinessVocabularyData, type ResolveVocabularyOptions, type ResolvedVocabulary, type VocabularySource, } from './vocabulary.js';
|
|
8
8
|
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
9
|
-
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf, type Level, type Summary } from './format.js';
|
|
9
|
+
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf, jsonReport, type JsonFinding, type JsonReport, type Level, type Summary } from './format.js';
|
|
10
|
+
export { loadProjectConfig, applyProjectConfig, warningRuleIds, renderInitConfig, CONFIG_FILE_NAME, CONFIG_POLICY, ConfigError, type ConfigRuleLevel, type ProjectConfig, } from './config.js';
|
|
11
|
+
export { editDistance, closestRuleId } from './utils/closest-id.js';
|
|
10
12
|
export type { CheckEnv, Finding, Rule, Severity, ThemeContext, DemoStore, DeclaredDemo } from './types.js';
|
|
11
13
|
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, ROOT_SERVICE, BUNDLED_VERSION, bundledVocabularyView, vocabularyViewOf, isBusinessKey, businessRoot, type VocabularyView } from './utils/business-vocabulary.js';
|
|
12
14
|
export { TEMPLATE_COPY_PLACES, copyViolations, isTestimonialSection, storeNameForms } from './utils/template-copy.js';
|
|
13
15
|
export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
|
|
14
16
|
export { designsOf, groupTemplates, mainTemplateKey, composeLabel, type DesignDeclaration, type ThemeDesignsConfig, type ThemeDesign, type ThemeTemplate, } from './utils/theme-designs.js';
|
|
15
17
|
export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, declaredFieldsByVariant } from './utils/theme-templates.js';
|
|
16
|
-
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
18
|
+
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, markdownHtmlRule, frameworkImport, moduleSpecifiers, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
17
19
|
export { ANALYSIS_RULES, variantParityRule, fieldParityRule, designTokensRule } from './rules/analysis.js';
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
export { checkTheme, rejects, RULES, AT_SUBMISSION } from './run.js';
|
|
7
7
|
export { resolveVocabulary, vocabularyCacheDir, readCachedVocabulary, bundledVocabulary, bundledVersion, parseEndpointPayload, parseVocabularyData, VOCABULARY_URL, VOCABULARY_CACHE_TTL_MS, } from './vocabulary.js';
|
|
8
8
|
export { loadContext, localEnv, CONTRACT_URL } from './context.js';
|
|
9
|
-
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf } from './format.js';
|
|
9
|
+
export { formatJson, formatGithubActions, formatStylish, summarize, levelOf, fileOf, jsonReport } from './format.js';
|
|
10
|
+
export { loadProjectConfig, applyProjectConfig, warningRuleIds, renderInitConfig, CONFIG_FILE_NAME, CONFIG_POLICY, ConfigError, } from './config.js';
|
|
11
|
+
export { editDistance, closestRuleId } from './utils/closest-id.js';
|
|
10
12
|
export { BUSINESS_KEYS, SERVICE_SLUGS, CATALOGUE, SUBCATEGORIES, ROOT_SERVICE, BUNDLED_VERSION, bundledVocabularyView, vocabularyViewOf, isBusinessKey, businessRoot } from './utils/business-vocabulary.js';
|
|
11
13
|
export { TEMPLATE_COPY_PLACES, copyViolations, isTestimonialSection, storeNameForms } from './utils/template-copy.js';
|
|
12
14
|
export { PRIMARY_DEMO_ID, DEMO_ID_FORMAT, demoFilesOf } from './utils/theme-demos.js';
|
|
@@ -17,5 +19,5 @@ export { TEMPLATE_DESCRIPTION_MAX, screenshotFile, sectionStyle, sectionCopy, de
|
|
|
17
19
|
// Per-kind rule arrays and the individual rule constants, for rule-level unit
|
|
18
20
|
// tests that want to run one rule against a hand-built ThemeContext instead
|
|
19
21
|
// of a whole theme directory through checkTheme().
|
|
20
|
-
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, frameworkImport, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
22
|
+
export { STATIC_RULES, moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule, templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule, templateBusinessRule, templateVersionsRule, templateDesignsRule, TEMPLATE_DESIGNS_MAX, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule, markdownHtmlRule, frameworkImport, moduleSpecifiers, STARTER_PLACEHOLDER_IMAGES, } from './rules/static.js';
|
|
21
23
|
export { ANALYSIS_RULES, variantParityRule, fieldParityRule, designTokensRule } from './rules/analysis.js';
|
package/dist/rules/static.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export declare const codeQualityRule: Rule;
|
|
|
16
16
|
* import Next, a theme may not.
|
|
17
17
|
*/
|
|
18
18
|
export declare function frameworkImport(source: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Every static module specifier in a source file: `import … from`,
|
|
21
|
+
* `export … from`, side-effect `import '…'`, dynamic `import('…')` and
|
|
22
|
+
* `require('…')`. Parsed like frameworkImport — regexes, not a bundler — so
|
|
23
|
+
* a theme whose module graph will not load still gets checked.
|
|
24
|
+
*/
|
|
25
|
+
export declare function moduleSpecifiers(source: string): string[];
|
|
19
26
|
export declare const sdkBoundaryRule: Rule;
|
|
20
27
|
/**
|
|
21
28
|
* The ThemeModule shape (Layout, Header, Footer, blocks, getBlock, pages…) is
|
|
@@ -80,4 +87,5 @@ export declare const fontsSelfHostedRule: Rule;
|
|
|
80
87
|
/** The starter's labelled placeholder images ("Your banner photo", "Category photo", "Product photo") under theme-assets/_bare. */
|
|
81
88
|
export declare const STARTER_PLACEHOLDER_IMAGES: readonly string[];
|
|
82
89
|
export declare const placeholderContentRule: Rule;
|
|
90
|
+
export declare const markdownHtmlRule: Rule;
|
|
83
91
|
export declare const STATIC_RULES: Rule[];
|
package/dist/rules/static.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join, relative } from 'node:path';
|
|
1
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
2
2
|
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { foreignImageRefs } from '../utils/theme-demo-images.js';
|
|
4
4
|
import { DEMO_ID_FORMAT, PRIMARY_DEMO_ID } from '../utils/theme-demos.js';
|
|
@@ -52,7 +52,7 @@ export const structureRule = {
|
|
|
52
52
|
findings.push(finding(context, 'theme/structure', 'reject', {
|
|
53
53
|
where: `${context.env.root}`,
|
|
54
54
|
found: `no theme screenshot (${SCREENSHOTS.join(' or ')})`,
|
|
55
|
-
fix: `
|
|
55
|
+
fix: `Run \`${context.env.capture('default')}\` — it captures the homepage at 1280×800 into theme.jpg.`,
|
|
56
56
|
docs: `${context.env.docs}#theme-png`,
|
|
57
57
|
}));
|
|
58
58
|
}
|
|
@@ -308,9 +308,29 @@ export function frameworkImport(source) {
|
|
|
308
308
|
const match = source.match(/(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)['"](next(?:\/[\w./-]+)?)['"]/);
|
|
309
309
|
return match ? match[1] : null;
|
|
310
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Every static module specifier in a source file: `import … from`,
|
|
313
|
+
* `export … from`, side-effect `import '…'`, dynamic `import('…')` and
|
|
314
|
+
* `require('…')`. Parsed like frameworkImport — regexes, not a bundler — so
|
|
315
|
+
* a theme whose module graph will not load still gets checked.
|
|
316
|
+
*/
|
|
317
|
+
export function moduleSpecifiers(source) {
|
|
318
|
+
const found = [];
|
|
319
|
+
const patterns = [
|
|
320
|
+
/(?:import|export)[^'"]*?from\s*['"]([^'"]+)['"]/g,
|
|
321
|
+
/\bimport\s*['"]([^'"]+)['"]/g,
|
|
322
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
323
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
324
|
+
];
|
|
325
|
+
for (const pattern of patterns) {
|
|
326
|
+
for (const match of source.matchAll(pattern))
|
|
327
|
+
found.push(match[1]);
|
|
328
|
+
}
|
|
329
|
+
return found;
|
|
330
|
+
}
|
|
311
331
|
export const sdkBoundaryRule = {
|
|
312
332
|
id: 'theme/core-boundary',
|
|
313
|
-
summary: 'No SDK or framework imports, no direct API calls, no store mutations',
|
|
333
|
+
summary: 'No SDK or framework imports, no app-code imports or escapes, no direct API calls, no store mutations',
|
|
314
334
|
kind: 'static',
|
|
315
335
|
run(context) {
|
|
316
336
|
const findings = [];
|
|
@@ -334,6 +354,33 @@ export const sdkBoundaryRule = {
|
|
|
334
354
|
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
335
355
|
}));
|
|
336
356
|
}
|
|
357
|
+
// A theme is a self-contained folder: it imports packages and its own
|
|
358
|
+
// files, never the app around it. `@/` only resolves inside the
|
|
359
|
+
// storefront repo, and a relative import past the theme root only
|
|
360
|
+
// resolves there too — both break in a developer's `npm create`
|
|
361
|
+
// project and in `theme:pull`.
|
|
362
|
+
const root = resolve(context.dir);
|
|
363
|
+
for (const spec of moduleSpecifiers(source)) {
|
|
364
|
+
if (spec.startsWith('@/')) {
|
|
365
|
+
findings.push(finding(context, 'theme/core-boundary', 'reject', {
|
|
366
|
+
where,
|
|
367
|
+
found: `imports app code ('${spec}')`,
|
|
368
|
+
fix: "Move the code into the theme's own folder, or import it from '@usequeek/theme-kit'.",
|
|
369
|
+
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
else if (spec.startsWith('.')) {
|
|
373
|
+
const resolved = resolve(dirname(path), spec);
|
|
374
|
+
if (resolved !== root && !resolved.startsWith(root + sep)) {
|
|
375
|
+
findings.push(finding(context, 'theme/core-boundary', 'reject', {
|
|
376
|
+
where,
|
|
377
|
+
found: `relative import '${spec}' escapes the theme (resolves to ${relative(root, resolved)})`,
|
|
378
|
+
fix: "Move the code into the theme's own folder, or import it from '@usequeek/theme-kit'.",
|
|
379
|
+
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
337
384
|
if (/\b(?:fetch|axios)\s*\(\s*['"`]https?:/.test(source)) {
|
|
338
385
|
findings.push(finding(context, 'theme/core-boundary', 'reject', {
|
|
339
386
|
where,
|
|
@@ -802,7 +849,7 @@ export const templateScreenshotRule = {
|
|
|
802
849
|
return [finding(context, 'theme/template-screenshot', 'reject', {
|
|
803
850
|
where,
|
|
804
851
|
found: `template "${id}" has no screenshot`,
|
|
805
|
-
fix: `
|
|
852
|
+
fix: `Run \`${context.env.capture(id)}\` — it captures this design's first screen at 1280×800 into ${file}. The AI looks at this image before committing to a template — a missing one means a blind pick.`,
|
|
806
853
|
docs: `${context.env.docs}#templates`,
|
|
807
854
|
})];
|
|
808
855
|
}
|
|
@@ -1443,7 +1490,53 @@ export const placeholderContentRule = {
|
|
|
1443
1490
|
return findings;
|
|
1444
1491
|
},
|
|
1445
1492
|
};
|
|
1493
|
+
/* ── renderMarkdown returns elements, not an HTML string ────────────── */
|
|
1494
|
+
/** The 1-based line of an offset in source. */
|
|
1495
|
+
function sourceLineOf(source, offset) {
|
|
1496
|
+
return source.slice(0, offset).split('\n').length;
|
|
1497
|
+
}
|
|
1498
|
+
function escapeRegExp(text) {
|
|
1499
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1500
|
+
}
|
|
1501
|
+
export const markdownHtmlRule = {
|
|
1502
|
+
id: 'theme/markdown-html',
|
|
1503
|
+
summary: 'renderMarkdown elements render as children, never through dangerouslySetInnerHTML',
|
|
1504
|
+
kind: 'static',
|
|
1505
|
+
run(context) {
|
|
1506
|
+
const findings = [];
|
|
1507
|
+
for (const path of themeSourceFiles(context.dir)) {
|
|
1508
|
+
if (!path.endsWith('.ts') && !path.endsWith('.tsx'))
|
|
1509
|
+
continue;
|
|
1510
|
+
const source = readFileSync(path, 'utf8');
|
|
1511
|
+
const add = (offset) => {
|
|
1512
|
+
findings.push(finding(context, 'theme/markdown-html', 'reject', {
|
|
1513
|
+
where: `${themePath(context, path)}:${sourceLineOf(source, offset)}`,
|
|
1514
|
+
found: 'renderMarkdown(…) passed to dangerouslySetInnerHTML — it returns elements, so the page shows "[object Object]"',
|
|
1515
|
+
fix: 'Render the elements as children: `<div className="…">{renderMarkdown(text, basePath)}</div>`, or use `<Markdown>` from @usequeek/theme-kit/components/markdown.',
|
|
1516
|
+
docs: `${context.env.docs}#what-themes-must-not-do`,
|
|
1517
|
+
}));
|
|
1518
|
+
};
|
|
1519
|
+
// (a) the call straight in the prop, across any whitespace or newlines.
|
|
1520
|
+
for (const match of source.matchAll(/dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:\s*renderMarkdown\s*\(/g)) {
|
|
1521
|
+
add(match.index + match[0].indexOf('__html'));
|
|
1522
|
+
}
|
|
1523
|
+
// (b) the call assigned to a name first (`const`/`let`/`var`), then used as `__html: name`.
|
|
1524
|
+
const names = new Set();
|
|
1525
|
+
for (const match of source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?renderMarkdown\s*\(/g)) {
|
|
1526
|
+
if (match[1])
|
|
1527
|
+
names.add(match[1]);
|
|
1528
|
+
}
|
|
1529
|
+
for (const name of names) {
|
|
1530
|
+
for (const match of source.matchAll(new RegExp(`__html\\s*:\\s*${escapeRegExp(name)}\\b`, 'g'))) {
|
|
1531
|
+
add(match.index);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return findings;
|
|
1536
|
+
},
|
|
1537
|
+
};
|
|
1446
1538
|
export const STATIC_RULES = [moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule,
|
|
1447
1539
|
templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule,
|
|
1448
1540
|
templateBusinessRule, templateVersionsRule, templateDesignsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule,
|
|
1541
|
+
markdownHtmlRule,
|
|
1449
1542
|
];
|
package/dist/run.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface CheckOptions {
|
|
|
24
24
|
only?: string[];
|
|
25
25
|
/** The vocabulary the rules read from the context. Default: the bundled snapshot, so existing callers keep working. */
|
|
26
26
|
vocabulary?: CheckVocabulary;
|
|
27
|
+
/** Called with each rule's elapsed milliseconds (used for `--verbose` per-rule timing). */
|
|
28
|
+
profile?: (ruleId: string, ms: number) => void;
|
|
27
29
|
}
|
|
28
30
|
export interface CheckResult {
|
|
29
31
|
context: ThemeContext;
|
package/dist/run.js
CHANGED
|
@@ -27,6 +27,7 @@ export async function checkTheme(themeDir, options = {}) {
|
|
|
27
27
|
const rules = RULES.filter((rule) => !options.only || options.only.includes(rule.id));
|
|
28
28
|
const findings = [];
|
|
29
29
|
for (const rule of rules) {
|
|
30
|
+
const started = Date.now();
|
|
30
31
|
try {
|
|
31
32
|
findings.push(...(await rule.run(context)));
|
|
32
33
|
}
|
|
@@ -42,6 +43,9 @@ export async function checkTheme(themeDir, options = {}) {
|
|
|
42
43
|
fix: 'Usually a theme module that fails to load. Run `npx tsc --noEmit` and fix the error first.',
|
|
43
44
|
});
|
|
44
45
|
}
|
|
46
|
+
finally {
|
|
47
|
+
options.profile?.(rule.id, Date.now() - started);
|
|
48
|
+
}
|
|
45
49
|
}
|
|
46
50
|
return { context, findings, vocabulary: { source, version: view.version } };
|
|
47
51
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface CheckEnv {
|
|
|
100
100
|
scaffold: string;
|
|
101
101
|
/** Where a template previews, by template id (`default` is the primary). */
|
|
102
102
|
preview: (templateId: string) => string;
|
|
103
|
+
/** The command that captures a design's screenshot (`default` is the primary). */
|
|
104
|
+
capture: (designId: string) => string;
|
|
103
105
|
/**
|
|
104
106
|
* True when Queek is checking a submission. Two checks only mean anything
|
|
105
107
|
* then: demo art on Queek's CDN, and screenshots uploaded — both done by the
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Plain Levenshtein edit distance, for "did you mean" suggestions. */
|
|
2
|
+
export declare function editDistance(a: string, b: string): number;
|
|
3
|
+
/** The known id closest to `id` by edit distance, or null when there are no candidates. */
|
|
4
|
+
export declare function closestRuleId(id: string, known: Iterable<string>): string | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Plain Levenshtein edit distance, for "did you mean" suggestions. */
|
|
2
|
+
export function editDistance(a, b) {
|
|
3
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
4
|
+
for (let i = 1; i <= a.length; i++) {
|
|
5
|
+
let diagonal = prev[0] ?? 0;
|
|
6
|
+
prev[0] = i;
|
|
7
|
+
for (let j = 1; j <= b.length; j++) {
|
|
8
|
+
const above = prev[j] ?? 0;
|
|
9
|
+
prev[j] = Math.min(above + 1, (prev[j - 1] ?? 0) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
10
|
+
diagonal = above;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return prev[b.length] ?? 0;
|
|
14
|
+
}
|
|
15
|
+
/** The known id closest to `id` by edit distance, or null when there are no candidates. */
|
|
16
|
+
export function closestRuleId(id, known) {
|
|
17
|
+
let best = null;
|
|
18
|
+
let bestDistance = Infinity;
|
|
19
|
+
for (const candidate of known) {
|
|
20
|
+
const distance = editDistance(id, candidate);
|
|
21
|
+
if (distance < bestDistance) {
|
|
22
|
+
best = candidate;
|
|
23
|
+
bestDistance = distance;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return best;
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequeek/theme-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The rules a Queek storefront theme is checked against, as a library.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"jiti": "^2.7.0",
|
|
27
|
-
"
|
|
27
|
+
"picomatch": "^4.0.7",
|
|
28
|
+
"typescript": "~5.9.3",
|
|
29
|
+
"yaml": "^2.9.1"
|
|
28
30
|
},
|
|
29
31
|
"peerDependencies": {
|
|
30
32
|
"@usequeek/theme-kit": ">=0.1.8"
|
|
@@ -52,6 +54,9 @@
|
|
|
52
54
|
"optional": false
|
|
53
55
|
}
|
|
54
56
|
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/picomatch": "^4.0.3"
|
|
59
|
+
},
|
|
55
60
|
"scripts": {
|
|
56
61
|
"build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/utils/business-vocabulary.json','dist/utils/business-vocabulary.json')\"",
|
|
57
62
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|