@keenmate/web-components-devkit 1.0.0-rc10 → 1.0.0-rc11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-rc11] - 2026-11-06 [PUBLISHED]
4
+
5
+ ### Added
6
+
7
+ - **Doctor understands monorepos: `kmwc.packages`.** Doctor assumed the repo root was the package.
8
+ In web-dropzone (npm workspaces: a private root holding the example site,
9
+ `packages/web-dropzone` the component, `packages/web-dropzone-core` a headless library) that gave
10
+ 113 errors that said little: `npm pack` on the private root "leaked" 101 files npm never publishes,
11
+ six rulebook suites couldn't find a component at the root, the overlay suite scanned
12
+ `node_modules`, and the loader check silently skipped — while the component's real findings
13
+ (129 non-BEM classes, no Custom Elements Manifest setup …) never showed. `"packages": [{ "path",
14
+ "kind": "component" | "library" }]` lists what the repo publishes: doctor packs each package, runs
15
+ the rulebook suites for its kind inside it (`rulebook:<suite>@<path>`; publish-command and
16
+ example-pages stay repo-wide at the root), and checks the loader class per component package.
17
+ Doctor warns when npm workspaces aren't all listed, errors on a listed path without a
18
+ package.json, and never packs a private workspace root. Without `packages` a repo is one package
19
+ at its root, as before.
20
+ - **Every example page gets the footer.** Only the index had one ("Created by Keenmate s.r.o. |
21
+ GitHub | web-components-devkit vX"); the other pages just ended. The kit now appends the same
22
+ line to every page but the index (`mountPageFooter()`, `.kmwc-page-footer`), inside the layout's
23
+ container. The index and the page footer render it from one function (`renderFooterLineHtml()`),
24
+ so they can't drift.
25
+
3
26
  ## [1.0.0-rc10] - 2026-11-05 [PUBLISHED]
4
27
 
5
28
  ### Added
package/README.md CHANGED
@@ -19,6 +19,11 @@ them in the same shape: `@keenmate/web-components-devkit`.
19
19
  > positioning) lives in [`@keenmate/web-components-core`](../web-components-core).
20
20
  > Nothing here ships inside a component's bundle.
21
21
 
22
+ ## What's New in v1.0.0-rc11
23
+
24
+ - **Doctor — monorepos: `kmwc.packages` lists what the repo publishes** — Doctor used to assume the repo root was the package. In web-dropzone (npm workspaces: a private root that holds the example site, the component in `packages/web-dropzone`, a headless library in `packages/web-dropzone-core`) that produced 113 errors that said little. `npm pack` on the private root "leaked" 101 files npm never publishes, six rulebook suites found no component at the root, and the overlay suite scanned `node_modules`. Meanwhile the component's real findings never showed. Now `"packages": [{ "path", "kind": "component" | "library" }]` in `package.json#kmwc` lists the published packages. Doctor packs each one and runs the rulebook suites for its kind inside it (reported as `rulebook:<suite>@<path>`). The publish-command and example-pages suites stay repo-wide, and the loader check runs per component package. Doctor warns when npm workspaces aren't all listed, errors on a listed path without a `package.json`, and never packs a private workspace root. A repo without `packages` is still one package at its root.
25
+ - **Examples kit — every example page ends with the footer** — Only the index page had the "Created by Keenmate s.r.o. | GitHub | web-components-devkit vX" footer; the other example pages just stopped. The kit now appends the same line to every page except the index (`mountPageFooter()`, `.kmwc-page-footer`), at the end of the layout's container. The index footer and the page footer are rendered by one function, `renderFooterLineHtml()`, so they can't drift apart. The index keeps its own extra links (README, API docs); the page footer carries Keenmate, the repo's GitHub link and the devkit version.
26
+
22
27
  ## What's New in v1.0.0-rc10
23
28
 
24
29
  - **Doctor — runs the variable-manifest check** — `kmwc check-vars` only ran inside `npm run build`, so a red manifest stayed invisible until someone built. During the devkit pass, web-treeview's `check:vars` failed on core's `--base-accent-color` (a name outside the base-variables contract) while `kmwc doctor` reported nothing. Doctor now reports the same verdict under `vars`: `--base-*` reads missing from the manifest, manifest entries the CSS no longer reads, and dead `--<prefix>-*` entries are errors; undocumented `--<prefix>-*` variables are a warning. Both commands share one function, `loadManifestVerdict()`, so they can't disagree.
@@ -28,11 +33,6 @@ them in the same shape: `@keenmate/web-components-devkit`.
28
33
  - **Examples kit — inline code readable in light mode** — Inline `code` used the raw accent colour on its pale chip, which read at about 3.3:1 for the default accent. The text keeps the accent's hue at no more than 0.5 lightness. The e2e spec checks five accents in both themes for at least 4.5:1.
29
34
  - **Devkit tests — the critical paths** — The suite now runs a real library build of the playground and checks that the emitted files, the UMD global and every export `index.d.ts` declares are exactly what package.json and doctor expect. It also checks that `kmwc new` scaffolds a repo doctor finds clean, that `kmwc sync` never rewrites a local file or an existing script, and that `check-release` keeps an rc off `latest`. Doctor itself is tested end to end on fixture repos.
30
35
 
