@keenmate/web-components-devkit 1.0.0-rc06 → 1.0.0-rc08

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.
@@ -19,8 +19,30 @@ 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
+ FIXED_COLORS_ATTRIBUTE,
38
+ FIXED_COLORS_MARKER,
39
+ getHardcodedColors,
40
+ loadCoreLoader,
41
+ loadStyledLoaderClasses,
42
+ } from '../repo-checks.js';
22
43
 
23
44
  const DEVKIT_PACKAGE_NAME = '@keenmate/web-components-devkit';
45
+ const LOCAL_STYLESHEET_FILE = 'examples-local.css';
24
46
  const LEGACY_PAGE_REFERENCES = ['examples-shared.css', 'examples-chapter-nav.js', 'examples-copy-link.js', 'examples-controls-store.js'];
25
47
  const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js'];
26
48
  const PLAYWRIGHT_CONFIG_FILES = ['playwright.config.ts', 'playwright.config.mjs', 'playwright.config.js'];
@@ -114,6 +136,11 @@ export function checkComponent(config, doctorConfig = {}) {
114
136
  if (!viteConfigFile) addError('vite', 'no vite.config.*', 'create one: export default defineComponentConfig()');
115
137
  else if (!/defineComponentConfig|createExamplesPlugin/.test(readFileSync(join(config.root, viteConfigFile), 'utf8'))) {
116
138
  addError('vite', `${viteConfigFile} doesn't use the devkit preset`, "import { defineComponentConfig } from '@keenmate/web-components-devkit/vite'");
139
+ // Before the switch: the preset derives the UMD global from kmwc — a silent rename breaks script-tag users.
140
+ const legacyGlobalName = getLegacyGlobalName(readFileSync(join(config.root, viteConfigFile), 'utf8'));
141
+ if (legacyGlobalName && config.library.shouldBuild && legacyGlobalName !== config.library.globalName) {
142
+ addError('vite', `the UMD global is "${legacyGlobalName}" today; the preset would make it "${config.library.globalName}"`, `set kmwc.library.globalName to "${legacyGlobalName}"`);
143
+ }
117
144
  } else addPass('vite', `${viteConfigFile} uses the devkit preset`);
118
145
 
119
146
  const playwrightConfigFile = getFirstExistingFile(config.root, PLAYWRIGHT_CONFIG_FILES);
@@ -160,6 +187,38 @@ export function checkComponent(config, doctorConfig = {}) {
160
187
  );
161
188
  }
162
189
 
190
+ // Page CSS that re-implements the kit (a local .kbd after the kit styled <kbd>, inline widths …).
191
+ const kitClassNames = loadKitClassNames();
192
+ const localStylesheetPath = join(config.root, LOCAL_STYLESHEET_FILE);
193
+ const kitDuplicateSources = [
194
+ ...pageFiles.map((pageFile) => ({ file: pageFile, duplicates: getKitDuplicates(readFileSync(join(config.root, pageFile), 'utf8'), kitClassNames) })),
195
+ ...(existsSync(localStylesheetPath)
196
+ ? [{ file: LOCAL_STYLESHEET_FILE, duplicates: getKitDuplicates(readFileSync(localStylesheetPath, 'utf8'), kitClassNames, { isStylesheet: true }) }]
197
+ : []),
198
+ ].filter((source) => source.duplicates.length);
199
+ for (const { file, duplicates } of kitDuplicateSources) {
200
+ const shownDuplicates = duplicates.slice(0, 3).map((duplicate) => `${duplicate.found} → ${duplicate.advice}`).join('; ');
201
+ addWarning('pages', `${file}: ${duplicates.length} rule(s) / style(s) the kit already covers — ${shownDuplicates}${duplicates.length > 3 ? ' …' : ''}`, 'use the kit (devkit README "Examples kit")');
202
+ }
203
+ if (pageFiles.length && !kitDuplicateSources.length) addPass('pages', 'no page CSS re-implements the kit');
204
+
205
+ // Page CSS colours that stay put when the theme switch flips the kit to dark.
206
+ const hardcodedColorSources = [
207
+ ...pageFiles.map((pageFile) => ({ file: pageFile, hardcodedColors: getHardcodedColors(readFileSync(join(config.root, pageFile), 'utf8')) })),
208
+ ...(existsSync(localStylesheetPath)
209
+ ? [{ file: LOCAL_STYLESHEET_FILE, hardcodedColors: getHardcodedColors(readFileSync(localStylesheetPath, 'utf8'), { isStylesheet: true }) }]
210
+ : []),
211
+ ].filter((source) => source.hardcodedColors.length);
212
+ for (const { file, hardcodedColors } of hardcodedColorSources) {
213
+ const shownColors = hardcodedColors.slice(0, 3).map(({ found }) => found).join('; ');
214
+ addWarning(
215
+ 'pages',
216
+ `${file}: ${hardcodedColors.length} hard-coded colour(s) that don't follow dark mode — ${shownColors}${hardcodedColors.length > 3 ? ' …' : ''}`,
217
+ `use the kit's --kmwc-* tokens or light-dark(); fixed colours on purpose: <style ${FIXED_COLORS_ATTRIBUTE}>, or /* ${FIXED_COLORS_MARKER} */ before one rule`
218
+ );
219
+ }
220
+ if (pageFiles.length && !hardcodedColorSources.length) addPass('pages', 'page CSS colours follow the theme');
221
+
163
222
  // ── generated files ──────────────────────────────────────────────────────
