@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 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.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
  }
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, 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';
@@ -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[];
@@ -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
  }
@@ -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: `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.`,
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.5.1",
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"