@keenmate/web-components-devkit 1.0.0-rc06 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +53 -17
- package/package.json +1 -1
- package/src/cli/commands/check-pages.js +189 -0
- package/src/cli/commands/check-vars.js +20 -8
- package/src/cli/commands/doctor.js +97 -1
- package/src/cli/kmwc.js +9 -0
- package/src/cli/repo-checks.js +249 -0
- package/src/cli/sync-plan.js +1 -0
- package/src/examples/css/base.css +28 -0
- package/src/examples/css/content.css +4 -1
- package/src/examples/css/index-page.css +4 -0
- package/src/vite/index.js +2 -6
- package/src/vite/page-composer.js +30 -1
- package/templates/Makefile.tmpl +4 -1
- package/templates/claude-block.md +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.0-rc07] - 2026-11-03 [PUBLISHED]
|
|
4
|
+
|
|
5
|
+
Found migrating web-treeview — each item is something doctor missed or got wrong there.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`kmwc check-pages`** — loads every example page in Chromium at 1440 / 390 / 320px and
|
|
10
|
+
reports page and console errors, failed requests, custom elements that never get defined and a
|
|
11
|
+
page wider than the viewport (with the widest elements named). treeview's iOS-Files mockup
|
|
12
|
+
(`width: 420px`) and theme gallery (`minmax(500px, 1fr)`) pushed a phone sideways and only a
|
|
13
|
+
browser could tell. Starts the repo's dev server, or `--url` for one already running (the built
|
|
14
|
+
site on the preview port); `--widths`. It looks inside shadow roots and at text (a long word
|
|
15
|
+
sticking out of a box that fits), and skips what can't widen the page (code blocks and other
|
|
16
|
+
scrolling boxes, hidden or fixed layers such as a closed chapter-nav panel). `kmwc sync` adds a
|
|
17
|
+
`check:pages` script and a `make check-pages` target.
|
|
18
|
+
- **Doctor: accepted deviations** — a rulebook failure whose id heads a section of
|
|
19
|
+
`VALIDATION-NOTES.md` (`## C-CST-4 / C-CST-10 — …`) is an architectural decision recorded per the
|
|
20
|
+
rulebook and counts as a pass (`--verbose` lists them). treeview's documented C-CST-4 / C-CSS-1 /
|
|
21
|
+
C-NC-6 showed as errors.
|
|
22
|
+
- **Doctor: library outputs** — every `./dist/…` target in package.json (`main`, `module`, `types`,
|
|
23
|
+
`style`, `exports`) must be a file the library build emits, and before the switch the old
|
|
24
|
+
`lib.name` must equal `kmwc.library.globalName`. The preset's defaults would have renamed
|
|
25
|
+
treeview's UMD global (`WebTreeView` → `WebTreeview`) and its CSS (`web-treeview.css` →
|
|
26
|
+
`style.css`) without a word.
|
|
27
|
+
- **Doctor: ports** — a script that pins its own `--port`, a config / Makefile / doc port outside the
|
|
28
|
+
repo's registry block, and a sibling checkout listening inside it are reported. treeview ran on
|
|
29
|
+
21111 (docs too), and web-daterangepicker hardcoded treeview's 12300; the check also finds
|
|
30
|
+
web-multiselect and web-dropzone on 12200 and web-component-framework-test on 12300.
|
|
31
|
+
- **Doctor: analytics** — a Plausible site id another repo also uses is an error (treeview's
|
|
32
|
+
snippet borrowed web-multiselect's, so its traffic was counted as multiselect's).
|
|
33
|
+
- **Doctor: page CSS the kit covers** — page `<style>` / `examples-local.css` rules on kit-owned
|
|
34
|
+
selectors (`.kbd`, `kbd`, `mark`, `.pill` …) and one-declaration inline styles a kit class covers
|
|
35
|
+
(`width` on a field → `.w-*`, `margin-left: auto` → `.push-end`, `margin-top: 1rem` → `.mt-1`)
|
|
36
|
+
are warnings.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **check-vars: input variables** — in the default mode a component variable is present when it is
|
|
41
|
+
declared **or** read. A knob that is read but never declared is an input, unset by design:
|
|
42
|
+
treeview forwards `--wtv-context-menu-*` to core's private `--_km-menu-*` so a page-wide
|
|
43
|
+
`--km-menu-*` still applies, and declaring them would break that chain — check-vars called all 26
|
|
44
|
+
dead. A read-with-fallback that isn't in the manifest is now reported as undocumented.
|
|
45
|
+
`cssVariables.sources` also takes a package path (`@keenmate/web-components-core/dist/menu/styles.js`)
|
|
46
|
+
for the `--base-*` reads of a core module the component renders.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **The text "<head>" / "</body>" on a page no longer receives the injected snippets** —
|
|
51
|
+
the plugin put the theme / page-config / analytics block after the first `<head…>` match,
|
|
52
|
+
which also matched `<header>` (a page without `<head>`), and the kit's module script before
|
|
53
|
+
the first `</body>`, which can be text inside a page's inline script (the kit then landed
|
|
54
|
+
in that script and broke it). Now: the first real `<head>` tag outside HTML comments, and
|
|
55
|
+
the last `</body>`. Same failure as web-multiselect's live action-buttons page, where its
|
|
56
|
+
own Dockerfile's `sed '/<head>/r …'` put the Plausible snippet (an HTML comment) into the
|
|
57
|
+
page's module script, so no picker got its options.
|
|
58
|
+
- **Headings break instead of widening a phone** — a chapter heading naming an API outside
|
|
59
|
+
`<code>` (drp's `EV05 · getMonthDataCallback & beforeMonthChangeCallback`) and the examples index
|
|
60
|
+
title ("Web DateRangePicker" at 2.5rem) had no break opportunity at 320px. `h1`–`h4` get
|
|
61
|
+
`overflow-wrap: anywhere`; the index title is 2rem on a phone.
|
|
62
|
+
- **A 250px widget fits a 320px phone** — body + card padding left 240px, so an inline calendar
|
|
63
|
+
widened the page. Below 400px the body pads 0.75rem, cards and example sections 1rem, demo cards
|
|
64
|
+
and demo areas 0.75rem (264px inside a card).
|
|
65
|
+
- **`mark` no longer splits a word** — the 2px side padding cut a match inside a word apart
|
|
66
|
+
(core's Search page: "Town send"); `padding: 0`, and a match that wraps keeps its look on both
|
|
67
|
+
lines (`box-decoration-break: clone`).
|
|
68
|
+
|
|
3
69
|
## [1.0.0-rc06] - 2026-11-01 [PUBLISHED]
|
|
4
70
|
|
|
5
71
|
### Added
|
package/README.md
CHANGED
|
@@ -19,6 +19,14 @@ 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-rc07
|
|
23
|
+
|
|
24
|
+
- **CLI — `kmwc check-pages` finds what only a browser can see** — Migrating web-treeview turned up pages that pushed a phone sideways: an iOS-Files mockup 420px wide and a theme gallery built on `minmax(500px, 1fr)`. No static check could have caught that. `kmwc check-pages` loads every example page in Chromium at 1440, 390 and 320px. It reports page and console errors, failed requests, custom elements that never get defined, and pages wider than the viewport, naming the widest elements. It looks inside shadow roots and at long words that overflow their box. It ignores layers that can't widen a page: scrolling code blocks, hidden or fixed layers, a closed chapter-nav panel. It starts the repo's dev server, or `--url` points it at one already running (for example the built site on the preview port), and `--widths` changes the widths. `kmwc sync` adds a `check:pages` script and a `make check-pages` target.
|
|
25
|
+
- **Doctor — catches what the treeview and daterangepicker migrations got wrong** — Doctor now checks that every `./dist/…` target in package.json is a file the library build actually emits, and that the old UMD name matches `kmwc.library.globalName`. Without that, the preset's defaults would quietly have renamed treeview's global (`WebTreeView` → `WebTreeview`) and its CSS (`web-treeview.css` → `style.css`). Doctor also reports scripts that pin their own `--port`, config, Makefile and doc ports outside the repo's registry block, and sibling repos that listen inside it. This found web-daterangepicker on treeview's 12300 and web-multiselect / web-dropzone on daterangepicker's 12200. A Plausible site id that another repo also uses is an error, because treeview's traffic was being counted as web-multiselect's. Page CSS that duplicates kit classes (`.kbd`, `mark`, inline field widths, `margin-left: auto`) is a warning.
|
|
26
|
+
- **Doctor — `VALIDATION-NOTES.md` records accepted deviations** — The rulebook lets a component record an architectural decision instead of following a rule. Doctor still reported those as errors (treeview's C-CST-4, C-CSS-1 and C-NC-6). A rulebook check id used as a `##` heading in `VALIDATION-NOTES.md` (`## C-CST-4 / C-CST-10 — …`) now counts as a pass, and `--verbose` lists them. A mention in plain prose doesn't count.
|
|
27
|
+
- **check-vars — input variables are not dead** — Some component variables are public knobs that are read with a fallback and never declared. Treeview forwards `--wtv-context-menu-*` to core's private `--_km-menu-*` so a page-wide `--km-menu-*` still applies, and declaring them would break that chain. check-vars called all 26 of them dead. In the default mode a variable now counts as present when it is declared or read, and a read-with-fallback missing from the manifest is reported as undocumented. `cssVariables.sources` also takes a package path (`@keenmate/web-components-core/dist/menu/styles.js`) for the `--base-*` reads of a core module the component renders.
|
|
28
|
+
- **Example pages — fit a 320px phone, and injected scripts land in the right place** — A chapter heading that names an API outside `<code>` now breaks instead of widening the page. On a phone the index title is 2rem. Below 400px, body, card and demo padding shrink, so a 250px inline calendar fits inside a card. `mark` no longer splits a word. The Vite plugin now puts its head block after the real `<head>` tag (not `<header>` or the text "<head>"). It puts the kit script before the last `</body>`, so it can no longer land inside a page's inline script and break it. That's the failure web-multiselect's sed-based Dockerfile caused on its live action-buttons page.
|
|
29
|
+
|
|
22
30
|
## What's New in v1.0.0-rc06
|
|
23
31
|
|
|
24
32
|
- **Example pages — `<kbd>`, `<mark>`, field widths and `.push-end`** — Every component documents a keyboard map and most highlight search matches. Pages either left `<kbd>` in the browser's look (core's Interaction page had 18 bare keys) or styled `<mark>` themselves. A `<kbd>` now looks like the spotlight's key hints and a `<mark>` like its match highlight. Native fields are about 20 characters wide, so pages set inline widths; core had 14. A number field is now 7rem by default. `.w-xs` / `.w-sm` / `.w-md` / `.w-lg` (4 / 6 / 9 / 12rem) size a short field, and a `.form-group` still stretches it to the row. `.push-end` moves an item to the end of a `.controls` row. See the playground's WG08 and FC12.
|
|
@@ -26,13 +34,6 @@ them in the same shape: `@keenmate/web-components-devkit`.
|
|
|
26
34
|
- **CLI — `kmwc <command> --help` no longer runs the command** — Only a top-level `--help` was handled, and unknown flags were silently ignored, so `kmwc sync --help` did a full sync and overwrote core's hand-written Makefile. Every command now lists the flags it accepts. `--help` / `-h` after any command prints that command's usage and exits 0, and an unknown option is an error (exit 2). A boolean flag no longer takes the next word as its value.
|
|
27
35
|
- **Libraries — `library: false`, `cssVariables: false`, and a doctor that knows the kind** — A repo like web-components-core builds its package with `tsc`. A plain `vite build` (the devkit's library mode) would empty its `dist/`, so `"kmwc": { "library": false }` now makes it refuse to run, while `vite build --mode examples` still builds the site. `"cssVariables": false` turns off the variables-manifest check, so sync stops re-adding `check:vars`. Doctor reads the registry kind (or `kmwc.kind`): a `library` runs only naming-conventions, readme-structure, publish-command and example-pages. On core that drops 12 errors and 4 "suite could not run" warnings that didn't apply to a library.
|
|
28
36
|
|
|
29
|
-
## What's New in v1.0.0-rc05
|
|
30
|
-
|
|
31
|
-
- **Log panels — one tone set for every log line** — web-components-core's example pages each coloured their log lines with their own classes (`.warn`, `.lv-error`, `data-kind="hook"` …), four schemes mixing severity with "what happened". A `.log-line` now takes one of five tones — `--ok`, `--info`, `--warn`, `--err`, `--muted`, the same names as `.pill` and `.status-dot` (which gain `--info` / `--muted`) — and `--action` marks a "▶ you clicked X" line. Kinds such as hook or callback map onto those tones; the kind itself goes in a cell as text. On `.log-panel--dark` the tones switch to fixed light tints (`--kmwc-console-ok|info|warn|err-color`), so they stay readable on the console in both themes, while a light `.log-panel` follows the page theme. Every line keeps its line breaks and indentation.
|
|
32
|
-
- **Log panels — timeline columns, text logs and an empty placeholder** — The cells `.log-line__seq`, `__time`, `__source` and `__name` turn a line into aligned columns, with the detail after them wrapping in its own column; the name takes the line's tone. A log fed by `textContent` is a `<pre class="log-panel">`, `data-empty="(nothing yet)"` shows while a panel is empty, and `--log-max-height: none` lets a log grow. This replaces the page-local timeline CSS core's Lifecycle and Interaction pages carried.
|
|
33
|
-
- **`.event-log` — the legacy console now renders as `.log-panel--dark`** — Many component pages (web-grid, web-dropzone, web-treeview, the showcases) still use `.event-log` / `.event-entry` / `.event-time` / `.event-name`, which differed slightly from the newer panel (grey base text, 0.8rem, a 200px cap, no columns). It is now the same panel, and an `.event-entry` holding `.event-time` / `.event-name` is a timeline row with an aligned time column and tones, so old and new logs look the same. Other `.event-log` markup — lines without `.event-entry`, page-own classes — only gets the panel look, so existing layouts don't break.
|
|
34
|
-
- **Small helpers — `.mono`, `td.is-highlight`, `.check-list`** — `.mono` sets the monospace font without the code chip, and `textarea.mono` makes a JSON or config editor. `td.is-highlight` (or `th`) tints one table cell, where only whole rows could be highlighted before. `.check-list` stacks checkbox / radio options one per line, a long label wrapping beside its box, with room for a trailing `.pill`. The playground shows everything on the Widgets (WG01–WG07), Form controls (FC11) and Tables (TB06) pages.
|
|
35
|
-
|
|
36
37
|
## Catalogue
|
|
37
38
|
|
|
38
39
|
<!-- kmwc:catalogue:start — generated by `npm run readme`, do not edit inside -->
|
|
@@ -41,8 +42,8 @@ them in the same shape: `@keenmate/web-components-devkit`.
|
|
|
41
42
|
| Component | Package | Prefix | Dev port | Links | Devkit | About |
|
|
42
43
|
|---|---|---|---:|---|:---:|---|
|
|
43
44
|
| **web-multiselect** | `@keenmate/web-multiselect` | `ms` | 12100 | [repo](https://github.com/keenmate/web-multiselect) · [examples](https://examples.web-multiselect.keenmate.dev) · [docs](https://web-multiselect.keenmate.dev) · `web-multiselect-showcase` · `web-multiselect-docs` | — | Lightweight multiselect web component with typeahead search, rich content support, and excellent keyboard… |
|
|
44
|
-
| **web-daterangepicker** | `@keenmate/web-daterangepicker` | `drp` | 12200 | [repo](https://github.com/keenmate/web-daterangepicker) · [docs](https://web-daterangepicker.keenmate.dev) · `web-daterangepicker-showcase` |
|
|
45
|
-
| **web-treeview** | `@keenmate/web-treeview` | `wtv` | 12300 | [repo](https://github.com/keenmate/web-treeview) · `web-treeview-showcase` |
|
|
45
|
+
| **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 |
|
|
46
|
+
| **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
47
|
| **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
48
|
| **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
49
|
| **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 |
|
|
@@ -168,9 +169,11 @@ Every field is optional. Defaults are derived from the package.
|
|
|
168
169
|
"packageJsonPaths": ["package.json"] // monorepos: list workspace package.jsons too
|
|
169
170
|
},
|
|
170
171
|
"cssVariables": { // false: no variables manifest, no check:vars
|
|
171
|
-
"sources": ["src/css"], // dirs (stylesheets) or explicit files (any type)
|
|
172
|
+
"sources": ["src/css"], // dirs (stylesheets) or explicit files (any type); a package
|
|
173
|
+
// path (e.g. a core module's CSS) resolves from node_modules
|
|
172
174
|
"internalVariables": ["ms-input-current-width"], // runtime internals, not theming knobs
|
|
173
|
-
"checkMode": "declared" //
|
|
175
|
+
"checkMode": "declared" // declared OR read (an input knob may be read, never set);
|
|
176
|
+
// "consumed": var() reads only
|
|
174
177
|
},
|
|
175
178
|
"rulebook": {
|
|
176
179
|
"path": "../BlissFramework/guidelines/web-components", // relative to the component; default: the nearest
|
|
@@ -362,6 +365,10 @@ kmwc doctor [--json] devkit checks + package contents + the rulebook's
|
|
|
362
365
|
(exit 1 on errors). --verbose lists passes, --no-rulebook skips the
|
|
363
366
|
suites, --no-pack skips the package-contents check
|
|
364
367
|
kmwc check-vars [--strict] component-variables.manifest.json ⇔ CSS
|
|
368
|
+
kmwc check-pages [--url u] every example page in Chromium at 1440 / 390 / 320px: page and console
|
|
369
|
+
errors, failed requests, never-defined elements, a page wider than the
|
|
370
|
+
viewport. Starts the dev server unless --url (e.g. the built site);
|
|
371
|
+
--widths 1440,390,320
|
|
365
372
|
kmwc check-release --tag t latest ⇔ release version, rc ⇔ -rcN, version not yet on npm
|
|
366
373
|
kmwc new web-<name> scaffold a component (--prefix, --title, --tag, --dir, --port, --minimal, --register)
|
|
367
374
|
--adopt: scaffold an ALREADY registered component (its port/prefix) to migrate into
|
|
@@ -388,7 +395,31 @@ and example-pages; the others check what a custom element ships (its CSS,
|
|
|
388
395
|
manifest, overlay).
|
|
389
396
|
When the rulebook isn't checked out (or there's no bash) the suites are
|
|
390
397
|
skipped with a warning — the rulebook stays the single source of truth; the
|
|
391
|
-
devkit never copies its rules.
|
|
398
|
+
devkit never copies its rules. A failure whose check id heads a section of
|
|
399
|
+
`VALIDATION-NOTES.md` (`## C-CSS-1 — Lean strategy`, `## C-CST-4 / C-CST-10 — …`)
|
|
400
|
+
is an accepted deviation — an architectural decision recorded per the rulebook —
|
|
401
|
+
and counts as a pass (`--verbose` lists them).
|
|
402
|
+
|
|
403
|
+
**What else doctor checks.** Beyond the presets, legacy files, fragment pages and
|
|
404
|
+
generated files:
|
|
405
|
+
|
|
406
|
+
- **Library outputs** — every `./dist/…` target in package.json (`main`,
|
|
407
|
+
`module`, `types`, `style`, `exports`) is a file the library build emits. Before
|
|
408
|
+
the switch to the preset it also compares the old `lib.name` with
|
|
409
|
+
`kmwc.library.globalName` — a renamed UMD global or CSS file breaks consumers
|
|
410
|
+
silently.
|
|
411
|
+
- **Ports** — no script pins a `--port` of its own, the repo's configs, Makefile
|
|
412
|
+
and docs stay inside its registry block, and no sibling checkout (a registry
|
|
413
|
+
entry at `../<id>`) listens on a port inside it.
|
|
414
|
+
- **Analytics** — no other repo uses the same Plausible site id.
|
|
415
|
+
- **Page CSS** — a page `<style>` or `examples-local.css` rule on a kit-owned
|
|
416
|
+
selector (`.kbd`, `kbd`, `mark`, `.pill`, `.card` …) and single-declaration
|
|
417
|
+
inline styles a kit class covers (`width` on a field → `.w-*`,
|
|
418
|
+
`margin-left: auto` → `.push-end`, `margin-top: 1rem` → `.mt-1`) are warnings.
|
|
419
|
+
|
|
420
|
+
`kmwc check-pages` is the browser half: it loads every example page and reports
|
|
421
|
+
what doctor can't see in the source — a script error, a 404, a fixed width that
|
|
422
|
+
pushes a phone sideways.
|
|
392
423
|
|
|
393
424
|
**Package contents.** Doctor runs `npm pack --dry-run` and fails on anything
|
|
394
425
|
dev-only that would reach a consumer's `node_modules`: `CLAUDE.md` at any
|
|
@@ -451,7 +482,11 @@ registry edits), and move the component's own code into that layout with
|
|
|
451
482
|
runner from the old Makefile, the canonical origin from the old
|
|
452
483
|
`examples-copy-link.js`, and internals from the old
|
|
453
484
|
`check-variable-manifest.mjs` (`cssVariables.internalVariables`).
|
|
454
|
-
3.
|
|
485
|
+
3. Run `npx kmwc doctor` **before** replacing `vite.config.ts`: it compares the
|
|
486
|
+
old `lib.name` with `kmwc.library.globalName` and package.json's `dist/`
|
|
487
|
+
targets with the preset's outputs — set `library.globalName` / `fileName` /
|
|
488
|
+
`cssFileName` to the published names so nothing gets renamed. Then replace
|
|
489
|
+
`vite.config.ts` (and any `vite.examples.config.ts`) with
|
|
455
490
|
`defineComponentConfig()`. Move genuinely specific bits to `plugins` and
|
|
456
491
|
`overrides`. Do the same for `playwright.config.ts`.
|
|
457
492
|
4. Pages: delete the `<link href="examples-shared.css">` and the
|
|
@@ -468,10 +503,11 @@ registry edits), and move the component's own code into that layout with
|
|
|
468
503
|
7. Set `"check:vars": "kmwc check-vars"` and run it.
|
|
469
504
|
8. `npx kmwc sync`, then review the diff. Repo-specific Makefile targets move
|
|
470
505
|
to `Makefile.local`.
|
|
471
|
-
9. Check it runs: `npm run dev`, `npm run build
|
|
472
|
-
`npm run preview:examples`, the e2e tests,
|
|
473
|
-
|
|
474
|
-
|
|
506
|
+
9. Check it runs: `npm run dev`, `npm run build` (compare `dist/` with the old
|
|
507
|
+
build), `npm run build:examples`, `npm run preview:examples`, the e2e tests,
|
|
508
|
+
`npx kmwc check-pages` (and `--url` on the preview port for the built site),
|
|
509
|
+
and finally `npx kmwc doctor` (devkit + rulebook — rulebook failures are real
|
|
510
|
+
findings to fix, or architectural decisions to record in `VALIDATION-NOTES.md`).
|
|
475
511
|
|
|
476
512
|
### Developing the devkit
|
|
477
513
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keenmate/web-components-devkit",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-rc07",
|
|
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",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kmwc check-pages — load every example page in a real browser and report what
|
|
3
|
+
* a reader would hit: page errors, console errors, failed requests, custom
|
|
4
|
+
* elements that never get defined, and a page wider than the viewport (a fixed
|
|
5
|
+
* width or a long word that pushes a phone sideways).
|
|
6
|
+
*
|
|
7
|
+
* Runs against the repo's own dev server (started here on kmwc.ports.dev), or a
|
|
8
|
+
* server you already have (--url, e.g. the built site on the preview port).
|
|
9
|
+
* Widths default to desktop + two phones. Uses the component's own
|
|
10
|
+
* @playwright/test (see getPeerModule) — `npx playwright install chromium` once.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { getKmwcConfig, getPeerModule, loadPeerModule } from '../../config.js';
|
|
15
|
+
import { getExamplePages } from '../../vite/index.js';
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_WIDTHS = [1440, 390, 320];
|
|
18
|
+
const VIEWPORT_HEIGHT = 900;
|
|
19
|
+
const SETTLE_MS = 600;
|
|
20
|
+
const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.mts', 'vite.config.mjs', 'vite.config.js'];
|
|
21
|
+
const MAX_OFFENDERS = 3;
|
|
22
|
+
// Requests a page doesn't control (favicon probes, analytics in a built site).
|
|
23
|
+
const IGNORED_REQUEST_PATTERN = /favicon\.ico|stats\.keenmate\.services|plausible/;
|
|
24
|
+
|
|
25
|
+
/** `--widths 1440,390` → [1440, 390]. Pure. */
|
|
26
|
+
export function parseWidths(widthsText) {
|
|
27
|
+
if (!widthsText) return DEFAULT_WIDTHS;
|
|
28
|
+
const widths = String(widthsText).split(',').map((width) => Number(width.trim()));
|
|
29
|
+
if (!widths.length || widths.some((width) => !Number.isInteger(width) || width < 200)) throw new Error(`--widths: "${widthsText}" is not a list of pixel widths ≥ 200`);
|
|
30
|
+
return widths;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Problems in one page load's report → [string]. Pure — exported for tests.
|
|
35
|
+
* @param {{ width: number, errors: string[], undefinedElements: string[], scrollWidth: number, clientWidth: number, offenders: string[] }} pageReport
|
|
36
|
+
*/
|
|
37
|
+
export function getPageProblems(pageReport) {
|
|
38
|
+
const problems = [...pageReport.errors];
|
|
39
|
+
if (pageReport.undefinedElements.length) problems.push(`never defined: <${pageReport.undefinedElements.join('>, <')}>`);
|
|
40
|
+
if (pageReport.scrollWidth > pageReport.clientWidth) {
|
|
41
|
+
const offenderText = pageReport.offenders.length ? ` — widest: ${pageReport.offenders.join(', ')}` : '';
|
|
42
|
+
problems.push(`page is ${pageReport.scrollWidth}px wide in a ${pageReport.clientWidth}px viewport${offenderText}`);
|
|
43
|
+
}
|
|
44
|
+
return problems;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runs in the page: overflow, undefined custom elements. A culprit is the deepest
|
|
49
|
+
* element — or the text — past the right edge, outside anything that scrolls or
|
|
50
|
+
* clips on purpose (a code block, a table wrapper) and outside hidden or fixed
|
|
51
|
+
* layers (a closed nav panel, a toast), which don't widen the page.
|
|
52
|
+
*/
|
|
53
|
+
function inspectPage(maxOffenders) {
|
|
54
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
55
|
+
// Shadow roots count: a component's own internals can widen the page too.
|
|
56
|
+
const getParent = (element) => element.parentElement || element.getRootNode().host || null;
|
|
57
|
+
const describeOne = (element) => `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ''}${[...element.classList].slice(0, 2).map((name) => `.${name}`).join('')}`;
|
|
58
|
+
const describe = (element) => {
|
|
59
|
+
const host = element.getRootNode().host;
|
|
60
|
+
return host ? `${describe(host)} › ${describeOne(element)}` : describeOne(element);
|
|
61
|
+
};
|
|
62
|
+
const getAllElements = (root) => [...root.querySelectorAll('*')].flatMap((element) => (element.shadowRoot ? [element, ...getAllElements(element.shadowRoot)] : [element]));
|
|
63
|
+
const checkIsContained = (element) => {
|
|
64
|
+
for (let current = element; current && current !== document.body; current = getParent(current)) {
|
|
65
|
+
const style = getComputedStyle(current);
|
|
66
|
+
if (style.position === 'fixed' || style.visibility === 'hidden' || style.display === 'none') return true;
|
|
67
|
+
if (current !== element && ['auto', 'scroll', 'hidden', 'clip'].includes(style.overflowX)) return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
const allElements = getAllElements(document.body);
|
|
72
|
+
const pastEdge = allElements.filter((element) => {
|
|
73
|
+
const box = element.getBoundingClientRect();
|
|
74
|
+
return box.width > 0 && box.right > viewportWidth + 1 && !checkIsContained(element);
|
|
75
|
+
});
|
|
76
|
+
const checkContainsDeep = (element, other) => {
|
|
77
|
+
for (let current = other; current; current = getParent(current)) if (current === element) return true;
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
const offenders = pastEdge.filter((element) => !pastEdge.some((other) => other !== element && checkContainsDeep(element, other))).map(describe);
|
|
81
|
+
// Text past the edge inside a box that fits: a long word with no break opportunity.
|
|
82
|
+
const textRoots = [document.body, ...allElements.filter((element) => element.shadowRoot).map((element) => element.shadowRoot)];
|
|
83
|
+
const textNodes = textRoots.flatMap((root) => {
|
|
84
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
85
|
+
const nodes = [];
|
|
86
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node);
|
|
87
|
+
return nodes;
|
|
88
|
+
});
|
|
89
|
+
for (const textNode of textNodes) {
|
|
90
|
+
const parent = textNode.parentElement;
|
|
91
|
+
if (!textNode.textContent.trim() || !parent || pastEdge.includes(parent)) continue;
|
|
92
|
+
const range = document.createRange();
|
|
93
|
+
range.selectNodeContents(textNode);
|
|
94
|
+
const box = range.getBoundingClientRect();
|
|
95
|
+
const parentStyle = getComputedStyle(parent);
|
|
96
|
+
if (box.right > viewportWidth + 1 && !['hidden', 'clip', 'auto', 'scroll'].includes(parentStyle.overflowX) && !checkIsContained(parent)) {
|
|
97
|
+
offenders.push(`${describe(parent)} "${textNode.textContent.trim().slice(0, 40)}"`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const undefinedElements = [...new Set([...document.querySelectorAll(':not(:defined)')].map((element) => element.localName).filter((name) => name.includes('-')))];
|
|
101
|
+
return {
|
|
102
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
103
|
+
clientWidth: viewportWidth,
|
|
104
|
+
offenders: [...new Set(offenders)].slice(0, maxOffenders),
|
|
105
|
+
undefinedElements,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function loadPageReport(browser, pageUrl, width) {
|
|
110
|
+
const page = await browser.newPage({ viewport: { width, height: VIEWPORT_HEIGHT } });
|
|
111
|
+
const errors = [];
|
|
112
|
+
page.on('pageerror', (error) => errors.push(`page error: ${error.message.split('\n')[0]}`));
|
|
113
|
+
page.on('console', (message) => {
|
|
114
|
+
if (message.type() === 'error') errors.push(`console error: ${message.text().split('\n')[0]}`);
|
|
115
|
+
});
|
|
116
|
+
page.on('response', (response) => {
|
|
117
|
+
if (response.status() >= 400 && !IGNORED_REQUEST_PATTERN.test(response.url())) errors.push(`HTTP ${response.status()}: ${response.url()}`);
|
|
118
|
+
});
|
|
119
|
+
page.on('requestfailed', (request) => {
|
|
120
|
+
if (!IGNORED_REQUEST_PATTERN.test(request.url())) errors.push(`request failed: ${request.url()} (${request.failure()?.errorText})`);
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
124
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
125
|
+
const inspection = await page.evaluate(inspectPage, MAX_OFFENDERS);
|
|
126
|
+
return { width, errors: [...new Set(errors)], ...inspection };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { width, errors: [...new Set([...errors, `load failed: ${error.message.split('\n')[0]}`])], scrollWidth: 0, clientWidth: 0, offenders: [], undefinedElements: [] };
|
|
129
|
+
} finally {
|
|
130
|
+
await page.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function startDevServer(config) {
|
|
135
|
+
const viteModule = await loadPeerModule(config.root, 'vite');
|
|
136
|
+
// Vite < 6 resolves to its CJS build here: the API sits on `default`.
|
|
137
|
+
const createServer = viteModule.createServer || viteModule.default?.createServer;
|
|
138
|
+
// Run like `npm run dev` in the repo: the preset reads package.json#kmwc from the cwd,
|
|
139
|
+
// and Vite looks for vite.config there too.
|
|
140
|
+
process.chdir(config.root);
|
|
141
|
+
const configFile = VITE_CONFIG_FILES.map((fileName) => join(config.root, fileName)).find((filePath) => existsSync(filePath));
|
|
142
|
+
const server = await createServer({ root: config.root, configFile: configFile || false, logLevel: 'error', server: { port: config.ports.dev, strictPort: true } });
|
|
143
|
+
try {
|
|
144
|
+
await server.listen();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await server.close();
|
|
147
|
+
throw new Error(
|
|
148
|
+
`port ${config.ports.dev} is busy (${error.code || error.message}). If that is this repo's dev server, run: kmwc check-pages --url http://localhost:${config.ports.dev}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return server;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function run(args) {
|
|
155
|
+
const config = getKmwcConfig(args.root);
|
|
156
|
+
const widths = parseWidths(args.widths);
|
|
157
|
+
const pageFiles = Object.values(getExamplePages(config)).map((pagePath) => pagePath.replace(/\\/g, '/').split('/').pop());
|
|
158
|
+
if (!pageFiles.length) {
|
|
159
|
+
console.log('kmwc check-pages: no example pages — nothing to check.');
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
let chromium;
|
|
163
|
+
try {
|
|
164
|
+
({ chromium } = getPeerModule(config.root, '@playwright/test'));
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error('@playwright/test is not installed in this repo — npm i -D @playwright/test && npx playwright install chromium');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const server = args.url ? null : await startDevServer(config);
|
|
170
|
+
const baseUrl = (args.url || `http://localhost:${config.ports.dev}`).replace(/\/$/, '');
|
|
171
|
+
const browser = await chromium.launch();
|
|
172
|
+
let problemCount = 0;
|
|
173
|
+
try {
|
|
174
|
+
console.log(`kmwc check-pages — ${pageFiles.length} page(s) × ${widths.join(' / ')}px on ${baseUrl}\n`);
|
|
175
|
+
for (const pageFile of pageFiles) {
|
|
176
|
+
const pageProblems = [];
|
|
177
|
+
for (const width of widths) {
|
|
178
|
+
for (const problem of getPageProblems(await loadPageReport(browser, `${baseUrl}/${pageFile}`, width))) pageProblems.push(`@${width}px ${problem}`);
|
|
179
|
+
}
|
|
180
|
+
problemCount += pageProblems.length;
|
|
181
|
+
console.log(`${pageProblems.length ? '✗' : '✓'} ${pageFile}${pageProblems.map((problem) => `\n ${problem}`).join('')}`);
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
await browser.close();
|
|
185
|
+
if (server) await server.close();
|
|
186
|
+
}
|
|
187
|
+
console.log(`\n${problemCount ? '✗' : '✓'} ${problemCount} problem(s)`);
|
|
188
|
+
return problemCount ? 1 : 0;
|
|
189
|
+
}
|
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
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));
|
|
@@ -81,7 +93,7 @@ export async function run(args) {
|
|
|
81
93
|
const prefix = config.prefix || manifest.prefix;
|
|
82
94
|
if (!prefix) throw new Error('no variable prefix — set kmwc.prefix or "prefix" in the manifest');
|
|
83
95
|
|
|
84
|
-
const cssText = config.cssVariables.sources.flatMap((sourcePath) => getStylesheetTexts(
|
|
96
|
+
const cssText = config.cssVariables.sources.flatMap((sourcePath) => getStylesheetTexts(resolveSourcePath(config.root, sourcePath), [])).join('\n');
|
|
85
97
|
const verdict = checkManifestVariables({
|
|
86
98
|
cssText,
|
|
87
99
|
manifest,
|
|
@@ -90,7 +102,7 @@ export async function run(args) {
|
|
|
90
102
|
checkMode: config.cssVariables.checkMode,
|
|
91
103
|
});
|
|
92
104
|
|
|
93
|
-
const presenceWord = config.cssVariables.checkMode === 'consumed' ? 'consumed via var()' : 'declared';
|
|
105
|
+
const presenceWord = config.cssVariables.checkMode === 'consumed' ? 'consumed via var()' : 'declared or read';
|
|
94
106
|
const renderSection = (title, names) => `${title} (${names.length}):\n` + names.map((name) => ` --${name}`).join('\n');
|
|
95
107
|
const errors = [];
|
|
96
108
|
const warnings = [];
|
|
@@ -19,8 +19,25 @@ 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'];
|
|
@@ -114,6 +131,11 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
114
131
|
if (!viteConfigFile) addError('vite', 'no vite.config.*', 'create one: export default defineComponentConfig()');
|
|
115
132
|
else if (!/defineComponentConfig|createExamplesPlugin/.test(readFileSync(join(config.root, viteConfigFile), 'utf8'))) {
|
|
116
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
|
+
}
|
|
117
139
|
} else addPass('vite', `${viteConfigFile} uses the devkit preset`);
|
|
118
140
|
|
|
119
141
|
const playwrightConfigFile = getFirstExistingFile(config.root, PLAYWRIGHT_CONFIG_FILES);
|
|
@@ -160,6 +182,21 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
160
182
|
);
|
|
161
183
|
}
|
|
162
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
|
+
|
|
163
200
|
// ── generated files ──────────────────────────────────────────────────────
|
|
164
201
|
if (config.hasPackageConfig) {
|
|
165
202
|
for (const planItem of getSyncPlan(config)) {
|
|
@@ -195,12 +232,37 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
195
232
|
`set package.json#kmwc.ports to { "dev": ${registryPorts.dev}, "image": ${registryPorts.image} } (components.json port block ${registryEntry.port})`
|
|
196
233
|
);
|
|
197
234
|
} else addPass('registry', `ports match the registry (dev ${config.ports.dev}, image ${config.ports.image})`);
|
|
235
|
+
checkPorts(config, registryEntry.port, { addPass, addWarning, addError });
|
|
198
236
|
}
|
|
199
237
|
if (registryEntry.prefix && config.prefix && registryEntry.prefix !== config.prefix) {
|
|
200
238
|
addError('registry', `kmwc.prefix "${config.prefix}" ≠ registry prefix "${registryEntry.prefix}"`, 'use the reserved prefix');
|
|
201
239
|
}
|
|
202
240
|
}
|
|
203
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
|
+
|
|
204
266
|
// ── package contents ─────────────────────────────────────────────────────
|
|
205
267
|
if (doctorConfig.shouldCheckPackage !== false) checkPackageContents(config, { addPass, addWarning, addError });
|
|
206
268
|
|
|
@@ -210,6 +272,33 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
210
272
|
return results;
|
|
211
273
|
}
|
|
212
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
|
+
|
|
213
302
|
/** Packed paths that break PACKAGE_LEAK_RULES → [{ path, label }]. Pure — exported for tests. */
|
|
214
303
|
export function getPackageLeaks(packedPaths) {
|
|
215
304
|
return packedPaths
|
|
@@ -260,6 +349,7 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
|
|
|
260
349
|
return;
|
|
261
350
|
}
|
|
262
351
|
const skippedSuites = new Set(config.rulebook.skippedChecks);
|
|
352
|
+
const acceptedCheckIds = loadAcceptedCheckIds(config.root);
|
|
263
353
|
const suites = getRulebookSuites(config.kind);
|
|
264
354
|
if (suites.length < RULEBOOK_SUITES.length) {
|
|
265
355
|
const componentOnlySuiteNames = RULEBOOK_SUITES.filter((suite) => !suites.includes(suite)).map((suite) => suite.name);
|
|
@@ -280,7 +370,11 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
|
|
|
280
370
|
continue;
|
|
281
371
|
}
|
|
282
372
|
const failures = getSuiteFailures(suiteResult.stdout);
|
|
283
|
-
for (const failure of failures)
|
|
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
|
+
}
|
|
284
378
|
if (!failures.length) addPass(checkName, suiteResult.status === 0 ? 'all auto checks pass' : `exit ${suiteResult.status}`);
|
|
285
379
|
}
|
|
286
380
|
}
|
|
@@ -302,6 +396,8 @@ export async function run(args) {
|
|
|
302
396
|
if (result.level === 'pass' && !args.verbose) continue;
|
|
303
397
|
console.log(`${LEVEL_ICONS[result.level]} [${result.checkName}] ${result.message}${result.fixHint ? `\n → ${result.fixHint}` : ''}`);
|
|
304
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)`);
|
|
305
401
|
console.log(`\n${errorCount ? '✗' : '✓'} ${errorCount} error(s), ${warningCount} warning(s)${args.verbose ? '' : ' (--verbose lists passing checks)'}`);
|
|
306
402
|
return errorCount ? 1 : 0;
|
|
307
403
|
}
|
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,249 @@
|
|
|
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
|
+
}
|
package/src/cli/sync-plan.js
CHANGED
|
@@ -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;
|
|
@@ -157,8 +157,11 @@
|
|
|
157
157
|
background-color: var(--kmwc-accent-light-bg-hover);
|
|
158
158
|
color: var(--kmwc-accent-color);
|
|
159
159
|
font-weight: 600;
|
|
160
|
-
padding:
|
|
160
|
+
/* No padding: a match inside a word ("Town|send") must not split it. */
|
|
161
|
+
padding: 0;
|
|
161
162
|
border-radius: var(--kmwc-radius-sm);
|
|
163
|
+
box-decoration-break: clone;
|
|
164
|
+
-webkit-box-decoration-break: clone;
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
/* ==========================================================================
|
package/src/vite/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readdirSync, existsSync, cpSync } from 'node:fs';
|
|
|
20
20
|
import { join, resolve } from 'node:path';
|
|
21
21
|
import { getKmwcConfig, getDevkitPackageJson, generateBuildDefines, loadPeerModule, DEVKIT_ROOT, NON_PAGE_HTML_FILES } from '../config.js';
|
|
22
22
|
import { renderThemeBootstrapScript } from '../examples/theme-switch.js';
|
|
23
|
-
import { composePage } from './page-composer.js';
|
|
23
|
+
import { composePage, insertIntoHead, insertBeforeBodyEnd } from './page-composer.js';
|
|
24
24
|
import { generateChapterIndex } from './chapter-index.js';
|
|
25
25
|
|
|
26
26
|
const AUTO_ENTRY_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'auto.js');
|
|
@@ -162,11 +162,7 @@ export function createExamplesPlugin(pluginConfig = {}) {
|
|
|
162
162
|
const kitImports = isBuild ? `import '${KIT_STYLESHEET_PATH}'; import '${VIRTUAL_AUTO_ID}';` : `import '${VIRTUAL_AUTO_ID}';`;
|
|
163
163
|
const bodyHtml = `<script type="module">${kitImports}</script>\n`;
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
? html.replace(/<head[^>]*>/i, (headTag) => `${headTag}\n${headHtml}`)
|
|
167
|
-
: headHtml + html;
|
|
168
|
-
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${bodyHtml}</body>`) : html + bodyHtml;
|
|
169
|
-
return html;
|
|
165
|
+
return insertBeforeBodyEnd(insertIntoHead(html, headHtml), bodyHtml);
|
|
170
166
|
},
|
|
171
167
|
},
|
|
172
168
|
|
|
@@ -64,7 +64,36 @@ export function mapOutsideComments(html, mapper, skippedPattern = HTML_COMMENT_P
|
|
|
64
64
|
return mappedHtml + mapper(html.slice(lastIndex));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// The real tag only — `<head[^>]*>` also matches `<header>`.
|
|
68
|
+
const HEAD_TAG_PATTERN = /<head(?=[\s>])[^>]*>/i;
|
|
69
|
+
const BODY_END_TAG = '</body>';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Insert `headHtml` right after the page's <head> tag (prepend when there is none).
|
|
73
|
+
* The first <head> outside comments is the real one: the text "<head>" in a tooltip
|
|
74
|
+
* or a script comment comes later, and a snippet landing there breaks the page
|
|
75
|
+
* (an HTML comment inside a module script throws).
|
|
76
|
+
*/
|
|
77
|
+
export function insertIntoHead(html, headHtml) {
|
|
78
|
+
let isInserted = false;
|
|
79
|
+
const insertedHtml = mapOutsideComments(html, (text) => {
|
|
80
|
+
if (isInserted || !HEAD_TAG_PATTERN.test(text)) return text;
|
|
81
|
+
isInserted = true;
|
|
82
|
+
return text.replace(HEAD_TAG_PATTERN, (headTag) => `${headTag}\n${headHtml}`);
|
|
83
|
+
});
|
|
84
|
+
return isInserted ? insertedHtml : headHtml + html;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Insert `bodyHtml` before the LAST </body> (append when there is none): an
|
|
89
|
+
* earlier one is text — e.g. a comment inside an inline script.
|
|
90
|
+
*/
|
|
91
|
+
export function insertBeforeBodyEnd(html, bodyHtml) {
|
|
92
|
+
const bodyEndIndex = html.toLowerCase().lastIndexOf(BODY_END_TAG);
|
|
93
|
+
return bodyEndIndex < 0 ? html + bodyHtml : html.slice(0, bodyEndIndex) + bodyHtml + html.slice(bodyEndIndex);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const mapToCamelCase =(kebabName) => kebabName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
68
97
|
|
|
69
98
|
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
70
99
|
const mapToEscapedHtml = (text) => String(text).replace(/[&<>"]/g, (character) => HTML_ESCAPES[character]);
|
package/templates/Makefile.tmpl
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# redefined there wins, with a make "overriding recipe" warning). Per-developer
|
|
5
5
|
# overrides (container runner, image name, port) go in .makefile.env.
|
|
6
6
|
|
|
7
|
-
.PHONY: help setup dev kill-port build build-examples preview-examples package publish-dry publish publish-rc clean check-version check-vars doctor sync test test-e2e test-e2e-ui test-e2e-headed test-e2e-install image-build image-run image-stop image-clean
|
|
7
|
+
.PHONY: help setup dev kill-port build build-examples preview-examples package publish-dry publish publish-rc clean check-version check-vars doctor check-pages sync test test-e2e test-e2e-ui test-e2e-headed test-e2e-install image-build image-run image-stop image-clean
|
|
8
8
|
|
|
9
9
|
-include .makefile.env
|
|
10
10
|
DOCKER_RUNNER ?= {{RUNNER}}
|
|
@@ -91,6 +91,9 @@ check-vars: ## Validate component-variables.manifest.json against the CSS
|
|
|
91
91
|
doctor: ## Check this repo against the Keenmate component standard
|
|
92
92
|
npx kmwc doctor
|
|
93
93
|
|
|
94
|
+
check-pages: ## Load every example page at 1440/390/320px: errors, undefined elements, overflow
|
|
95
|
+
npx kmwc check-pages
|
|
96
|
+
|
|
94
97
|
sync: ## Regenerate devkit-owned files (Dockerfile, nginx.conf, Makefile, …)
|
|
95
98
|
npx kmwc sync
|
|
96
99
|
|
|
@@ -48,4 +48,7 @@ locally — every local fork is how they drifted apart.
|
|
|
48
48
|
otherwise published with it).
|
|
49
49
|
- Before finishing: `npx kmwc doctor` must pass — it runs the devkit checks,
|
|
50
50
|
checks the `npm pack` file list for dev-only files, and runs the rulebook's
|
|
51
|
-
`*.checks.sh` suites.
|
|
51
|
+
`*.checks.sh` suites. A rulebook failure that is a real architectural decision
|
|
52
|
+
goes into `VALIDATION-NOTES.md` under a `## C-XX-N — …` heading (deferrals
|
|
53
|
+
don't count). After changing a page, `npx kmwc check-pages` loads every page at
|
|
54
|
+
desktop and phone widths and reports errors and horizontal overflow.
|