164
223
  if (config.hasPackageConfig) {
165
224
  for (const planItem of getSyncPlan(config)) {
@@ -195,12 +254,40 @@ export function checkComponent(config, doctorConfig = {}) {
195
254
  `set package.json#kmwc.ports to { "dev": ${registryPorts.dev}, "image": ${registryPorts.image} } (components.json port block ${registryEntry.port})`
196
255
  );
197
256
  } else addPass('registry', `ports match the registry (dev ${config.ports.dev}, image ${config.ports.image})`);
257
+ checkPorts(config, registryEntry.port, { addPass, addWarning, addError });
198
258
  }
199
259
  if (registryEntry.prefix && config.prefix && registryEntry.prefix !== config.prefix) {
200
260
  addError('registry', `kmwc.prefix "${config.prefix}" ≠ registry prefix "${registryEntry.prefix}"`, 'use the reserved prefix');
201
261
  }
202
262
  }
203
263
 
264
+ // ── library output names ─────────────────────────────────────────────────
265
+ if (config.hasPackageConfig && config.library.shouldBuild) {
266
+ const outputMismatches = getLibraryOutputMismatches(config.packageJson, config.library);
267
+ for (const { field, target } of outputMismatches) {
268
+ addError(
269
+ 'library',
270
+ `package.json#${field} → ${target}, but the library build emits ${getLibraryOutputPaths(config.library).join(', ')}`,
271
+ 'set kmwc.library.fileName / cssFileName to the published names (or fix the package.json target)'
272
+ );
273
+ }
274
+ if (!outputMismatches.length) addPass('library', 'package.json targets match the library build outputs');
275
+ }
276
+
277
+ // ── core's default loader class ──────────────────────────────────────────
278
+ if (config.prefix && config.kind !== LIBRARY_KIND) checkLoaderClass(config, { addPass, addWarning, addError });
279
+
280
+ // ── analytics ────────────────────────────────────────────────────────────
281
+ const plausibleSiteId = loadPlausibleSiteId(config.root);
282
+ const sharingRepoIds = loadSharedPlausibleSiteIds(config, getRegistry(), plausibleSiteId);
283
+ if (sharingRepoIds.length) {
284
+ addError(
285
+ 'analytics',
286
+ `Plausible site id ${plausibleSiteId} is also used by ${sharingRepoIds.join(', ')} — one site's traffic is counted as the other's`,
287
+ "set kmwc.analytics.plausibleSiteId to this site's own id"
288
+ );
289
+ } else if (plausibleSiteId) addPass('analytics', `Plausible site id ${plausibleSiteId} is this repo's own`);
290
+
204
291
  // ── package contents ─────────────────────────────────────────────────────
205
292
  if (doctorConfig.shouldCheckPackage !== false) checkPackageContents(config, { addPass, addWarning, addError });
206
293
 
@@ -210,6 +297,57 @@ export function checkComponent(config, doctorConfig = {}) {
210
297
  return results;
211
298
  }
212
299
 
