@keenmate/web-components-devkit 1.0.0-rc05 → 1.0.0-rc07

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.
@@ -8,10 +8,12 @@
8
8
  *
9
9
  * Contract (prefix = kmwc.prefix or manifest.prefix, e.g. `ms`):
10
10
  * - every `--base-*` read via var() must be in baseVariables, and vice versa
11
- * - checkMode "declared" (default): every `--<prefix>-*` DECLARED (`--x:`)
12
- * must be in componentVariables, and vice versa
13
- * - checkMode "consumed": the same, but against `var(--<prefix>-*)` reads (for
14
- * components whose public knobs are read-with-fallback, never declared)
11
+ * - checkMode "declared" (default): every `--<prefix>-*` DECLARED (`--x:`) or
12
+ * READ (`var(--x)`) must be in componentVariables, and vice versa. A read
13
+ * without a declaration is an input variable — a public knob that is unset by
14
+ * design, e.g. one forwarded to core's menu so a page-wide value still applies
15
+ * - checkMode "consumed": only `var(--<prefix>-*)` reads count (for components
16
+ * whose knobs are all read-with-fallback; a declared-but-unread name is dead)
15
17
  * - names in kmwc.cssVariables.internalVariables are runtime internals — exempt
16
18
  *
17
19
  * Undocumented component variables are a warning unless --strict; everything else errors.
@@ -25,7 +27,9 @@ const STYLESHEET_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less', '.pcss
25
27
  /**
26
28
  * Directories are scanned for stylesheets; a file listed explicitly is read
27
29
  * whatever its type — e.g. a built bundle that inlines the compiled CSS, for
28
- * components whose SCSS generates variable names via interpolation.
30
+ * components whose SCSS generates variable names via interpolation, or a core
31
+ * module whose CSS the component renders (`@keenmate/web-components-core/dist/menu/styles.js`
32
+ * — a package path resolves from node_modules).
29
33
  */
