@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 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
@@ -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 .ts/.tsx under a theme, for the whole-tree scans. */
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:3000/${templateId}`,
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 .ts/.tsx under a theme, for the whole-tree scans. */
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 /\.tsx?$/.test(entry.name) ? [full] : [];
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
- /** Machine-readable output: stable keys, one object per run. */
15
- export function formatJson(result) {
16
- return JSON.stringify({
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
- }, null, 2);
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';
@@ -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[];
@@ -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: `Capture the homepage at 1280×800 from ${context.env.preview('default')} and save it as theme.jpg.`,
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: `Capture the template's first screen at 1280×800 (${context.env.preview(id)}) and save it here. The AI looks at this image before committing to a template — a missing one means a blind pick.`,
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.5.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
- "typescript": "~5.9.3"
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"