300
+ /** Ports: script --port flags, own configs and docs outside the block, siblings inside it. */
301
+ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
302
+ const [firstPort, lastPort] = getPortBlockRange(portBlock);
303
+ const blockLabel = `${firstPort}–${lastPort}`;
304
+ const scriptPortMismatches = getScriptPortMismatches(config.packageJson.scripts, config.ports);
305
+ for (const { scriptName, port, expectedPort } of scriptPortMismatches) {
306
+ addError('ports', `script "${scriptName}" pins --port ${port}; kmwc.ports gives ${expectedPort}`, `drop the --port flag (the preset reads kmwc.ports)`);
307
+ }
308
+ const ownStrayPorts = loadOwnPortsOutsideBlock(config, portBlock).filter(({ file }) => !file.startsWith('package.json#scripts.'));
309
+ 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)');
310
+ const siblingCollisions = loadSiblingPortCollisions(config, getRegistry());
311
+ for (const { id, port, file } of siblingCollisions) {
312
+ addError('ports', `${id} uses port ${port} (${file}) — inside this repo's block ${blockLabel}`, `move ${id} to its own components.json block`);
313
+ }
314
+ const stalePortMentions = loadStalePortMentions(config.root, portBlock);
315
+ if (stalePortMentions.length) {
316
+ addWarning(
317
+ 'ports',
318
+ `docs mention ports outside ${blockLabel}: ${stalePortMentions.map(({ file, port }) => `${file} ${port}`).join(', ')}`,
319
+ `update them to ${getPortsForBlock(portBlock).dev} (dev) / ${getPortsForBlock(portBlock).image} (image)`
320
+ );
321
+ }
322
+ if (!scriptPortMismatches.length && !ownStrayPorts.length && !siblingCollisions.length && !stalePortMentions.length) {
323
+ addPass('ports', `scripts, configs, docs and sibling repos keep to block ${blockLabel}`);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * The CSS styles the loader class the INSTALLED core draws. Core 6d6b768 renamed
329
+ * `<prefix>-loader` → `<prefix>__loader` with no overlap release; renaming the CSS
330
+ * before (or after) the core bump leaves the default loader unstyled, and no rulebook
331
+ * check sees that (a VALIDATION-NOTES `## C-NC-8` even hides a leftover old class).
332
+ */
333
+ function checkLoaderClass(config, { addPass, addError }) {
334
+ const coreLoader = loadCoreLoader(config.root);
335
+ if (!coreLoader?.separator) return;
336
+ const coreClassName = `${config.prefix}${coreLoader.separator}loader`;
337
+ const styledLoaderClasses = loadStyledLoaderClasses(config.root, config.prefix);
338
+ const mismatchedClasses = styledLoaderClasses.filter(({ separator }) => separator !== coreLoader.separator);
339
+ for (const { file, className } of mismatchedClasses) {
340
+ addError(
341
+ 'loader',
342
+ `${file} styles .${className}, but core ${coreLoader.version} draws the default loader as <span class="${coreClassName}"> — it renders unstyled`,
343
+ coreLoader.separator === '__'
344
+ ? `rename .${className} → .${coreClassName} (CSS, tests, docs; keep the --${config.prefix}-loader-* tokens)`
345
+ : `keep .${coreClassName} until the component builds on a core that draws <prefix>__loader, then rename in the same change`
346
+ );
347
+ }
348
+ if (styledLoaderClasses.length && !mismatchedClasses.length) addPass('loader', `CSS styles .${coreClassName}, the class core ${coreLoader.version} draws`);
349
+ }
350
+
213
351
  /** Packed paths that break PACKAGE_LEAK_RULES → [{ path, label }]. Pure — exported for tests. */
214
352
  export function getPackageLeaks(packedPaths) {
215
353
  return packedPaths
@@ -260,6 +398,7 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
260
398
  return;
261
399
  }
262
400
  const skippedSuites = new Set(config.rulebook.skippedChecks);
401
+ const acceptedCheckIds = loadAcceptedCheckIds(config.root);
263
402
  const suites = getRulebookSuites(config.kind);
264
403
  if (suites.length < RULEBOOK_SUITES.length) {
265
404
  const componentOnlySuiteNames = RULEBOOK_SUITES.filter((suite) => !suites.includes(suite)).map((suite) => suite.name);
@@ -280,7 +419,11 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
280
419
  continue;
281
420
  }
282
421
  const failures = getSuiteFailures(suiteResult.stdout);
283
- for (const failure of failures) addError(checkName, `${failure.checkId} ${failure.reason}`, `see ${suite.name}.checks.md → ${failure.checkId}`);
422
+ for (const failure of failures) {
423
+ // An architectural decision recorded in VALIDATION-NOTES.md (the rulebook's accepted-deviation file).
424
+ if (acceptedCheckIds.has(failure.checkId)) addPass(checkName, `${failure.checkId} accepted deviation (${VALIDATION_NOTES_FILE}): ${failure.reason}`);
425
+ else addError(checkName, `${failure.checkId} ${failure.reason}`, `see ${suite.name}.checks.md → ${failure.checkId}`);
426
+ }
284
427
  if (!failures.length) addPass(checkName, suiteResult.status === 0 ? 'all auto checks pass' : `exit ${suiteResult.status}`);
285
428
  }
286
429
  }
@@ -302,6 +445,8 @@ export async function run(args) {
302
445
  if (result.level === 'pass' && !args.verbose) continue;
303
446
  console.log(`${LEVEL_ICONS[result.level]} [${result.checkName}] ${result.message}${result.fixHint ? `\n → ${result.fixHint}` : ''}`);
304
447
  }
448
+ const acceptedCount = results.filter((result) => result.level === 'pass' && result.message.includes(`accepted deviation (${VALIDATION_NOTES_FILE})`)).length;
449
+ if (acceptedCount && !args.verbose) console.log(`ℹ ${acceptedCount} rulebook failure(s) accepted in ${VALIDATION_NOTES_FILE} (--verbose lists them)`);
305
450
  console.log(`\n${errorCount ? '✗' : '✓'} ${errorCount} error(s), ${warningCount} warning(s)${args.verbose ? '' : ' (--verbose lists passing checks)'}`);
306
451
  return errorCount ? 1 : 0;
307
452
  }
