@usequeek/theme-check 0.5.1 → 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.js +3 -2
- 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 +1 -0
- package/dist/rules/static.js +48 -2
- 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.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
|
}
|
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, moduleSpecifiers, 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, moduleSpecifiers, 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
|
@@ -87,4 +87,5 @@ export declare const fontsSelfHostedRule: Rule;
|
|
|
87
87
|
/** The starter's labelled placeholder images ("Your banner photo", "Category photo", "Product photo") under theme-assets/_bare. */
|
|
88
88
|
export declare const STARTER_PLACEHOLDER_IMAGES: readonly string[];
|
|
89
89
|
export declare const placeholderContentRule: Rule;
|
|
90
|
+
export declare const markdownHtmlRule: Rule;
|
|
90
91
|
export declare const STATIC_RULES: Rule[];
|
package/dist/rules/static.js
CHANGED
|
@@ -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
|
}
|
|
@@ -849,7 +849,7 @@ export const templateScreenshotRule = {
|
|
|
849
849
|
return [finding(context, 'theme/template-screenshot', 'reject', {
|
|
850
850
|
where,
|
|
851
851
|
found: `template "${id}" has no screenshot`,
|
|
852
|
-
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.`,
|
|
853
853
|
docs: `${context.env.docs}#templates`,
|
|
854
854
|
})];
|
|
855
855
|
}
|
|
@@ -1490,7 +1490,53 @@ export const placeholderContentRule = {
|
|
|
1490
1490
|
return findings;
|
|
1491
1491
|
},
|
|
1492
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
|
+
};
|
|
1493
1538
|
export const STATIC_RULES = [moduleContractRule, structureRule, demoStoreRule, demoStoresRule, demoArtRule, codeQualityRule, sdkBoundaryRule, selectionMetadataRule, demoCompletenessRule, subscribeScopeRule, demoBlockTypesRule, compositionVariantsRule, identityRule, productMetafieldsRule, poweredByRule, fontsSelfHostedRule,
|
|
1494
1539
|
templateDescriptionRule, templateScreenshotRule, templateChromeRule, templateStyleRule,
|
|
1495
1540
|
templateBusinessRule, templateVersionsRule, templateDesignsRule, templatePagesRule, templateCopyRule, vendorFactsRule, placeholderContentRule,
|
|
1541
|
+
markdownHtmlRule,
|
|
1496
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"
|