30
34
  function getStylesheetTexts(sourcePath, stylesheetTexts, isExplicit = true) {
31
35
  if (!existsSync(sourcePath)) throw new Error(`cssVariables source not found: ${sourcePath}`);
@@ -37,6 +41,14 @@ function getStylesheetTexts(sourcePath, stylesheetTexts, isExplicit = true) {
37
41
  return stylesheetTexts;
38
42
  }
39
43
 
44
+ /** A source relative to the repo, else a package path (`@scope/pkg/dist/x.js`) under node_modules. */
45
+ function resolveSourcePath(root, sourcePath) {
46
+ const repoPath = resolve(root, sourcePath);
47
+ if (existsSync(repoPath) || sourcePath.startsWith('.')) return repoPath;
48
+ const packagePath = resolve(root, 'node_modules', sourcePath);
49
+ return existsSync(packagePath) ? packagePath : repoPath;
50
+ }
51
+
40
52
  const mapToRegExpSource = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
53
 
42
54
  /** Pure verdict — exported for tests. */
@@ -48,7 +60,7 @@ export function checkManifestVariables({ cssText, manifest, prefix, internalVari
48
60
  const consumedVariables = new Set([...cssText.matchAll(new RegExp(`var\\(\\s*--(${prefixSource}-[a-z0-9-]+)`, 'gi'))].map((match) => match[1]));
49
61
  const consumedBaseVariables = new Set([...cssText.matchAll(/var\(\s*--(base-[a-z0-9-]+)/gi)].map((match) => match[1]));
50
62
 
51
- const componentVariablesInCss = checkMode === 'consumed' ? consumedVariables : declaredVariables;
63
+ const componentVariablesInCss = checkMode === 'consumed' ? consumedVariables : new Set([...declaredVariables, ...consumedVariables]);
52
64
  const internalVariableSet = new Set(internalVariables);
53
65
  const manifestComponentVariables = new Set((manifest.componentVariables || []).map((entry) => entry.name));
54
66
  const manifestBaseVariables = new Set((manifest.baseVariables || []).map((entry) => entry.name));
@@ -68,6 +80,10 @@ export function checkManifestVariables({ cssText, manifest, prefix, internalVari
68
80
  export async function run(args) {
69
81
  const config = getKmwcConfig(args.root);
70
82
  const isStrict = !!args.strict;
83
+ if (!config.cssVariables.isEnabled) {
84
+ console.log('kmwc check-vars: off (kmwc.cssVariables is false) — nothing to check.');
85
+ return 0;
86
+ }
71
87
  const manifestPath = resolve(config.root, config.cssVariables.manifest);
72
88
  if (!existsSync(manifestPath)) {
73
89
  console.log(`kmwc check-vars: no ${config.cssVariables.manifest} — nothing to check.`);
@@ -77,7 +93,7 @@ export async function run(args) {
77
93
  const prefix = config.prefix || manifest.prefix;
78
94
  if (!prefix) throw new Error('no variable prefix — set kmwc.prefix or "prefix" in the manifest');
79
95
 
80
- const cssText = config.cssVariables.sources.flatMap((sourcePath) => getStylesheetTexts(resolve(config.root, sourcePath), [])).join('\n');
96
+ const cssText = config.cssVariables.sources.flatMap((sourcePath) => getStylesheetTexts(resolveSourcePath(config.root, sourcePath), [])).join('\n');
81
97
  const verdict = checkManifestVariables({
82
98
  cssText,
83
99
  manifest,
@@ -86,7 +102,7 @@ export async function run(args) {
86
102
  checkMode: config.cssVariables.checkMode,
87
103
  });
88
104
 
89
- const presenceWord = config.cssVariables.checkMode === 'consumed' ? 'consumed via var()' : 'declared';
105
+ const presenceWord = config.cssVariables.checkMode === 'consumed' ? 'consumed via var()' : 'declared or read';
90
106
  const renderSection = (title, names) => `${title} (${names.length}):\n` + names.map((name) => ` --${name}`).join('\n');
91
107
  const errors = [];
92
108
  const warnings = [];
@@ -19,27 +19,56 @@ import { spawnSync } from 'node:child_process';
19
19
  import { getKmwcConfig, getPortsForBlock, getRegistry, LEGACY_FILES, NON_PAGE_HTML_FILES, PACKAGE_LEAK_RULES, PACKAGE_FILES_EXCLUSIONS } from '../../config.js';
20
20
  import { getSyncPlan, getScriptsPlan, CLAUDE_BLOCK_START } from '../sync-plan.js';
21
21
  import { checkIsFragment } from '../../vite/page-composer.js';
22
+ import {
23
+ VALIDATION_NOTES_FILE,
24
+ loadAcceptedCheckIds,
25
+ getLibraryOutputMismatches,
26
+ getLibraryOutputPaths,
27
+ getLegacyGlobalName,
28
+ getPortBlockRange,
29
+ getScriptPortMismatches,
30
+ loadStalePortMentions,
31
+ loadSiblingPortCollisions,
32
+ loadOwnPortsOutsideBlock,
33
+ loadPlausibleSiteId,
34
+ loadSharedPlausibleSiteIds,
35
+ loadKitClassNames,
36
+ getKitDuplicates,
37
+ } from '../repo-checks.js';
22
38
 
23
39
  const DEVKIT_PACKAGE_NAME = '@keenmate/web-components-devkit';
40
+ const LOCAL_STYLESHEET_FILE = 'examples-local.css';
24
41
  const LEGACY_PAGE_REFERENCES = ['examples-shared.css', 'examples-chapter-nav.js', 'examples-copy-link.js', 'examples-controls-store.js'];
25
42
  const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js'];
26
43
  const PLAYWRIGHT_CONFIG_FILES = ['playwright.config.ts', 'playwright.config.mjs', 'playwright.config.js'];
27
44
 
28
- /** Rulebook suites in the rulebook's canonical reading order; the prefix is passed where the script takes one. */
45
+ /**
46
+ * Rulebook suites in the rulebook's canonical reading order; the prefix is
47
+ * passed where the script takes one. `isComponentOnly` suites check what a
48
+ * custom element ships (its CSS, manifest, overlay) — a `library` (registry
49
+ * kind, or kmwc.kind) skips them.
50
+ */
29
51
  export const RULEBOOK_SUITES = [
30
- { name: 'component-structure', isPrefixed: false },
31
- { name: 'naming-conventions', isPrefixed: true },
32
- { name: 'css-structure', isPrefixed: true },
33
- { name: 'theme-container', isPrefixed: true },
34
- { name: 'base-variables', isPrefixed: true },
35
- { name: 'color-scheme', isPrefixed: true },
36
- { name: 'readme-structure', isPrefixed: false },
37
- { name: 'custom-elements-manifest', isPrefixed: false },
38
- { name: 'responsive-overlay', isPrefixed: true },
39
- { name: 'publish-command', isPrefixed: false },
40
- { name: 'example-pages', isPrefixed: false },
52
+ { name: 'component-structure', isPrefixed: false, isComponentOnly: true },
53
+ { name: 'naming-conventions', isPrefixed: true, isComponentOnly: false },
54
+ { name: 'css-structure', isPrefixed: true, isComponentOnly: true },
55
+ { name: 'theme-container', isPrefixed: true, isComponentOnly: true },
56
+ { name: 'base-variables', isPrefixed: true, isComponentOnly: true },
57
+ { name: 'color-scheme', isPrefixed: true, isComponentOnly: true },
58
+ { name: 'readme-structure', isPrefixed: false, isComponentOnly: false },
59
+ { name: 'custom-elements-manifest', isPrefixed: false, isComponentOnly: true },
60
+ { name: 'responsive-overlay', isPrefixed: true, isComponentOnly: true },
61
+ { name: 'publish-command', isPrefixed: false, isComponentOnly: false },
62
+ { name: 'example-pages', isPrefixed: false, isComponentOnly: false },
41
63
  ];
42
64
 
65
+ const LIBRARY_KIND = 'library';
66
+
67
+ /** The suites that apply to a repo of `kind`. Pure — exported for tests. */
68
+ export function getRulebookSuites(kind) {
69
+ return RULEBOOK_SUITES.filter((suite) => kind !== LIBRARY_KIND || !suite.isComponentOnly);
70
+ }
71
+
43
72
  function getMajorVersion(versionRange) {
44
73
  const match = String(versionRange || '').match(/(\d+)/);
45
74
  return match ? Number(match[1]) : null;
@@ -85,7 +114,7 @@ export function checkComponent(config, doctorConfig = {}) {
85
114
  } else {
86
115
  addPass('config', 'package.json#kmwc present');
87
116
  if (!config.packageJson.kmwc.title) addWarning('config', 'kmwc.title not set (falls back to a title-cased id)', 'set kmwc.title');
88
- if (!config.prefix) addWarning('config', 'kmwc.prefix not set — rulebook suites run without a CSS prefix', 'set kmwc.prefix to the prefix reserved in base-variables.md');
117
+ if (!config.prefix && config.kind !== LIBRARY_KIND) addWarning('config', 'kmwc.prefix not set — rulebook suites run without a CSS prefix', 'set kmwc.prefix to the prefix reserved in base-variables.md');
89
118
  if (!config.packageJson.kmwc.ports?.dev) addWarning('config', 'kmwc.ports.dev not set (falls back to 5173)', 'take the port from the devkit components.json');
90
119
  }
91
120
 
@@ -102,6 +131,11 @@ export function checkComponent(config, doctorConfig = {}) {
102
131
  if (!viteConfigFile) addError('vite', 'no vite.config.*', 'create one: export default defineComponentConfig()');
103
132
  else if (!/defineComponentConfig|createExamplesPlugin/.test(readFileSync(join(config.root, viteConfigFile), 'utf8'))) {
104
133
  addError('vite', `${viteConfigFile} doesn't use the devkit preset`, "import { defineComponentConfig } from '@keenmate/web-components-devkit/vite'");
134
+ // Before the switch: the preset derives the UMD global from kmwc — a silent rename breaks script-tag users.
135
+ const legacyGlobalName = getLegacyGlobalName(readFileSync(join(config.root, viteConfigFile), 'utf8'));
136
+ if (legacyGlobalName && config.library.shouldBuild && legacyGlobalName !== config.library.globalName) {
137
+ addError('vite', `the UMD global is "${legacyGlobalName}" today; the preset would make it "${config.library.globalName}"`, `set kmwc.library.globalName to "${legacyGlobalName}"`);
138
+ }
105
139
  } else addPass('vite', `${viteConfigFile} uses the devkit preset`);
106
140
 
107
141
  const playwrightConfigFile = getFirstExistingFile(config.root, PLAYWRIGHT_CONFIG_FILES);
@@ -148,6 +182,21 @@ export function checkComponent(config, doctorConfig = {}) {
148
182
  );
149
183
  }
150
184
 
185
+ // Page CSS that re-implements the kit (a local .kbd after the kit styled <kbd>, inline widths …).
186
+ const kitClassNames = loadKitClassNames();
187
+ const localStylesheetPath = join(config.root, LOCAL_STYLESHEET_FILE);
188
+ const kitDuplicateSources = [
189
+ ...pageFiles.map((pageFile) => ({ file: pageFile, duplicates: getKitDuplicates(readFileSync(join(config.root, pageFile), 'utf8'), kitClassNames) })),
190
+ ...(existsSync(localStylesheetPath)
191
+ ? [{ file: LOCAL_STYLESHEET_FILE, duplicates: getKitDuplicates(readFileSync(localStylesheetPath, 'utf8'), kitClassNames, { isStylesheet: true }) }]
192
+ : []),
193
+ ].filter((source) => source.duplicates.length);
194
+ for (const { file, duplicates } of kitDuplicateSources) {
195
+ const shownDuplicates = duplicates.slice(0, 3).map((duplicate) => `${duplicate.found} → ${duplicate.advice}`).join('; ');
196
+ addWarning('pages', `${file}: ${duplicates.length} rule(s) / style(s) the kit already covers — ${shownDuplicates}${duplicates.length > 3 ? ' …' : ''}`, 'use the kit (devkit README "Examples kit")');
197
+ }
198
+ if (pageFiles.length && !kitDuplicateSources.length) addPass('pages', 'no page CSS re-implements the kit');
199
+
151
200
  // ── generated files ──────────────────────────────────────────────────────
152
201
  if (config.hasPackageConfig) {
153
202
  for (const planItem of getSyncPlan(config)) {
@@ -183,12 +232,37 @@ export function checkComponent(config, doctorConfig = {}) {
183
232
  `set package.json#kmwc.ports to { "dev": ${registryPorts.dev}, "image": ${registryPorts.image} } (components.json port block ${registryEntry.port})`
184
233
  );
185
234
  } else addPass('registry', `ports match the registry (dev ${config.ports.dev}, image ${config.ports.image})`);
235
+ checkPorts(config, registryEntry.port, { addPass, addWarning, addError });
186
236
  }
187
237
  if (registryEntry.prefix && config.prefix && registryEntry.prefix !== config.prefix) {
188
238
  addError('registry', `kmwc.prefix "${config.prefix}" ≠ registry prefix "${registryEntry.prefix}"`, 'use the reserved prefix');
189
239
  }
190
240
  }
191
241
 
242
+ // ── library output names ─────────────────────────────────────────────────
243
+ if (config.hasPackageConfig && config.library.shouldBuild) {
244
+ const outputMismatches = getLibraryOutputMismatches(config.packageJson, config.library);
245
+ for (const { field, target } of outputMismatches) {
246
+ addError(
247
+ 'library',
248
+ `package.json#${field} → ${target}, but the library build emits ${getLibraryOutputPaths(config.library).join(', ')}`,
249
+ 'set kmwc.library.fileName / cssFileName to the published names (or fix the package.json target)'
250
+ );
251
+ }
252
+ if (!outputMismatches.length) addPass('library', 'package.json targets match the library build outputs');
253
+ }
254
+
255
+ // ── analytics ────────────────────────────────────────────────────────────
256
+ const plausibleSiteId = loadPlausibleSiteId(config.root);
257
+ const sharingRepoIds = loadSharedPlausibleSiteIds(config, getRegistry(), plausibleSiteId);
258
+ if (sharingRepoIds.length) {
259
+ addError(
260
+ 'analytics',
261
+ `Plausible site id ${plausibleSiteId} is also used by ${sharingRepoIds.join(', ')} — one site's traffic is counted as the other's`,
262
+ "set kmwc.analytics.plausibleSiteId to this site's own id"
263
+ );
264
+ } else if (plausibleSiteId) addPass('analytics', `Plausible site id ${plausibleSiteId} is this repo's own`);
265
+
192
266
  // ── package contents ─────────────────────────────────────────────────────
193
267
  if (doctorConfig.shouldCheckPackage !== false) checkPackageContents(config, { addPass, addWarning, addError });
194
268
 
@@ -198,6 +272,33 @@ export function checkComponent(config, doctorConfig = {}) {
198
272
  return results;
199
273
  }
200
274
 
275
+ /** Ports: script --port flags, own configs and docs outside the block, siblings inside it. */
276
+ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
277
+ const [firstPort, lastPort] = getPortBlockRange(portBlock);
278
+ const blockLabel = `${firstPort}–${lastPort}`;
279
+ const scriptPortMismatches = getScriptPortMismatches(config.packageJson.scripts, config.ports);
280
+ for (const { scriptName, port, expectedPort } of scriptPortMismatches) {
281
+ addError('ports', `script "${scriptName}" pins --port ${port}; kmwc.ports gives ${expectedPort}`, `drop the --port flag (the preset reads kmwc.ports)`);
282
+ }
283
+ const ownStrayPorts = loadOwnPortsOutsideBlock(config, portBlock).filter(({ file }) => !file.startsWith('package.json#scripts.'));
284
+ for (const { port, file } of ownStrayPorts) addError('ports', `${file} uses port ${port}, outside this repo's block ${blockLabel}`, 'take ports from kmwc.ports (the devkit presets do)');
285
+ const siblingCollisions = loadSiblingPortCollisions(config, getRegistry());
286
+ for (const { id, port, file } of siblingCollisions) {
287
+ addError('ports', `${id} uses port ${port} (${file}) — inside this repo's block ${blockLabel}`, `move ${id} to its own components.json block`);
288
+ }
289
+ const stalePortMentions = loadStalePortMentions(config.root, portBlock);
290
+ if (stalePortMentions.length) {
291
+ addWarning(
292
+ 'ports',
293
+ `docs mention ports outside ${blockLabel}: ${stalePortMentions.map(({ file, port }) => `${file} ${port}`).join(', ')}`,
294
+ `update them to ${getPortsForBlock(portBlock).dev} (dev) / ${getPortsForBlock(portBlock).image} (image)`
295
+ );
296
+ }
297
+ if (!scriptPortMismatches.length && !ownStrayPorts.length && !siblingCollisions.length && !stalePortMentions.length) {
298
+ addPass('ports', `scripts, configs, docs and sibling repos keep to block ${blockLabel}`);
299
+ }
300
+ }
301
+
201
302
  /** Packed paths that break PACKAGE_LEAK_RULES → [{ path, label }]. Pure — exported for tests. */
202
303
  export function getPackageLeaks(packedPaths) {
203
304
  return packedPaths
@@ -248,7 +349,13 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
248
349
  return;
249
350
  }
250
351
  const skippedSuites = new Set(config.rulebook.skippedChecks);
251
- for (const suite of RULEBOOK_SUITES) {
352
+ const acceptedCheckIds = loadAcceptedCheckIds(config.root);
353
+ const suites = getRulebookSuites(config.kind);
354
+ if (suites.length < RULEBOOK_SUITES.length) {
355
+ const componentOnlySuiteNames = RULEBOOK_SUITES.filter((suite) => !suites.includes(suite)).map((suite) => suite.name);
356
+ addPass('rulebook', `${config.kind}: component-only suites not run (${componentOnlySuiteNames.join(', ')})`);
357
+ }
358
+ for (const suite of suites) {
252
359
  if (skippedSuites.has(suite.name)) continue;
253
360
  if (!existsSync(join(config.rulebook.path, `${suite.name}.checks.sh`))) continue;
254
361
  const suiteResult = runRulebookSuite(config, suite);
@@ -263,7 +370,11 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
263
370
  continue;
264
371
  }
265
372
  const failures = getSuiteFailures(suiteResult.stdout);
266
- for (const failure of failures) addError(checkName, `${failure.checkId} ${failure.reason}`, `see ${suite.name}.checks.md → ${failure.checkId}`);
373
+ for (const failure of failures) {
374
+ // An architectural decision recorded in VALIDATION-NOTES.md (the rulebook's accepted-deviation file).
375
+ if (acceptedCheckIds.has(failure.checkId)) addPass(checkName, `${failure.checkId} accepted deviation (${VALIDATION_NOTES_FILE}): ${failure.reason}`);
376
+ else addError(checkName, `${failure.checkId} ${failure.reason}`, `see ${suite.name}.checks.md → ${failure.checkId}`);
377
+ }
267
378
  if (!failures.length) addPass(checkName, suiteResult.status === 0 ? 'all auto checks pass' : `exit ${suiteResult.status}`);
268
379
  }
269
380
  }
@@ -280,11 +391,13 @@ export async function run(args) {
280
391
  }
281
392
 
282
393
  const LEVEL_ICONS = { pass: '✓', warning: '⚠', error: '✗' };
283
- console.log(`kmwc doctor — ${config.packageName} (${config.root})\n`);
394
+ console.log(`kmwc doctor — ${config.packageName}${config.kind === 'component' ? '' : ` [${config.kind}]`} (${config.root})\n`);
284
395
  for (const result of results) {
285
396
  if (result.level === 'pass' && !args.verbose) continue;
286
397
  console.log(`${LEVEL_ICONS[result.level]} [${result.checkName}] ${result.message}${result.fixHint ? `\n → ${result.fixHint}` : ''}`);
287
398
  }
399
+ const acceptedCount = results.filter((result) => result.level === 'pass' && result.message.includes(`accepted deviation (${VALIDATION_NOTES_FILE})`)).length;
400
+ if (acceptedCount && !args.verbose) console.log(`ℹ ${acceptedCount} rulebook failure(s) accepted in ${VALIDATION_NOTES_FILE} (--verbose lists them)`);
288
401
  console.log(`\n${errorCount ? '✗' : '✓'} ${errorCount} error(s), ${warningCount} warning(s)${args.verbose ? '' : ' (--verbose lists passing checks)'}`);
289
402
  return errorCount ? 1 : 0;
290
403
  }
package/src/cli/kmwc.js CHANGED
@@ -5,62 +5,109 @@
5
5
  * kmwc sync [--check] regenerate devkit-owned files from package.json#kmwc
6
6
  * kmwc doctor [--json] check this repo against the devkit + rulebook standard
7
7
  * kmwc check-vars [--strict] validate component-variables.manifest.json against the CSS
8
+ * kmwc check-pages [--url <u>] every example page in Chromium: errors, undefined elements, overflow
8
9
  * kmwc check-release --tag <tag> gate a publish (version ⇔ dist-tag, not yet on npm)
9
10
  * kmwc new <name> [options] scaffold a new component repo next to the devkit
10
11
  * kmwc readme [--check] regenerate the devkit README catalogue (devkit repo only)
12
+ * kmwc <command> --help that command's usage (never runs it)
11
13
  *
12
14
  * All commands act on the current directory (override with --root <dir>).
13
- * Each command module exports `run(args)` returning the process exit code.
15
+ * Each command module exports `run(args)` returning the process exit code; an
16
+ * unknown option is an error (exit 2), never ignored.
14
17
  */
15
18
  import { argv, exit } from 'node:process';
16
- import { parseArgs } from './args.js';
19
+ import { parseArgs, checkIsHelpRequested } from './args.js';
17
20
 
18
- const COMMAND_LOADERS = {
19
- sync: () => import('./commands/sync.js'),
20
- doctor: () => import('./commands/doctor.js'),
21
- 'check-vars': () => import('./commands/check-vars.js'),
22
- 'check-release': () => import('./commands/check-release.js'),
23
- new: () => import('./commands/new.js'),
24
- readme: () => import('./commands/readme.js'),
25
- };
26
-
27
- function renderUsage() {
28
- console.log(`kmwc — Keenmate web-components devkit
29
-
30
- Usage: kmwc <command> [options]
31
-
32
- sync [--check] Regenerate Dockerfile, nginx.conf, Makefile, .dockerignore,
21
+ /** Every command: its module, the flags it takes and its usage text. */
22
+ const COMMANDS = {
23
+ sync: {
24
+ load: () => import('./commands/sync.js'),
25
+ flags: { booleans: ['check'] },
26
+ usage: `sync [--check] Regenerate Dockerfile, nginx.conf, Makefile, .dockerignore,
33
27
  the CLAUDE.md devkit block, .gitignore entries and missing
34
- standard scripts. --check: report drift, write nothing.
35
- doctor [--json] Check this repo against the devkit standard and the
28
+ standard scripts. --check: report drift, write nothing.`,
29
+ },
30
+ doctor: {
31
+ load: () => import('./commands/doctor.js'),
32
+ flags: { booleans: ['json', 'verbose', 'no-rulebook', 'no-pack'] },
33
+ usage: `doctor [--json] Check this repo against the devkit standard and the
36
34
  BlissFramework rulebook (*.checks.sh), and that npm pack ships no
37
- dev-only files. --verbose, --no-rulebook, --no-pack.
38
- check-vars [--strict] Validate component-variables.manifest.json against the CSS.
39
- check-release --tag <t> Gate a publish: latest ⇔ release version, rc ⇔ -rcN,
40
- version not on npm yet. --offline skips the npm lookup.
41
- new <name> Scaffold a new component (e.g. \`kmwc new web-colorpicker --prefix cpk\`).
42
- --title "…" --tag … --prefix … --dir … --port … --register
35
+ dev-only files. --verbose, --no-rulebook, --no-pack.`,
36
+ },
37
+ 'check-vars': {
38
+ load: () => import('./commands/check-vars.js'),
39
+ flags: { booleans: ['strict'] },
40
+ usage: `check-vars [--strict] Validate component-variables.manifest.json against the CSS.`,
41
+ },
42
+ 'check-pages': {
43
+ load: () => import('./commands/check-pages.js'),
44
+ flags: { values: ['url', 'widths'] },
45
+ usage: `check-pages [--url <u>] Load every example page in Chromium at 1440 / 390 / 320px and report
46
+ page/console errors, failed requests, undefined elements and a page
47
+ wider than the viewport. Starts the dev server unless --url is given.
48
+ --widths 1440,390,320.`,
49
+ },
50
+ 'check-release': {
51
+ load: () => import('./commands/check-release.js'),
52
+ flags: { booleans: ['offline'], values: ['tag'] },
53
+ usage: `check-release --tag <t> Gate a publish: latest ⇔ release version, rc ⇔ -rcN,
54
+ version not on npm yet. --offline skips the npm lookup.`,
55
+ },
56
+ new: {
57
+ load: () => import('./commands/new.js'),
58
+ flags: { booleans: ['register', 'minimal', 'adopt', 'offline'], values: ['title', 'tagline', 'tag', 'prefix', 'dir', 'port'] },
59
+ usage: `new <name> Scaffold a new component (e.g. \`kmwc new web-colorpicker --prefix cpk\`).
60
+ --title "…" --tagline "…" --tag … --prefix … --dir … --port …
61
+ --register --offline
43
62
  --minimal: empty skeleton (no inputs/events) instead of the sample
44
- --adopt: an already registered component (migration scratch scaffold)
45
- readme [--check] Regenerate the README catalogue from components.json (devkit repo).
63
+ --adopt: an already registered component (migration scratch scaffold)`,
64
+ },
65
+ readme: {
66
+ load: () => import('./commands/readme.js'),
67
+ flags: { booleans: ['check'] },
68
+ usage: `readme [--check] Regenerate the README catalogue from components.json (devkit repo).`,
69
+ },
70
+ };
71
+
72
+ const GLOBAL_USAGE = `Global: --root <dir> Act on <dir> instead of the current directory.
73
+ --help, -h Show the usage (of one command: kmwc <command> --help).`;
46
74
 
47
- Global: --root <dir> Act on <dir> instead of the current directory.`);
75
+ function renderUsage(commandName) {
76
+ if (commandName) {
77
+ console.log(`Usage: kmwc ${COMMANDS[commandName].usage}\n\n${GLOBAL_USAGE}`);
78
+ return;
79
+ }
80
+ const commandLines = Object.values(COMMANDS).map((command) => ` ${command.usage}`).join('\n');
81
+ console.log(`kmwc — Keenmate web-components devkit\n\nUsage: kmwc <command> [options]\n\n${commandLines}\n\n${GLOBAL_USAGE}`);
48
82
  }
49
83
 
50
84
  async function main() {
51
85
  const [commandName, ...commandArgs] = argv.slice(2);
52
86
  if (!commandName || ['help', '--help', '-h'].includes(commandName)) {
53
- renderUsage();
87
+ renderUsage(COMMANDS[commandArgs[0]] ? commandArgs[0] : undefined);
54
88
  return 0;
55
89
  }
56
- const loadCommand = COMMAND_LOADERS[commandName];
57
- if (!loadCommand) {
90
+ const command = COMMANDS[commandName];
91
+ if (!command) {
58
92
  console.error(`kmwc: unknown command "${commandName}"\n`);
59
93
  renderUsage();
60
94
  return 2;
61
95
  }
62
- const command = await loadCommand();
63
- return (await command.run(parseArgs(commandArgs))) ?? 0;
96
+ // Help never runs the command (`kmwc sync --help` once overwrote a Makefile).
97
+ if (checkIsHelpRequested(commandArgs)) {
98
+ renderUsage(commandName);
99
+ return 0;
100
+ }
101
+ let args;
102
+ try {
103
+ args = parseArgs(commandArgs, command.flags);
104
+ } catch (error) {
105
+ console.error(`kmwc ${commandName}: ${error.message}\n`);
106
+ renderUsage(commandName);
107
+ return 2;
108
+ }
109
+ const commandModule = await command.load();
110
+ return (await commandModule.run(args)) ?? 0;
64
111
  }
65
112
 
66
113
  main().then(