package/src/cli/kmwc.js CHANGED
@@ -5,6 +5,7 @@
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)
@@ -38,6 +39,14 @@ const COMMANDS = {
38
39
  flags: { booleans: ['strict'] },
39
40
  usage: `check-vars [--strict] Validate component-variables.manifest.json against the CSS.`,
40
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
+ },
41
50
  'check-release': {
42
51
  load: () => import('./commands/check-release.js'),
43
52
  flags: { booleans: ['offline'], values: ['tag'] },
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Repo checks behind `kmwc doctor` that read a component's own files (and its
3
+ * registry siblings): accepted rulebook deviations, library output names, ports,
4
+ * analytics ids and page CSS the kit already covers. Each check is a pure
5
+ * function over text / config where it can be (unit-tested), plus a thin
6
+ * file-reading wrapper.
7
+ */
8
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { DEVKIT_ROOT } from '../config.js';
11
+
12
+ export const VALIDATION_NOTES_FILE = 'VALIDATION-NOTES.md';
13
+ const PORT_BLOCK_SIZE = 100;
14
+ const PLAUSIBLE_SNIPPET_FILE = 'plausible-snippet.html';
15
+ const DOC_FILES = ['README.md', 'CLAUDE.md'];
16
+ const DOCS_DIR = 'docs';
17
+ const SIBLING_PORT_FILES = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js', 'playwright.config.ts', 'playwright.config.mjs', 'playwright.config.js', 'Makefile', 'make.bat'];
18
+ const PORTED_SCRIPTS = { dev: 'dev', preview: 'preview', 'preview:examples': 'preview' };
19
+ const CHECK_ID_PATTERN = /\bC-[A-Z]+-\d+\b/g;
20
+
21
+ const readText = (filePath) => (existsSync(filePath) ? readFileSync(filePath, 'utf8') : null);
22
+ const getUniqueSorted = (values) => [...new Set(values)].sort((left, right) => (left > right ? 1 : left < right ? -1 : 0));
23
+
24
+ // ── accepted deviations ──────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Check ids accepted in VALIDATION-NOTES.md: every `C-XX-N` in a `##` heading
28
+ * (`## C-CST-4 / C-CST-10 — …` accepts both). Only headings count, so prose that
29
+ * merely mentions a check doesn't accept it.
30
+ */
31
+ export function getAcceptedCheckIds(notesText) {
32
+ if (!notesText) return new Set();
33
+ const headings = notesText.split(/\r?\n/).filter((line) => /^##\s/.test(line));
34
+ return new Set(headings.flatMap((heading) => heading.match(CHECK_ID_PATTERN) || []));
35
+ }
36
+
37
+ export function loadAcceptedCheckIds(root) {
38
+ return getAcceptedCheckIds(readText(join(root, VALIDATION_NOTES_FILE)));
39
+ }
40
+
41
+ // ── library output names ─────────────────────────────────────────────────────
42
+
43
+ /** The dist/ files the library build emits for a resolved `config.library`. */
44
+ export function getLibraryOutputPaths(library) {
45
+ const outputPaths = library.formats.map((format) => `dist/${library.fileName}.${format === 'es' ? 'js' : `${format}.js`}`);
46
+ outputPaths.push(`dist/${library.cssFileName}.css`);
47
+ if (library.shouldEmitTypes) outputPaths.push('dist/index.d.ts');
48
+ return outputPaths;
49
+ }
50
+
51
+ /** Every `./dist/…` target package.json points at, as [{ field, target }] (globs skipped). */
52
+ export function getPackageDistTargets(packageJson) {
53
+ const targets = [];
54
+ const addTarget = (field, target) => {
55
+ if (typeof target !== 'string') return;
56
+ const normalizedTarget = target.replace(/^\.\//, '');
57
+ if (normalizedTarget.startsWith('dist/') && !normalizedTarget.includes('*') && !normalizedTarget.endsWith('/')) targets.push({ field, target: normalizedTarget });
58
+ };
59
+ for (const field of ['main', 'module', 'types', 'typings', 'style', 'browser', 'unpkg', 'jsdelivr']) addTarget(field, packageJson[field]);
60
+ const visitExports = (value, path) => {
61
+ if (typeof value === 'string') addTarget(path, value);
62
+ else if (value && typeof value === 'object') for (const [key, child] of Object.entries(value)) visitExports(child, `${path}[${JSON.stringify(key)}]`);
63
+ };
64
+ visitExports(packageJson.exports, 'exports');
65
+ return targets;
66
+ }
67
+
68
+ /**
69
+ * package.json targets the library build won't produce — a renamed UMD/ES file
70
+ * or CSS breaks consumers silently (Vite 7 names the CSS after the package
71
+ * unless kmwc.library.cssFileName says otherwise). Pure.
72
+ */
73
+ export function getLibraryOutputMismatches(packageJson, library) {
74
+ const outputPaths = new Set(getLibraryOutputPaths(library));
75
+ // Types emitted by the repo's own step (`tsc`) aren't the preset's to know.
76
+ const isOwnTypes = (target) => !library.shouldEmitTypes && target.endsWith('.d.ts');
77
+ return getPackageDistTargets(packageJson).filter(({ target }) => !outputPaths.has(target) && !target.endsWith('.map') && !isOwnTypes(target));
78
+ }
79
+
80
+ /**
81
+ * A hand-written vite.config's `lib.name` (the UMD global) — migration check: the
82
+ * preset derives the global from kmwc.library.globalName. null when not found.
83
+ */
84
+ export function getLegacyGlobalName(viteConfigText) {
85
+ const libraryBlock = viteConfigText.match(/\blib\s*:\s*\{([\s\S]*?)\}/);
86
+ return libraryBlock?.[1].match(/\bname\s*:\s*['"]([A-Za-z_$][\w$]*)['"]/)?.[1] || null;
87
+ }
88
+
89
+ // ── ports ────────────────────────────────────────────────────────────────────
90
+
91
+ /** [first, last] port of a registry block (blocks are numbered by 100). */
92
+ export function getPortBlockRange(portBlock) {
93
+ return [portBlock, portBlock + PORT_BLOCK_SIZE - 1];
94
+ }
95
+
96
+ const checkIsInRange = (port, [firstPort, lastPort]) => port >= firstPort && port <= lastPort;
97
+
98
+ /** Scripts that pin a `--port` different from kmwc.ports → [{ scriptName, port, expectedPort }]. Pure. */
99
+ export function getScriptPortMismatches(scripts = {}, ports) {
100
+ return Object.entries(PORTED_SCRIPTS).flatMap(([scriptName, portName]) => {
101
+ const port = Number(String(scripts[scriptName] || '').match(/--port[=\s]+(\d+)/)?.[1]);
102
+ return port && port !== ports[portName] ? [{ scriptName, port, expectedPort: ports[portName] }] : [];
103
+ });
104
+ }
105
+
106
+ /** Ports a text names as `localhost:N`, `--port N`, `port: N`, `port N` or `PORT ?= N`. Pure. */
107
+ export function getMentionedPorts(text) {
108
+ const patterns = [/localhost:(\d{4,5})\b/g, /--port[=\s]+(\d{4,5})\b/g, /\bport\s*[:=]\s*(\d{4,5})\b/gi, /\bport\s+(\d{4,5})\b/gi, /PORT\s*\??=\s*(\d{4,5})\b/g];
109
+ return getUniqueSorted(patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => Number(match[1]))));
110
+ }
111
+
112
+ /** Mentions in README.md / CLAUDE.md / docs/*.md of ports outside the repo's block → [{ file, port }]. */
113
+ export function loadStalePortMentions(root, portBlock) {
114
+ const blockRange = getPortBlockRange(portBlock);
115
+ const docsDir = join(root, DOCS_DIR);
116
+ const docFiles = [...DOC_FILES, ...(existsSync(docsDir) ? readdirSync(docsDir).filter((name) => name.endsWith('.md')).map((name) => `${DOCS_DIR}/${name}`) : [])];
117
+ return docFiles.flatMap((file) => {
118
+ const text = readText(join(root, file));
119
+ return text ? getMentionedPorts(text).filter((port) => !checkIsInRange(port, blockRange)).map((port) => ({ file, port })) : [];
120
+ });
121
+ }
122
+
123
+ /** Ports a repo listens on: kmwc.ports (+ preview), script --port flags, ports in its vite / playwright configs and Makefile. */
124
+ export function loadRepoPorts(repoRoot) {
125
+ const repoPorts = [];
126
+ const packageJson = JSON.parse(readText(join(repoRoot, 'package.json')) || '{}');
127
+ const kmwcPorts = packageJson.kmwc?.ports || {};
128
+ if (kmwcPorts.dev) repoPorts.push({ port: kmwcPorts.dev, file: 'package.json#kmwc.ports.dev' }, { port: kmwcPorts.dev + 1, file: 'package.json#kmwc.ports (preview)' });
129
+ if (kmwcPorts.image) repoPorts.push({ port: kmwcPorts.image, file: 'package.json#kmwc.ports.image' });
130
+ for (const [scriptName, command] of Object.entries(packageJson.scripts || {})) {
131
+ for (const port of getMentionedPorts(command)) repoPorts.push({ port, file: `package.json#scripts.${scriptName}` });
132
+ }
133
+ for (const file of SIBLING_PORT_FILES) {
134
+ const text = readText(join(repoRoot, file));
135
+ if (text) for (const port of getMentionedPorts(text)) repoPorts.push({ port, file });
136
+ }
137
+ return repoPorts;
138
+ }
139
+
140
+ /**
141
+ * A sibling's checkout, resolved next to this repo. Registry paths are relative
142
+ * to the devkit checkout, which sits beside the components — so `../<id>` works
143
+ * from any component root. Entries inside the devkit (`.`, `playground`) and the
144
+ * repo itself are not siblings.
145
+ */
146
+ function getSiblingRoot(root, registryEntry) {
147
+ if (!registryEntry.path?.startsWith('../')) return null;
148
+ const siblingRoot = resolve(root, registryEntry.path);
149
+ return siblingRoot === resolve(root) ? null : siblingRoot;
150
+ }
151
+
152
+ /** Sibling repos listening on a port inside this repo's block → [{ id, port, file }]. */
153
+ export function loadSiblingPortCollisions(config, registry) {
154
+ const ownEntry = registry.components.find((entry) => entry.id === config.id);
155
+ if (!ownEntry?.port) return [];
156
+ const blockRange = getPortBlockRange(ownEntry.port);
157
+ return registry.components
158
+ .filter((entry) => entry.id !== config.id)
159
+ .flatMap((entry) => {
160
+ const siblingRoot = getSiblingRoot(config.root, entry);
161
+ if (!siblingRoot || !existsSync(join(siblingRoot, 'package.json'))) return [];
162
+ return loadRepoPorts(siblingRoot)
163
+ .filter(({ port }) => checkIsInRange(port, blockRange))
164
+ .map(({ port, file }) => ({ id: entry.id, port, file }));
165
+ });
166
+ }
167
+
168
+ /** This repo's own configs naming a port outside its block (a hand-written vite.config before migration). */
169
+ export function loadOwnPortsOutsideBlock(config, portBlock) {
170
+ const blockRange = getPortBlockRange(portBlock);
171
+ return loadRepoPorts(config.root).filter(({ port, file }) => !checkIsInRange(port, blockRange) && !file.startsWith('package.json#kmwc'));
172
+ }
173
+
174
+ // ── analytics ────────────────────────────────────────────────────────────────
175
+
176
+ /** A repo's Plausible site id: kmwc.analytics.plausibleSiteId, else the legacy snippet's `pa-…` script. */
177
+ export function loadPlausibleSiteId(repoRoot) {
178
+ const packageJson = JSON.parse(readText(join(repoRoot, 'package.json')) || '{}');
179
+ if (packageJson.kmwc?.analytics?.plausibleSiteId) return packageJson.kmwc.analytics.plausibleSiteId;
180
+ return readText(join(repoRoot, PLAUSIBLE_SNIPPET_FILE))?.match(/\/js\/(pa-[\w-]+)\.js/)?.[1] || null;
181
+ }
182
+
183
+ /** Siblings using the same Plausible site id (their traffic would be counted as this site's) → [id]. */
184
+ export function loadSharedPlausibleSiteIds(config, registry, siteId) {
185
+ if (!siteId) return [];
186
+ return registry.components
187
+ .filter((entry) => entry.id !== config.id)
188
+ .filter((entry) => {
189
+ const siblingRoot = getSiblingRoot(config.root, entry);
190
+ return siblingRoot && existsSync(join(siblingRoot, 'package.json')) && loadPlausibleSiteId(siblingRoot) === siteId;
191
+ })
192
+ .map((entry) => entry.id);
193
+ }
194
+
195
+ // ── page CSS the kit already covers ──────────────────────────────────────────
196
+
197
+ /** Page-local selectors with a kit replacement (key → advice). */
198
+ export const KIT_REPLACED_SELECTORS = {
199
+ '.kbd': 'use <kbd> — the kit styles it',
200
+ kbd: 'the kit styles <kbd>',
201
+ mark: 'the kit styles <mark>',
202
+ '.mt-1': 'the kit has .mt-1',
203
+ '.push-end': 'the kit has .push-end',
204
+ '.mono': 'the kit has .mono',
205
+ };
206
+
207
+ /** The kit's page vocabulary — every unprefixed class its stylesheets define. */
208
+ export function loadKitClassNames() {
209
+ const cssDir = join(DEVKIT_ROOT, 'src', 'examples', 'css');
210
+ const cssText = readdirSync(cssDir).map((name) => readFileSync(join(cssDir, name), 'utf8')).join('\n');
211
+ return new Set([...cssText.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/\.([a-z][a-z0-9-]*)/g)].map((match) => match[1]).filter((name) => !name.startsWith('kmwc')));
212
+ }
213
+
214
+ /** Top-level selectors of a stylesheet (one per comma part, comments and at-rule wrappers ignored). Pure. */
215
+ export function getRuleSelectors(cssText) {
216
+ const plainText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
217
+ return [...plainText.matchAll(/(^|[{};])\s*([^{};@]+?)\s*\{/g)].flatMap((match) => match[2].split(',').map((part) => part.trim())).filter(Boolean);
218
+ }
219
+
220
+ /** Map an inline width to the nearest kit width helper (4 / 6 / 9 / 12rem). */
221
+ function getWidthClass(value, unit) {
222
+ const rem = unit === 'px' ? value / 16 : value;
223
+ return rem <= 5 ? 'w-xs' : rem <= 7.5 ? 'w-sm' : rem <= 10.5 ? 'w-md' : 'w-lg';
224
+ }
225
+
226
+ /**
227
+ * What in one page (or examples-local.css) re-implements the kit: rules on a
228
+ * kit-owned selector and single-declaration inline styles a kit class covers.
229
+ * Returns [{ found, advice }]. Pure — `kitClassNames` from loadKitClassNames().
230
+ */
231
+ export function getKitDuplicates(pageText, kitClassNames, { isStylesheet = false } = {}) {
232
+ const duplicates = [];
233
+ const styleText = isStylesheet ? pageText : [...pageText.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]).join('\n');
234
+ for (const selector of getUniqueSorted(getRuleSelectors(styleText))) {
235
+ if (KIT_REPLACED_SELECTORS[selector]) duplicates.push({ found: `${selector} { … }`, advice: KIT_REPLACED_SELECTORS[selector] });
236
+ else if (/^\.[a-z][a-z0-9-]*$/.test(selector) && kitClassNames.has(selector.slice(1))) {
237
+ duplicates.push({ found: `${selector} { … }`, advice: `restyles the kit's ${selector} for this page — scope it (e.g. .my-demo ${selector}) or drop it` });
238
+ }
239
+ }
240
+ if (isStylesheet) return duplicates;
241
+ for (const match of pageText.matchAll(/<(\w+)\b[^>]*\sstyle="\s*([^";]+?)\s*;?\s*"/g)) {
242
+ const [, tagName, declaration] = match;
243
+ const width = declaration.match(/^width\s*:\s*(\d+(?:\.\d+)?)(rem|px)$/i);
244
+ if (width && /^(input|select)$/i.test(tagName)) duplicates.push({ found: `<${tagName} style="${declaration}">`, advice: `class="${getWidthClass(Number(width[1]), width[2])}"` });
245
+ else if (/^margin-(left|inline-start)\s*:\s*auto$/i.test(declaration)) duplicates.push({ found: `style="${declaration}"`, advice: 'class="push-end"' });
246
+ else if (/^margin-top\s*:\s*1rem$/i.test(declaration)) duplicates.push({ found: `style="${declaration}"`, advice: 'class="mt-1"' });
247
+ }
248
+ return duplicates;
249
+ }
250
+
251
+ // ── hard-coded colours in page CSS ───────────────────────────────────────────
252
+
253
+ /**
254
+ * Fixed colours on purpose (a theme gallery, a mockup, a test fixture): a page `<style>`
255
+ * block with the attribute, or one rule right after a `/* kmwc-fixed-colors *\/` comment.
256
+ */
257
+ export const FIXED_COLORS_ATTRIBUTE = 'data-kmwc-fixed-colors';
258
+ export const FIXED_COLORS_MARKER = 'kmwc-fixed-colors';
259
+ const MARKED_RULE_PATTERN = new RegExp(`/\\*[^*]*${FIXED_COLORS_MARKER}[\\s\\S]*?\\*/\\s*[^{}]*\\{[^{}]*\\}`, 'g');
260
+ const COLOR_PROPERTY_PATTERN = /color|background|border|outline|shadow|fill|stroke|text-decoration/i;
261
+ const COLOR_LITERAL_PATTERN =
262
+ /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*(?!from\b)|\b(?:white|black|red|green|blue|gray|grey|silver|orange|yellow|purple|pink|navy|teal|maroon|olive|lime|aqua|fuchsia|gold|crimson)\b/i;
263
+
264
+ /** A declaration value with every `var(…)` collapsed — a literal fallback never shows while the kit token exists. */
265
+ function mapWithoutVarFallbacks(value) {
266
+ let previous;
267
+ let current = value;
268
+ do {
269
+ previous = current;
270
+ current = current.replace(/var\([^()]*\)/gi, 'var()');
271
+ } while (current !== previous);
272
+ return current;
273
+ }
274
+
275
+ /** True when a colour declaration's value is a literal that ignores dark mode. Pure. */
276
+ export function checkIsHardcodedColor(property, value) {
277
+ if (!COLOR_PROPERTY_PATTERN.test(property) || property.startsWith('--')) return false;
278
+ const plainValue = mapWithoutVarFallbacks(value);
279
+ return !/light-dark\(/i.test(plainValue) && COLOR_LITERAL_PATTERN.test(plainValue);
280
+ }
281
+
282
+ /**
283
+ * Hard-coded colours in one page's `<style>` blocks and `style="…"` attributes (or in
284
+ * examples-local.css): they stay the same in dark mode while the kit around them flips.
285
+ * `var()` fallbacks and `light-dark()` pairs follow the theme; a `<style data-kmwc-fixed-colors>`
286
+ * block and a rule marked `/* kmwc-fixed-colors *\/` are skipped. Returns [{ found }]. Pure.
287
+ */
288
+ export function getHardcodedColors(pageText, { isStylesheet = false } = {}) {
289
+ const styleText = isStylesheet
290
+ ? pageText
291
+ : [...pageText.matchAll(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi)]
292
+ .filter((match) => !match[1].includes(FIXED_COLORS_ATTRIBUTE))
293
+ .map((match) => match[2])
294
+ .join('\n');
295
+ const hardcodedColors = [];
296
+ const checkedText = styleText.replace(MARKED_RULE_PATTERN, '').replace(/\/\*[\s\S]*?\*\//g, '');
297
+ for (const [, selector, body] of checkedText.matchAll(/([^{};@]+?)\s*\{([^{}]*)\}/g)) {
298
+ for (const declaration of body.split(';')) {
299
+ const [property, ...valueParts] = declaration.split(':');
300
+ const value = valueParts.join(':').trim();
301
+ if (value && checkIsHardcodedColor(property.trim(), value)) hardcodedColors.push({ found: `${selector.trim().replace(/\s+/g, ' ')} { ${property.trim()}: ${value} }` });
302
+ }
303
+ }
304
+ if (isStylesheet) return hardcodedColors;
305
+ for (const [, inlineStyle] of pageText.matchAll(/\sstyle="([^"]*)"/g)) {
306
+ for (const declaration of inlineStyle.split(';')) {
307
+ const [property, ...valueParts] = declaration.split(':');
308
+ const value = valueParts.join(':').trim();
309
+ if (value && checkIsHardcodedColor(property.trim(), value)) hardcodedColors.push({ found: `style="${property.trim()}: ${value}"` });
310
+ }
311
+ }
312
+ return hardcodedColors;
313
+ }
314
+
315
+ // ── core's default loader class ──────────────────────────────────────────────
316
+
317
+ const CORE_PACKAGE_PATH = join('node_modules', '@keenmate', 'web-components-core');
318
+ const CORE_RENDER_LOADER_FILE = join('dist', 'dom', 'render-loader.js');
319
+ const SOURCE_DIR = 'src';
320
+ const SKIPPED_SOURCE_DIRS = new Set(['node_modules', 'dist']);
321
+
322
+ /**
323
+ * The separator in the class core's default loader gets: `'__'` (`<prefix>__loader`),
324
+ * `'-'` (`<prefix>-loader`, before core 6d6b768), or null when the build doesn't say. Pure.
325
+ */
326
+ export function getCoreLoaderSeparator(renderLoaderText) {
327
+ const match =
328
+ renderLoaderText.match(/\$\{\s*[\w$.]+\s*\}(__|-)loader(?![\w-])/) || renderLoaderText.match(/\+\s*["'`](__|-)loader["'`]/);
329
+ return match ? match[1] : null;
330
+ }
331
+
332
+ /** The loader classes a stylesheet styles: [{ className, separator }]. Pure. */
333
+ export function getStyledLoaderClasses(cssText, prefix) {
334
+ const pattern = new RegExp(`\.(${prefix}(__|-)loader)(?![\w-])`, 'g');
335
+ const plainText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
336
+ const classes = new Map([...plainText.matchAll(pattern)].map((match) => [match[1], match[2]]));
337
+ return [...classes].map(([className, separator]) => ({ className, separator }));
338
+ }
339
+
340
+ /** The installed core (walking up for hoisted installs): { version, separator } or null. */
341
+ export function loadCoreLoader(root) {
342
+ for (let directory = resolve(root); ; directory = resolve(directory, '..')) {
343
+ const packageText = readText(join(directory, CORE_PACKAGE_PATH, 'package.json'));
344
+ if (packageText) {
345
+ const renderLoaderText = readText(join(directory, CORE_PACKAGE_PATH, CORE_RENDER_LOADER_FILE));
346
+ return { version: JSON.parse(packageText).version, separator: renderLoaderText ? getCoreLoaderSeparator(renderLoaderText) : null };
347
+ }
348
+ if (resolve(directory, '..') === directory) return null;
349
+ }
350
+ }
351
+
352
+ function getSourceStylesheets(directory, relativeDirectory = SOURCE_DIR) {
353
+ if (!existsSync(directory)) return [];
354
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
355
+ const relativePath = `${relativeDirectory}/${entry.name}`;
356
+ if (entry.isDirectory()) return SKIPPED_SOURCE_DIRS.has(entry.name) ? [] : getSourceStylesheets(join(directory, entry.name), relativePath);
357
+ return entry.name.endsWith('.css') ? [relativePath] : [];
358
+ });
359
+ }
360
+
361
+ /** Loader classes styled in the component's src CSS: [{ file, className, separator }]. */
362
+ export function loadStyledLoaderClasses(root, prefix) {
363
+ return getSourceStylesheets(join(root, SOURCE_DIR)).flatMap((file) =>
364
+ getStyledLoaderClasses(readFileSync(join(root, file), 'utf8'), prefix).map((styledClass) => ({ file, ...styledClass }))
365
+ );
366
+ }
@@ -84,6 +84,7 @@ export function getStandardScripts(config) {
84
84
  'test:e2e:ui': 'playwright test --ui',
85
85
  'test:e2e:headed': 'playwright test --headed',
86
86
  'test:e2e:install': 'playwright install chromium',
87
+ 'check:pages': 'kmwc check-pages',
87
88
  });