31
- ## What's New in v1.0.0-rc09
32
-
33
- - **check-pages — page CSS that can't reach into a shadow root** — web-treeview's custom-layout and responsive pages styled the rows their `renderNodeCallback` draws with page CSS (`.file-icon`, `.node-badge`, `.isco-row`, `.dev-badge` …, 18 rules). Those rows live in the tree's shadow root, and page CSS stops at the shadow boundary, so none of the rules ever applied. Nothing in the source shows where a render callback's output ends up, so this needs the loaded page. `kmwc check-pages` now reads the page's own rules (its `<style>` blocks and `examples-local.css`) and finds what each one styles: the last part of the selector, without pseudo-classes. It reports a rule when that element exists nowhere in the document but does exist inside a component's shadow root, and names the component. Run against treeview's pages from before its fix, it lists exactly those 18 rules. The fix is a CSS variable, `::part`, or the component's styles hook (`getCustomStylesCallback`).
34
- - **Examples kit — the grey secondary button in dark mode** — `button.secondary` / `.btn-secondary` had white text on `#9ca3af` in dark mode, at 2.54:1. That grey sits just under the 0.34 luminance switch in the formula that picks a button's text colour, so the formula chose white. It showed on 9 web-treeview pages ("Clear", "Reset", "Move up" …) and on every other component site's grey buttons. The button now has its own fill, `--kmwc-neutral-fill-color` (`#6b7280` light / `#4b5563` dark), plus a hover token, so white text stays at 4.5:1 or better in both themes, hovered too. Muted status dots and pills keep the lighter `--kmwc-neutral-color`. An e2e spec checks the contrast in both themes.
35
-
36
36
  ## Catalogue
37
37
 
38
38
  <!-- kmwc:catalogue:start — generated by `npm run readme`, do not edit inside -->