88
89
  }
89
90
  return scripts;
@@ -161,6 +161,15 @@
161
161
  margin-bottom: 0.75rem;
162
162
  }
163
163
 
164
+ /* A heading naming an API (`EV05 · getMonthDataCallback & beforeMonthChangeCallback`)
165
+ has no break opportunity — let it break rather than widen the page on a phone. */
166
+ h1,
167
+ h2,
168
+ h3,
169
+ h4 {
170
+ overflow-wrap: anywhere;
171
+ }
172
+
164
173
  ul {
165
174
  line-height: 1.8;
166
175
  }
@@ -207,6 +216,25 @@
207
216
  }
208
217
  }
209
218
 
219
+ /* A small phone (320–400px): a component with a fixed minimum width — an inline
220
+ calendar is ~250px — still fits inside a card. */
221
+ @media (max-width: 400px) {
222
+ body {
223
+ padding: 0.75rem;
224
+ }
225
+
226
+ .card,
227
+ .example-section {
228
+ padding: 1rem;
229
+ }
230
+
231
+ /* Boxes nested in a card (a demo card holding a demo area) add up. */
232
+ .demo-card,
233
+ .demo-area {
234
+ padding: 0.75rem;
235
+ }
236
+ }
237
+
210
238
  @media (prefers-reduced-motion: reduce) {
211
239
  html {
212
240
  scroll-behavior: auto;