@@ -44,7 +44,7 @@ them in the same shape: `@keenmate/web-components-devkit`.
44
44
  | **web-daterangepicker** | `@keenmate/web-daterangepicker` | `drp` | 12200 | [repo](https://github.com/keenmate/web-daterangepicker) · [docs](https://web-daterangepicker.keenmate.dev) · `web-daterangepicker-showcase` | ✅ | Lightweight date picker web component with excellent keyboard navigation and range selection |
45
45
  | **web-treeview** | `@keenmate/web-treeview` | `wtv` | 12300 | [repo](https://github.com/keenmate/web-treeview) · `web-treeview-showcase` | ✅ | Lightweight treeview web component with hierarchical data display, search, expand/collapse, drag-drop, and… |
46
46
  | **web-commandpalette** | `@keenmate/web-commandpalette` | `cp` | 12400 | [repo](https://github.com/keenmate/web-commandpalette) · [examples](https://examples.web-commandpalette.keenmate.dev) | ✅ | Framework-agnostic command-palette web component — modes, multi-step commands, scoped + global search,… |
47
- | **web-dropzone** | `@keenmate/web-dropzone` | `dz` | 12500 | [repo](https://github.com/keenmate/web-dropzone) | — | Lightweight file dropzone web component with drag-drop, previews, validation, and multiple display modes. |
47
+ | **web-dropzone** | `@keenmate/web-dropzone` | `dz` | 12500 | [repo](https://github.com/keenmate/web-dropzone) | ✅ | Lightweight file dropzone web component with drag-drop, previews, validation, and multiple display modes. |
48
48
  | **web-grid** | `@keenmate/web-grid` | `wg` | 12600 | [repo](https://github.com/keenmate/web-grid) · [docs](https://web-grid.keenmate.com) · [showcase](https://web-grid.keenmate.com) | — | Framework-agnostic data grid web component with sorting, filtering, inline editing, and keyboard navigation |
49
49
  | **web-editor** | `@keenmate/web-editor` | `km` | 12700 | [examples](https://examples.web-editor.keenmate.dev) | ✅ | Keenmate Web Editor — a framework-agnostic WYSIWYG / Markdown editor built on ProseMirror. |
50
50
  | **web-switch** | `@keenmate/web-switch` | — | 12800 | [repo](https://github.com/keenmate/web-switch) | — | Framework-agnostic web component wrapper for @keenmate/svelte-switch. Works with React, Vue, Angular, or… |
@@ -106,7 +106,7 @@ in `package.json#kmwc`, and gets the rest.
106
106
  | Piece | Import / command | Replaces |
107
107
  |---|---|---|
108
108
  | Vite preset | `@keenmate/web-components-devkit/vite` → `defineComponentConfig()` | per-repo `vite.config.ts`, `vite.examples.config.ts` |
109
- | Examples kit (auto-injected) | shared styles, chapter nav, copy-link buttons, theme switch, version badge, analytics | `examples-shared.css`, `examples-chapter-nav.js`, `examples-copy-link.js`, `plausible-snippet.html`, Dockerfile `sed` |
109
+ | Examples kit (auto-injected) | shared styles, chapter nav, copy-link buttons, theme switch, version badge, page footer, analytics | `examples-shared.css`, `examples-chapter-nav.js`, `examples-copy-link.js`, `plausible-snippet.html`, Dockerfile `sed` |
110
110
  | Fragment pages | `<meta name="kmwc:title">` pages + `<kmwc-include>` partials + `<kmwc-demo>` (live markup = snippet) | the head / header / container boilerplate copied into every page, hand-copied `.code-block` snippets |
111
111
  | Examples helpers | `@keenmate/web-components-devkit/examples` → `attachControlsPersistence`, `renderExamplesIndex`, `mountExamplesKit` | `examples-controls-store.js`, 150 lines of inline index-page CSS |
112
112
  | Playwright preset | `@keenmate/web-components-devkit/playwright` → `definePlaywrightConfig()` | per-repo `playwright.config.ts` |
@@ -179,7 +179,11 @@ Every field is optional. Defaults are derived from the package.
179
179
  // BlissFramework checkout above it. env KMWC_RULEBOOK_PATH wins
180
180
  "skippedChecks": [] // e.g. ["responsive-overlay"]
181
181
  },
182
- "sync": { "skippedFiles": [] } // e.g. ["Makefile"] to opt a file out
182
+ "sync": { "skippedFiles": [] }, // e.g. ["Makefile"] to opt a file out
183
+ "packages": [ // monorepos only (npm workspaces): what doctor checks
184
+ { "path": "packages/web-dropzone", "kind": "component" },
185
+ { "path": "packages/web-dropzone-core", "kind": "library" }
186
+ ]
183
187
  }
184
188
  ```
185
189
 
@@ -193,6 +197,16 @@ manifest check (no `check:vars` script, `kmwc check-vars` skips), for variables
193
197
  the checker can't see, such as names built by string interpolation. Doctor runs
194
198
  only the rulebook suites that apply to a library (see below).
195
199
 
200
+ **A monorepo** (npm workspaces, e.g. web-dropzone: a private root with the example
201
+ site, `packages/web-dropzone` + `packages/web-dropzone-core`) lists its published
202
+ packages in `kmwc.packages`, each with its kind. The root keeps the devkit's
203
+ example site, generated files, ports and registry checks; doctor packs each
204
+ package (`npm pack`), runs the rulebook suites for its kind *in* the package
205
+ (reported as `rulebook:<suite>@<path>`), and checks the loader class in each
206
+ component package. publish-command and example-pages stay repo-wide, at the root.
207
+ Without `packages` the root is the one package — doctor warns when npm workspaces
208
+ exist but aren't listed, and doesn't pack a private workspace root.
209
+
196
210
  ### Vite preset
197
211
 
198
212
  ```ts
@@ -336,6 +350,9 @@ doctor` lists the ones still repeating the head/header boilerplate.
336
350
  shows `v1.2.3`, or `v1.2.3 (built …)` in a built site.
337
351
  - **Landing page**: `renderExamplesIndex({ pages })` renders the header, cards,
338
352
  footer and cross-page nav from the registry plus `kmwc` config.
353
+ - **Page footer**: every other example page ends with the same line as the
354
+ index footer — Keenmate, the repo's GitHub link, the devkit version — inside
355
+ the layout's container (`.kmwc-page-footer`).
339
356
  - **Late content**: the kit mounts one task after DOM-ready. Pages that render
340
357
  sections after a fetch call `mountExamplesKit()`.
341
358
  - **Analytics**: the Plausible snippet goes into **built** pages only, and only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keenmate/web-components-devkit",
3
- "version": "1.0.0-rc10",
3
+ "version": "1.0.0-rc11",
4
4
  "description": "Shared dev, example-site and build infrastructure for Keenmate web components: Vite preset, examples kit (chapter nav, copy-link, controls store, shared styles), Playwright preset and the `kmwc` CLI (sync, doctor, check-vars, new, readme).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,7 +14,7 @@
14
14
  * Errors fail (exit 1); warnings don't.
15
15
  */
16
16
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
17
- import { join } from 'node:path';
17
+ import { join, relative, resolve } from 'node:path';
18
18
  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';
@@ -66,8 +66,9 @@ export const RULEBOOK_SUITES = [
66
66
  { name: 'readme-structure', isPrefixed: false, isComponentOnly: false },
67
67
  { name: 'custom-elements-manifest', isPrefixed: false, isComponentOnly: true },
68
68
  { name: 'responsive-overlay', isPrefixed: true, isComponentOnly: true },
69
- { name: 'publish-command', isPrefixed: false, isComponentOnly: false },
70
- { name: 'example-pages', isPrefixed: false, isComponentOnly: false },
69
+ // Repo-wide: one /publish command and one example site per repo, checked at its root.
70
+ { name: 'publish-command', isPrefixed: false, isComponentOnly: false, isRepoWide: true },
71
+ { name: 'example-pages', isPrefixed: false, isComponentOnly: false, isRepoWide: true },
71
72
  ];
72
73
 
73
74
  const LIBRARY_KIND = 'library';
@@ -122,9 +123,9 @@ export function getBashPath({ platform = process.platform, env = process.env, ch
122
123
  return gitRoots.map((gitRoot) => join(gitRoot, GIT_BASH_RELATIVE_PATH)).find((bashPath) => checkExists(bashPath)) || 'bash';
123
124
  }
124
125
 
125
- function runRulebookSuite(config, suite) {
126
+ function runRulebookSuite(config, suite, checkedRoot) {
126
127
  const scriptPath = join(config.rulebook.path, `${suite.name}.checks.sh`).replace(/\\/g, '/');
127
- const scriptArgs = [scriptPath, config.root.replace(/\\/g, '/')];
128
+ const scriptArgs = [scriptPath, checkedRoot.replace(/\\/g, '/')];
128
129
  if (suite.isPrefixed && config.prefix) scriptArgs.push(config.prefix);
129
130
  return spawnSync(getBashPath(), scriptArgs, { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
130
131
  }
@@ -302,8 +303,15 @@ export function checkComponent(config, doctorConfig = {}) {
302
303
  if (!outputMismatches.length) addPass('library', 'package.json targets match the library build outputs');
303
304
  }
304
305
 
306
+ // ── monorepo: the workspace packages doctor checks ───────────────────────
307
+ checkWorkspacePackages(config, { addPass, addWarning, addError });
308
+
305
309
  // ── core's default loader class ──────────────────────────────────────────
306
- if (config.prefix && config.kind !== LIBRARY_KIND) checkLoaderClass(config, { addPass, addWarning, addError });
310
+ if (config.prefix && config.kind !== LIBRARY_KIND) {
311
+ for (const checkedPackage of config.packages.filter((packageEntry) => packageEntry.kind === 'component')) {
312
+ checkLoaderClass(config, checkedPackage, { addPass, addWarning, addError });
313
+ }
314
+ }
307
315
 
308
316
  // ── variable manifest (what `kmwc check-vars` checks; it only runs in the build) ──
309
317
  if (config.hasPackageConfig) checkVariableManifest(config, { addPass, addWarning, addError });
@@ -320,7 +328,9 @@ export function checkComponent(config, doctorConfig = {}) {
320
328
  } else if (plausibleSiteId) addPass('analytics', `Plausible site id ${plausibleSiteId} is this repo's own`);
321
329
 
322
330
  // ── package contents ─────────────────────────────────────────────────────
323
- if (doctorConfig.shouldCheckPackage !== false) checkPackageContents(config, { addPass, addWarning, addError });
331
+ if (doctorConfig.shouldCheckPackage !== false) {
332
+ for (const checkedPackage of config.packages) checkPackageContents(checkedPackage, { addPass, addWarning, addError });
333
+ }
324
334
 
325
335
  // ── rulebook suites ──────────────────────────────────────────────────────
326
336
  if (doctorConfig.shouldRunRulebook !== false) checkRulebook(config, { addPass, addWarning, addError });
@@ -361,11 +371,15 @@ function checkPorts(config, portBlock, registry, { addPass, addWarning, addError
361
371
  * before (or after) the core bump leaves the default loader unstyled, and no rulebook
362
372
  * check sees that (a VALIDATION-NOTES `## C-NC-8` even hides a leftover old class).
363
373
  */
364
- function checkLoaderClass(config, { addPass, addWarning, addError }) {
365
- const coreLoader = loadCoreLoader(config.root);
374
+ function checkLoaderClass(config, checkedPackage, { addPass, addWarning, addError }) {
375
+ const coreLoader = loadCoreLoader(checkedPackage.root);
366
376
  if (!coreLoader?.separator) return;
367
377
  const coreClassName = `${config.prefix}${coreLoader.separator}loader`;
368
- const { isCoreClassStyled, renamedClasses, collidingClasses } = getLoaderClassVerdict(coreLoader.separator, loadStyledLoaderClasses(config.root, config.prefix));
378
+ const styledLoaderClasses = loadStyledLoaderClasses(checkedPackage.root, config.prefix).map((styledClass) => ({
379
+ ...styledClass,
380
+ file: getPackageFilePath(checkedPackage, styledClass.file),
381
+ }));
382
+ const { isCoreClassStyled, renamedClasses, collidingClasses } = getLoaderClassVerdict(coreLoader.separator, styledLoaderClasses);
369
383
  for (const { file, className } of collidingClasses) {
370
384
  addWarning(
371
385
  'loader',
@@ -418,10 +432,56 @@ export function getPackageLeaks(packedPaths) {
418
432
  });
419
433
  }
420
434
 
435
+ /** `src/x.css` in a workspace package → `packages/web-x/src/x.css`; unchanged at the repo root. */
436
+ function getPackageFilePath(checkedPackage, filePath) {
437
+ return checkedPackage.path === '.' ? filePath : `${checkedPackage.path.replace(/\\/g, '/').replace(/\/$/, '')}/${filePath}`;
438
+ }
439
+
440
+ /** The workspace directories package.json declares (`packages/*` → each subdirectory with a package.json). */
441
+ function getWorkspaceRoots(config) {
442
+ const workspaces = config.packageJson.workspaces;
443
+ const workspacePatterns = Array.isArray(workspaces) ? workspaces : workspaces?.packages || [];
444
+ return workspacePatterns.flatMap((pattern) => {
445
+ if (!pattern.endsWith('/*')) return [resolve(config.root, pattern)];
446
+ const parentPath = resolve(config.root, pattern.slice(0, -2));
447
+ if (!existsSync(parentPath)) return [];
448
+ return readdirSync(parentPath)
449
+ .map((entryName) => join(parentPath, entryName))
450
+ .filter((entryPath) => existsSync(join(entryPath, 'package.json')));
451
+ });
452
+ }
453
+
454
+ /**
455
+ * A monorepo lists its packages in kmwc.packages, or doctor checks only the root (which a
456
+ * private workspace root never publishes). Every workspace package is listed; every listed one exists.
457
+ */
458
+ function checkWorkspacePackages(config, { addPass, addWarning, addError }) {
459
+ for (const checkedPackage of config.packages) {
460
+ if (!existsSync(join(checkedPackage.root, 'package.json'))) addError('packages', `kmwc.packages lists ${checkedPackage.path}, which has no package.json`, 'fix the path');
461
+ }
462
+ const workspaceRoots = getWorkspaceRoots(config);
463
+ if (!workspaceRoots.length) return;
464
+ const toRepoPath = (absolutePath) => relative(config.root, absolutePath).replace(/\\/g, '/');
465
+ if (!config.packageJson.kmwc?.packages) {
466
+ addWarning(
467
+ 'packages',
468
+ `npm workspaces (${workspaceRoots.map(toRepoPath).join(', ')}) but no kmwc.packages — doctor checks the root as the package`,
469
+ `list them: "packages": [${workspaceRoots.map((workspaceRoot) => `{ "path": "${toRepoPath(workspaceRoot)}", "kind": "component" | "library" }`).join(', ')}]`
470
+ );
471
+ return;
472
+ }
473
+ const listedRoots = new Set(config.packages.map((checkedPackage) => checkedPackage.root));
474
+ const unlistedRoots = workspaceRoots.filter((workspaceRoot) => !listedRoots.has(workspaceRoot));
475
+ for (const workspaceRoot of unlistedRoots) {
476
+ addWarning('packages', `workspace package ${toRepoPath(workspaceRoot)} is not in kmwc.packages — doctor doesn't check it`, `add { "path": "${toRepoPath(workspaceRoot)}", "kind": … }`);
477
+ }
478
+ if (!unlistedRoots.length) addPass('packages', `kmwc.packages lists every workspace package (${config.packages.map((checkedPackage) => `${checkedPackage.path} ${checkedPackage.kind}`).join(', ')})`);
479
+ }
480
+
421
481
  /** The file list `npm publish` would ship (no lifecycle scripts, nothing written). */
422
- function getPackedPaths(config) {
482
+ function getPackedPaths(packageRoot) {
423
483
  const packResult = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
424
- cwd: config.root,
484
+ cwd: packageRoot,
425
485
  encoding: 'utf8',
426
486
  shell: process.platform === 'win32',
427
487
  });
@@ -430,26 +490,37 @@ function getPackedPaths(config) {
430
490
  return packReport[0].files.map((file) => file.path);
431
491
  }
432
492
 
433
- function checkPackageContents(config, { addPass, addWarning, addError }) {
493
+ /**
494
+ * One package's `npm pack` list. A private workspace root is skipped: npm never publishes it and,
495
+ * having no `files`, it would "leak" everything (list the packages in kmwc.packages). A private
496
+ * single package (the devkit playground) is still checked — it stands in for a real one.
497
+ */
498
+ function checkPackageContents(checkedPackage, { addPass, addWarning, addError }) {
499
+ const packageLabel = checkedPackage.path === '.' ? '' : `${checkedPackage.path}: `;
500
+ const packageJson = JSON.parse(readFileSync(join(checkedPackage.root, 'package.json'), 'utf8'));
501
+ if (packageJson.private && packageJson.workspaces) {
502
+ addPass('package', `${packageLabel}private workspace root — never published, contents not checked`);
503
+ return;
504
+ }
434
505
  let packedPaths;
435
506
  try {
436
- packedPaths = getPackedPaths(config);
507
+ packedPaths = getPackedPaths(checkedPackage.root);
437
508
  } catch {
438
509
  packedPaths = null;
439
510
  }
440
511
  if (!packedPaths) {
441
- addWarning('package', 'npm pack --dry-run failed — package contents not checked');
512
+ addWarning('package', `${packageLabel}npm pack --dry-run failed — package contents not checked`);
442
513
  return;
443
514
  }
444
515
  const packageLeaks = getPackageLeaks(packedPaths);
445
516
  for (const packageLeak of packageLeaks) {
446
517
  addError(
447
518
  'package',
448
- `${packageLeak.path} would be published (${packageLeak.label})`,
449
- `exclude it in package.json "files" (e.g. ${PACKAGE_FILES_EXCLUSIONS.map((exclusion) => `"${exclusion}"`).join(', ')}) or narrow the glob`
519
+ `${getPackageFilePath(checkedPackage, packageLeak.path)} would be published (${packageLeak.label})`,
520
+ `exclude it in ${packageLabel ? `${checkedPackage.path}/` : ''}package.json "files" (e.g. ${PACKAGE_FILES_EXCLUSIONS.map((exclusion) => `"${exclusion}"`).join(', ')}) or narrow the glob`
450
521
  );
451
522
  }
452
- if (!packageLeaks.length) addPass('package', `${packedPaths.length} packed file(s), no dev-only files`);
523
+ if (!packageLeaks.length) addPass('package', `${packageLabel}${packedPaths.length} packed file(s), no dev-only files`);
453
524
  }
454
525
 
455
526
  function checkRulebook(config, { addPass, addWarning, addError }) {
@@ -458,21 +529,32 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
458
529
  return;
459
530
  }
460
531
  const skippedSuites = new Set(config.rulebook.skippedChecks);
461
- const acceptedCheckIds = loadAcceptedCheckIds(config.root);
462
- const suites = getRulebookSuites(config.kind);
463
- if (suites.length < RULEBOOK_SUITES.length) {
464
- const componentOnlySuiteNames = RULEBOOK_SUITES.filter((suite) => !suites.includes(suite)).map((suite) => suite.name);
465
- addPass('rulebook', `${config.kind}: component-only suites not run (${componentOnlySuiteNames.join(', ')})`);
532
+ const isMonorepo = config.packages.some((checkedPackage) => checkedPackage.path !== '.');
533
+ // [{ suite, checkedRoot, checkName, acceptedCheckIds }]: a package's suites run in the package
534
+ // (for its kind), the repo-wide ones once at the root. A single-package repo runs all at the root.
535
+ const suiteRuns = [];
536
+ for (const checkedPackage of config.packages) {
537
+ const suites = getRulebookSuites(checkedPackage.kind).filter((suite) => !isMonorepo || !suite.isRepoWide);
538
+ const packageSuffix = isMonorepo ? `@${checkedPackage.path}` : '';
539
+ if (suites.length < RULEBOOK_SUITES.filter((suite) => !isMonorepo || !suite.isRepoWide).length) {
540
+ const componentOnlySuiteNames = RULEBOOK_SUITES.filter((suite) => suite.isComponentOnly).map((suite) => suite.name);
541
+ addPass(`rulebook${packageSuffix}`, `${checkedPackage.kind}: component-only suites not run (${componentOnlySuiteNames.join(', ')})`);
542
+ }
543
+ const acceptedCheckIds = new Set([...loadAcceptedCheckIds(config.root), ...loadAcceptedCheckIds(checkedPackage.root)]);
544
+ for (const suite of suites) suiteRuns.push({ suite, checkedRoot: checkedPackage.root, checkName: `rulebook:${suite.name}${packageSuffix}`, acceptedCheckIds });
545
+ }
546
+ if (isMonorepo) {
547
+ const acceptedCheckIds = loadAcceptedCheckIds(config.root);
548
+ for (const suite of RULEBOOK_SUITES.filter((repoSuite) => repoSuite.isRepoWide)) suiteRuns.push({ suite, checkedRoot: config.root, checkName: `rulebook:${suite.name}`, acceptedCheckIds });
466
549
  }
467
- for (const suite of suites) {
550
+ for (const { suite, checkedRoot, checkName, acceptedCheckIds } of suiteRuns) {
468
551
  if (skippedSuites.has(suite.name)) continue;
469
552
  if (!existsSync(join(config.rulebook.path, `${suite.name}.checks.sh`))) continue;
470
- const suiteResult = runRulebookSuite(config, suite);
553
+ const suiteResult = runRulebookSuite(config, suite, checkedRoot);
471
554
  if (suiteResult.error) {
472
555
  addWarning('rulebook', `cannot run bash (${suiteResult.error.code}) — rulebook checks skipped`, 'install Git Bash / run from a POSIX shell');
473
556
  return;
474
557
  }
475
- const checkName = `rulebook:${suite.name}`;
476
558
  const failures = getSuiteFailures(suiteResult.stdout);
477
559
  // A suite passes with exit 0 and fails with exit 1 + FAIL lines. Anything else (2 = usage,
478
560
  // 127 = the script wasn't found — WSL's bash on a C:/ path) means it never ran: say so,
package/src/config.d.ts CHANGED
@@ -73,6 +73,19 @@ export interface KmwcPackageConfig {
73
73
  skippedChecks?: string[];
74
74
  };
75
75
  sync?: { skippedFiles?: string[] };
76
+ /**
77
+ * The packages this repo publishes, for a monorepo whose root is not the package (npm
78
+ * workspaces): doctor runs the package-contents check and the rulebook suites for each one's
79
+ * kind there. Default: one package, the repo root, with the repo's kind.
80
+ */
81
+ packages?: Array<{ path: string; kind: 'component' | 'library' }>;
82
+ }
83
+
84
+ /** One package doctor checks: `path` as configured, `root` resolved. */
85
+ export interface KmwcPackage {
86
+ path: string;
87
+ root: string;
88
+ kind: 'component' | 'library';
76
89
  }
77
90
 
78
91
  export interface KmwcConfig {
@@ -131,6 +144,7 @@ export interface KmwcConfig {
131
144
  };
132
145
  rulebook: { path: string; skippedChecks: string[] };
133
146
  sync: { skippedFiles: string[] };
147
+ packages: KmwcPackage[];
134
148
  }
135
149
 
136
150
  export const DEVKIT_ROOT: string;
package/src/config.js CHANGED
@@ -183,6 +183,23 @@ export function getKmwcConfig(root = process.cwd()) {
183
183
  return mapKmwcConfig(getJsonFile(packageJsonPath), root);
184
184
  }
185
185
 
186
+ const PACKAGE_KINDS = ['component', 'library'];
187
+
188
+ /**
189
+ * `kmwc.packages` → [{ path, root, kind }]; without it, one package at the repo root with the
190
+ * repo's kind (a showcase / docs / test repo checks as a component, as before).
191
+ */
192
+ function mapPackages(packageEntries, root, repoKind) {
193
+ if (!packageEntries) return [{ path: '.', root, kind: repoKind === 'library' ? 'library' : 'component' }];
194
+ if (!Array.isArray(packageEntries) || !packageEntries.length) throw new Error('kmwc.packages: expected a non-empty array of { path, kind }');
195
+ return packageEntries.map((packageEntry) => {
196
+ if (!packageEntry?.path || !PACKAGE_KINDS.includes(packageEntry.kind)) {
197
+ throw new Error(`kmwc.packages: ${JSON.stringify(packageEntry)} needs a "path" and a "kind" (${PACKAGE_KINDS.join(' | ')})`);
198
+ }
199
+ return { path: packageEntry.path, root: resolve(root, packageEntry.path), kind: packageEntry.kind };
200
+ });
201
+ }
202
+
186
203
  /** Map a parsed package.json to the resolved config (pure — used by tests). */
187
204
  export function mapKmwcConfig(packageJson, root = process.cwd()) {
188
205
  const packageConfig = packageJson.kmwc || {};
@@ -199,6 +216,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
199
216
  const cssVariables = packageConfig.cssVariables === false ? { checkMode: 'off' } : packageConfig.cssVariables || {};
200
217
  const rulebook = packageConfig.rulebook || {};
201
218
  const devPort = ports.dev ?? 5173;
219
+ const kind = packageConfig.kind || getRegistryKind(id);
202
220
 
203
221
  return {
204
222
  root,
@@ -206,7 +224,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
206
224
  packageJson,
207
225
  id,
208
226
  packageName: packageJson.name || id,
209
- kind: packageConfig.kind || getRegistryKind(id),
227
+ kind,
210
228
  version: packageJson.version || '0.1.0',
211
229
  title: packageConfig.title || mapToTitleCase(id),
212
230
  tagline: packageConfig.tagline || packageJson.description || '',
@@ -275,6 +293,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
275
293
  sync: {
276
294
  skippedFiles: packageConfig.sync?.skippedFiles || [],
277
295
  },
296
+ packages: mapPackages(packageConfig.packages, root, kind),
278
297
  };
279
298
  }
280
299
 
@@ -97,6 +97,12 @@ export const INDEX_PAGE_CLASSES = {
97
97
  devkit: 'kmwc-index__devkit',
98
98
  };
99
99
 
100
+ /** The footer the kit appends to every example page but the index (page-footer.js). */
101
+ export const PAGE_FOOTER_CLASSES = {
102
+ root: 'kmwc-page-footer',
103
+ devkit: 'kmwc-page-footer__devkit',
104
+ };
105
+
100
106
  /** <kmwc-demo> output (rendered at build time by page-composer.js) + the kit's copy button. */
101
107
  export const DEMO_CLASSES = {
102
108
  root: 'kmwc-demo',
@@ -0,0 +1,19 @@
1
+ /* ==============================================================================
2
+ EXAMPLES KIT — PAGE FOOTER
3
+ ==============================================================================
4
+ The footer the kit appends to every example page but the index
5
+ (page-footer.js); the same line as the index footer. */
6
+ @layer kmwc {
7
+ .kmwc-page-footer {
8
+ text-align: center;
9
+ margin-top: 3rem;
10
+ padding: 1.5rem 0 1rem;
11
+ border-top: 1px solid var(--kmwc-border-color);
12
+ color: var(--kmwc-text-color-3);
13
+ font-size: 0.875rem;
14
+ }
15
+
16
+ .kmwc-page-footer p {
17
+ margin: 0;
18
+ }
19
+ }
@@ -31,3 +31,4 @@
31
31
  @import './spotlight.css';
32
32
  @import './theme-switch.css';
33
33
  @import './index-page.css';
34
+ @import './page-footer.css';
@@ -19,6 +19,7 @@
19
19
  import { getPageConfig } from './page-config.js';
20
20
  import { getVersionLabel } from './version-badge.js';
21
21
  import { INDEX_PAGE_CLASSES } from './constants.js';
22
+ import { renderFooterLineHtml } from './page-footer.js';
22
23
 
23
24
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
24
25
  const mapToEscapedHtml = (text) => String(text ?? '').replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
@@ -32,10 +33,6 @@ export function renderExamplesIndex(indexConfig) {
32
33
  const pages = indexConfig.pages || [];
33
34
  const title = indexConfig.title ?? pageConfig.title;
34
35
  const tagline = indexConfig.tagline ?? pageConfig.tagline;
35
- const footerLinks = [
36
- ...(pageConfig.repository ? [{ label: 'GitHub', href: pageConfig.repository }] : []),
37
- ...(indexConfig.links || []),
38
- ];
39
36
  const classes = INDEX_PAGE_CLASSES;
40
37
 
41
38
  document.body.classList.add(classes.body);
@@ -63,13 +60,7 @@ export function renderExamplesIndex(indexConfig) {
63
60
  .join('')}
64
61
  </div>
65
62
  <footer class="${classes.footer}">
66
- <p>Created by <a href="https://github.com/keenmate" target="_blank" rel="noopener">Keenmate s.r.o.</a>${footerLinks
67
- .map((link) => ` | <a href="${mapToEscapedHtml(link.href)}" target="_blank" rel="noopener">${mapToEscapedHtml(link.label)}</a>`)
68
- .join('')}${
69
- pageConfig.devkitVersion
70
- ? ` | <a class="${classes.devkit}" href="${mapToEscapedHtml(pageConfig.devkitHomepage)}" target="_blank" rel="noopener">web-components-devkit v${mapToEscapedHtml(pageConfig.devkitVersion)}</a>`
71
- : ''
72
- }</p>
63
+ ${renderFooterLineHtml(pageConfig, { links: indexConfig.links, devkitClass: classes.devkit })}
73
64
  </footer>`;
74
65
  containerEl.prepend(pageElement);
75
66
 
@@ -11,7 +11,7 @@ export interface KmwcPageConfig {
11
11
  isSpotlightEnabled: boolean;
12
12
  repository: string;
13
13
  homepage: string;
14
- /** The @keenmate/web-components-devkit version that built the page (shown in the index footer). */
14
+ /** The @keenmate/web-components-devkit version that built the page (shown in the page footers). */
15
15
  devkitVersion: string;
16
16
  devkitHomepage: string;
17
17
  }
@@ -93,6 +93,10 @@ export function renderVersionBadges(root?: ParentNode): void;
93
93
  export function attachDemoCopyButtons(root?: ParentNode): void;
94
94
  export function getVersionLabel(pageConfig?: KmwcPageConfig): string;
95
95
 
96
+ /** Append the footer (Keenmate | GitHub | devkit version) to an example page; none on the index. */
97
+ export function mountPageFooter(): HTMLElement | null;
98
+ /** The footer line the index and the page footer share. */
99
+ export function renderFooterLineHtml(pageConfig: KmwcPageConfig, footerConfig: { links?: Array<{ label: string; href: string }>; devkitClass: string }): string;
96
100
  export function mountThemeSwitch(): { unmount(): void };
97
101
  export function applyTheme(theme: Theme): void;
98
102
  export function getSavedTheme(): Theme;
@@ -118,6 +122,7 @@ export const SPOTLIGHT_FULLSCREEN_MEDIA: string;
118
122
  export const CHAPTER_CODE_PATTERN: RegExp;
119
123
  export const COPY_LINK_CLASSES: Record<string, string>;
120
124
  export const INDEX_PAGE_CLASSES: Record<string, string>;
125
+ export const PAGE_FOOTER_CLASSES: { root: string; devkit: string };
121
126
  export const THEME_SWITCH_CLASSES: Record<string, string>;
122
127
  export const DEMO_CLASSES: Record<string, string>;
123
128
  export const THEMES: Theme[];
@@ -15,6 +15,7 @@ export { searchChapters, checkMatchesAny, renderHighlightedText } from './chapte
15
15
  export { attachCopyLinks, copyText, getSectionUrl } from './copy-link.js';
16
16
  export { renderVersionBadges, getVersionLabel } from './version-badge.js';
17
17
  export { attachDemoCopyButtons } from './demo-code.js';
18
+ export { mountPageFooter, renderFooterLineHtml } from './page-footer.js';
18
19
  export { mountThemeSwitch, applyTheme, getSavedTheme } from './theme-switch.js';
19
20
  export { ensureSectionAnchors, generateSlug, getHeadingLabel, getChapterCode, mapToCodeKey, findSectionByHash } from './sections.js';
20
21
  export { getPageConfig, attachDomReadyHandler } from './page-config.js';
@@ -5,15 +5,16 @@ import { attachCopyLinks } from './copy-link.js';
5
5
  import { renderVersionBadges } from './version-badge.js';
6
6
  import { mountThemeSwitch } from './theme-switch.js';
7
7
  import { attachDemoCopyButtons } from './demo-code.js';
8
+ import { mountPageFooter } from './page-footer.js';
8
9
  import { INDEX_PAGE_CLASSES } from './constants.js';
9
10
 
10
11
  /**
11
12
  * (Re)mount the page-level helpers on the current DOM (copy-links, chapter
12
13
  * nav, short chapter links, spotlight, version badges, demo copy buttons,
13
- * theme switch). Runs automatically once per page; call it again after
14
+ * theme switch, page footer). Runs automatically once per page; call it again after
14
15
  * rendering sections asynchronously. Idempotent: copy-link / demo copy buttons
15
- * skip elements that already have one, the nav, spotlight and theme switch are
16
- * remounted.
16
+ * skip elements that already have one, the nav, spotlight, theme switch and
17
+ * footer are remounted.
17
18
  */
18
19
  export function mountExamplesKit() {
19
20
  // Copy-links before the nav: the nav strips the buttons back out when
@@ -26,4 +27,5 @@ export function mountExamplesKit() {
26
27
  renderVersionBadges();
27
28
  attachDemoCopyButtons();
28
29
  mountThemeSwitch();
30
+ mountPageFooter();
29
31
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The footer every example page ends with — Keenmate, the repo, and the devkit
3
+ * that built the page. The index page renders the same line in its own footer
4
+ * (index-page.js); the other pages get it from the kit (mountPageFooter).
5
+ */
6
+ import { getPageConfig } from './page-config.js';
7
+ import { INDEX_PAGE_CLASSES, PAGE_FOOTER_CLASSES } from './constants.js';
8
+
9
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
10
+ const mapToEscapedHtml = (text) => String(text ?? '').replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
11
+
12
+ /**
13
+ * The footer line: "Created by Keenmate s.r.o. | GitHub | …links | web-components-devkit vX".
14
+ * @param {import('./index.js').KmwcPageConfig} pageConfig
15
+ * @param {{ links?: Array<{ label: string, href: string }>, devkitClass: string }} footerConfig
16
+ */
17
+ export function renderFooterLineHtml(pageConfig, { links = [], devkitClass }) {
18
+ const footerLinks = [...(pageConfig.repository ? [{ label: 'GitHub', href: pageConfig.repository }] : []), ...links];
19
+ const linksHtml = footerLinks
20
+ .map((link) => ` | <a href="${mapToEscapedHtml(link.href)}" target="_blank" rel="noopener">${mapToEscapedHtml(link.label)}</a>`)
21
+ .join('');
22
+ const devkitHtml = pageConfig.devkitVersion
23
+ ? ` | <a class="${devkitClass}" href="${mapToEscapedHtml(pageConfig.devkitHomepage)}" target="_blank" rel="noopener">web-components-devkit v${mapToEscapedHtml(pageConfig.devkitVersion)}</a>`
24
+ : '';
25
+ return `<p>Created by <a href="https://github.com/keenmate" target="_blank" rel="noopener">Keenmate s.r.o.</a>${linksHtml}${devkitHtml}</p>`;
26
+ }
27
+
28
+ /**
29
+ * Append the footer to an example page — inside the layout's `.container`
30
+ * when there is one, else at the end of <body>. Remounting replaces it; the
31
+ * index page keeps its own footer and gets none.
32
+ * @returns {HTMLElement | null}
33
+ */
34
+ export function mountPageFooter() {
35
+ document.querySelector(`.${PAGE_FOOTER_CLASSES.root}`)?.remove();
36
+ if (document.body.classList.contains(INDEX_PAGE_CLASSES.body)) return null;
37
+
38
+ const footerElement = document.createElement('footer');
39
+ footerElement.className = PAGE_FOOTER_CLASSES.root;
40
+ footerElement.innerHTML = renderFooterLineHtml(getPageConfig(), { devkitClass: PAGE_FOOTER_CLASSES.devkit });
41
+ const containerElement = document.querySelector('body > .container');
42
+ (containerElement || document.body).append(footerElement);
43
+ return footerElement;
44
+ }