@keenmate/web-components-devkit 1.0.0-rc09 → 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 +77 -0
- package/README.md +49 -14
- package/package.json +2 -1
- package/src/cli/commands/check-pages.js +8 -5
- package/src/cli/commands/check-vars.js +18 -11
- package/src/cli/commands/doctor.js +191 -44
- package/src/cli/repo-checks.js +34 -4
- package/src/config.d.ts +14 -0
- package/src/config.js +20 -1
- package/src/examples/constants.js +6 -0
- package/src/examples/css/page-footer.css +19 -0
- package/src/examples/css/style.css +1 -0
- package/src/examples/css/tokens.css +3 -2
- package/src/examples/index-page.js +2 -11
- package/src/examples/index.d.ts +6 -1
- package/src/examples/index.js +1 -0
- package/src/examples/kit.js +5 -3
- package/src/examples/page-footer.js +44 -0
- package/src/vite/page-composer.js +7 -2
- package/templates/claude-block.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
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
|
+
|
|
26
|
+
## [1.0.0-rc10] - 2026-11-05 [PUBLISHED]
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Doctor runs the variable-manifest check.** `kmwc check-vars` only ran inside `npm run build`, so a
|
|
31
|
+
red manifest went unseen until a build: web-treeview's `check:vars` failed on core's
|
|
32
|
+
non-contract `--base-accent-color` while doctor passed. Doctor now reports the same verdict
|
|
33
|
+
(`vars`): missing / dead `--base-*` and dead `--<prefix>-*` entries are errors, undocumented
|
|
34
|
+
`--<prefix>-*` a warning. `loadManifestVerdict()` is shared by both.
|
|
35
|
+
- **Opt-outs for markup that is right on purpose:**
|
|
36
|
+
- an element whose own `style="…"` sets fixed colours carries `data-kmwc-fixed-colors` (doctor's
|
|
37
|
+
colour check). Markup a render callback draws inside a shadow root can't be styled by page CSS,
|
|
38
|
+
so inline is the only way there (web-treeview's context-menu role chips);
|
|
39
|
+
- `data-kmwc-undefined` on an element a demo leaves undefined on purpose — check-pages no longer
|
|
40
|
+
reports it (core's PG01 registers its element on a button press). The "never defined" problem
|
|
41
|
+
names the attribute;
|
|
42
|
+
- `<!-- kmwc-other-ports: why -->` in a doc that names other repos' ports on purpose (a dated
|
|
43
|
+
plan, a cross-repo review) — doctor's port scan skips it.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **Doctor: the loader check tells a collision from a rename.** On a core that still draws
|
|
48
|
+
`<prefix>-loader`, CSS that styles both classes has its own element called `<prefix>__loader`
|
|
49
|
+
(web-multiselect's loading-state row). That was reported as a rename done too early (error); it is
|
|
50
|
+
now a warning that core's loader takes the name — and those rules — with the bump, so rename the
|
|
51
|
+
component's element in the same change.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **Fragment pages: `<link>` / `<style>` text inside an attribute value stayed put.** The composer
|
|
56
|
+
moved a `<link>` it found in a tooltip (`title="…the <link> in <head>"`) into `<head>` as an empty
|
|
57
|
+
element, and the tooltip lost its text (web-multiselect). It now reads whole start tags, quoted
|
|
58
|
+
attribute values included, and moves only real `<link>` / `<style>` elements.
|
|
59
|
+
- **Inline code in light mode** read at ~3.3:1 — the raw accent on the pale chip. The text keeps the
|
|
60
|
+
accent hue at most 0.5 lightness (≥ 4.5:1 for every accent the spec tries, light and dark).
|
|
61
|
+
- **Doctor: a rulebook suite that never ran counted as a pass.** Only exit 2 was read as "could
|
|
62
|
+
not run"; any other exit without `FAIL` lines passed. On Windows a bare `bash` can be WSL's, which
|
|
63
|
+
can't read `C:/…` paths — every suite exited 127 and doctor reported the rulebook green (found when
|
|
64
|
+
`make publish-rc` ran the tests from PowerShell). Doctor now runs Git for Windows' bash (from PATH
|
|
65
|
+
or the install root; `KMWC_BASH` overrides) and warns on any exit but 0, or 1 with `FAIL` lines.
|
|
66
|
+
|
|
67
|
+
### Internal
|
|
68
|
+
|
|
69
|
+
- **Tests on the critical paths.** Doctor end to end on fixture repos (a healthy repo is clean, each
|
|
70
|
+
broken one gets its finding; `doctor.js` 26% → 98% of lines); a real `vite build` of the playground
|
|
71
|
+
must emit exactly what doctor checks package.json against, with the UMD global and every export
|
|
72
|
+
`index.d.ts` declares; `kmwc new` (full and `--minimal`) scaffolds a repo doctor finds clean;
|
|
73
|
+
`kmwc sync` rewrites only what it owns and `--check` writes nothing; `check-release` keeps an rc off
|
|
74
|
+
`latest`. Composer cases for single-quoted values, `<style>` text and a `>` inside an attribute.
|
|
75
|
+
- **`npm run test:e2e:coverage`** (maintainer) — the dev-server e2e run with line coverage of the
|
|
76
|
+
kit's browser code (`src/examples/*.js`, 84%), snapshot before every navigation.
|
|
77
|
+
- **README:** a sentence about `data-kmwc-undefined` had landed inside the rc09 What's New bullet;
|
|
78
|
+
it's back in the check-pages docs.
|
|
79
|
+
|
|
3
80
|
## [1.0.0-rc09] - 2026-11-04 [PUBLISHED]
|
|
4
81
|
|
|
5
82
|
### Added
|
package/README.md
CHANGED
|
@@ -19,15 +19,19 @@ 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-
|
|
22
|
+
## What's New in v1.0.0-rc11
|
|
23
23
|
|
|
24
|
-
- **
|
|
25
|
-
- **Examples kit —
|
|
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
26
|
|
|
27
|
-
## What's New in v1.0.0-
|
|
27
|
+
## What's New in v1.0.0-rc10
|
|
28
28
|
|
|
29
|
-
- **Doctor —
|
|
30
|
-
- **Doctor —
|
|
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.
|
|
30
|
+
- **Doctor and check-pages — opt-outs for markup that is right on purpose** — The migrations of web-treeview, web-multiselect and web-components-core hit three findings that could never pass. Markup a render callback draws inside a shadow root can only be coloured inline, so an element whose `style="…"` sets fixed colours on purpose now carries `data-kmwc-fixed-colors` (treeview's context-menu role chips). An element a demo leaves undefined on purpose carries `data-kmwc-undefined`, and check-pages skips it (core's PG01 registers its element on a button press); the "never defined" message names the attribute. A doc that names other repos' ports on purpose, such as a dated plan or a cross-repo review, carries `<!-- kmwc-other-ports: why -->`, and doctor's port scan skips it.
|
|
31
|
+
- **Doctor — the loader check tells a collision from a rename** — web-multiselect has its own loading-state element called `.ms__loader`, next to core rc17's `.ms-loader`. Doctor read it as a rename done before the core bump and reported an error. On a core that still draws `<prefix>-loader`, CSS that styles both classes now gets a warning instead: from core 6d6b768 on, core's default loader takes that class name and picks up the component's rules. The fix is to rename the component's own element in the same change as the core bump.
|
|
32
|
+
- **Fragment pages — text inside attribute values stays put** — The page composer moves a page's `<link>` and `<style>` elements into `<head>`, but it also matched them inside attribute values. web-multiselect's tooltip `title="…the <link> in <head>"` turned into an empty `<link>` in the head, and the tooltip lost its text; a `<style>` written inside an attribute would even have been applied to the whole page. The composer now reads whole start tags, quoted values included, and moves only real elements.
|
|
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.
|
|
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.
|
|
31
35
|
|
|
32
36
|
## Catalogue
|
|
33
37
|
|
|
@@ -36,11 +40,11 @@ them in the same shape: `@keenmate/web-components-devkit`.
|
|
|
36
40
|
|
|
37
41
|
| Component | Package | Prefix | Dev port | Links | Devkit | About |
|
|
38
42
|
|---|---|---|---:|---|:---:|---|
|
|
39
|
-
| **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` |
|
|
43
|
+
| **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… |
|
|
40
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 |
|
|
41
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… |
|
|
42
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,… |
|
|
43
|
-
| **web-dropzone** | `@keenmate/web-dropzone` | `dz` | 12500 | [repo](https://github.com/keenmate/web-dropzone) |
|
|
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. |
|
|
44
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 |
|
|
45
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. |
|
|
46
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… |
|
|
@@ -102,7 +106,7 @@ in `package.json#kmwc`, and gets the rest.
|
|
|
102
106
|
| Piece | Import / command | Replaces |
|
|
103
107
|
|---|---|---|
|
|
104
108
|
| Vite preset | `@keenmate/web-components-devkit/vite` → `defineComponentConfig()` | per-repo `vite.config.ts`, `vite.examples.config.ts` |
|
|
105
|
-
| 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` |
|
|
106
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 |
|
|
107
111
|
| Examples helpers | `@keenmate/web-components-devkit/examples` → `attachControlsPersistence`, `renderExamplesIndex`, `mountExamplesKit` | `examples-controls-store.js`, 150 lines of inline index-page CSS |
|
|
108
112
|
| Playwright preset | `@keenmate/web-components-devkit/playwright` → `definePlaywrightConfig()` | per-repo `playwright.config.ts` |
|
|
@@ -175,7 +179,11 @@ Every field is optional. Defaults are derived from the package.
|
|
|
175
179
|
// BlissFramework checkout above it. env KMWC_RULEBOOK_PATH wins
|
|
176
180
|
"skippedChecks": [] // e.g. ["responsive-overlay"]
|
|
177
181
|
},
|
|
178
|
-
"sync": { "skippedFiles": [] }
|
|
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
|
+
]
|
|
179
187
|
}
|
|
180
188
|
```
|
|
181
189
|
|
|
@@ -189,6 +197,16 @@ manifest check (no `check:vars` script, `kmwc check-vars` skips), for variables
|
|
|
189
197
|
the checker can't see, such as names built by string interpolation. Doctor runs
|
|
190
198
|
only the rulebook suites that apply to a library (see below).
|
|
191
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
|
+
|
|
192
210
|
### Vite preset
|
|
193
211
|
|
|
194
212
|
```ts
|
|
@@ -332,6 +350,9 @@ doctor` lists the ones still repeating the head/header boilerplate.
|
|
|
332
350
|
shows `v1.2.3`, or `v1.2.3 (built …)` in a built site.
|
|
333
351
|
- **Landing page**: `renderExamplesIndex({ pages })` renders the header, cards,
|
|
334
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`).
|
|
335
356
|
- **Late content**: the kit mounts one task after DOM-ready. Pages that render
|
|
336
357
|
sections after a fetch call `mountExamplesKit()`.
|
|
337
358
|
- **Analytics**: the Plausible snippet goes into **built** pages only, and only
|
|
@@ -385,6 +406,9 @@ and the e2e suite before building and publishing.
|
|
|
385
406
|
base-variables, color-scheme, readme-structure, custom-elements-manifest,
|
|
386
407
|
responsive-overlay, publish-command, example-pages) with `bash`, passing `kmwc.prefix`, and
|
|
387
408
|
reports each `FAIL` as an error pointing at the matching `.checks.md` entry.
|
|
409
|
+
On Windows that is Git for Windows' bash (a bare `bash` may be WSL's, which can't
|
|
410
|
+
read `C:/` paths); `KMWC_BASH` overrides it. A suite that exits with anything but 0,
|
|
411
|
+
or 1 with `FAIL` lines, never ran — a warning, never a pass.
|
|
388
412
|
A `library` runs only naming-conventions, readme-structure, publish-command
|
|
389
413
|
and example-pages; the others check what a custom element ships (its CSS,
|
|
390
414
|
manifest, overlay).
|
|
@@ -405,7 +429,9 @@ generated files:
|
|
|
405
429
|
silently.
|
|
406
430
|
- **Ports** — no script pins a `--port` of its own, the repo's configs, Makefile
|
|
407
431
|
and docs stay inside its registry block, and no sibling checkout (a registry
|
|
408
|
-
entry at `../<id>`) listens on a port inside it.
|
|
432
|
+
entry at `../<id>`) listens on a port inside it. A doc that names other repos'
|
|
433
|
+
ports on purpose (a dated plan, a cross-repo review) carries
|
|
434
|
+
`<!-- kmwc-other-ports: why -->` and isn't scanned.
|
|
409
435
|
- **Analytics** — no other repo uses the same Plausible site id.
|
|
410
436
|
- **Page CSS** — a page `<style>` or `examples-local.css` rule on a kit-owned
|
|
411
437
|
selector (`.kbd`, `kbd`, `mark`, `.pill`, `.card` …) and single-declaration
|
|
@@ -415,12 +441,19 @@ generated files:
|
|
|
415
441
|
it stays put when the theme switch flips the kit to dark. `var()` fallbacks and
|
|
416
442
|
`light-dark()` pairs follow the theme. Fixed colours on purpose (a theme gallery, a
|
|
417
443
|
mockup, a test fixture) go in a `<style data-kmwc-fixed-colors>` block, or one rule
|
|
418
|
-
gets a `/* kmwc-fixed-colors: why */` comment right before it.
|
|
444
|
+
gets a `/* kmwc-fixed-colors: why */` comment right before it. An element whose
|
|
445
|
+
`style="…"` sets them on purpose carries the same attribute — markup a render
|
|
446
|
+
callback draws inside a shadow root can't be reached by page CSS at all.
|
|
419
447
|
- **Loader class** — the component CSS styles the class the *installed* core gives
|
|
420
448
|
its default loader (`<prefix>__loader` from core 6d6b768 on, `<prefix>-loader`
|
|
421
449
|
before). Renaming the selector on one side of the core bump leaves the loader
|
|
422
450
|
unstyled, and a VALIDATION-NOTES `## C-NC-8` would hide the old class from the
|
|
423
|
-
rulebook.
|
|
451
|
+
rulebook. On a core that still draws `<prefix>-loader`, CSS that styles both
|
|
452
|
+
classes has an element of its own called `<prefix>__loader`: a warning, because
|
|
453
|
+
core's loader takes that name (and those rules) with the bump.
|
|
454
|
+
- **Variable manifest** — the same verdict as `kmwc check-vars`, which otherwise
|
|
455
|
+
only runs inside `npm run build`: `--base-*` reads and the manifest agree, no
|
|
456
|
+
dead `--<prefix>-*` entries (errors), no undocumented ones (warning).
|
|
424
457
|
|
|
425
458
|
`kmwc check-pages` is the browser half: it loads every example page and reports
|
|
426
459
|
what doctor can't see in the source — a script error, a 404, a fixed width that
|
|
@@ -428,7 +461,8 @@ pushes a phone sideways, and page CSS that can never apply: a rule (in the page'
|
|
|
428
461
|
`<style>` or `examples-local.css`) whose element exists only inside a component's
|
|
429
462
|
shadow root, such as the rows a `renderNodeCallback` draws. Page CSS stops at the
|
|
430
463
|
shadow boundary — style those through a CSS variable, `::part` or the component's
|
|
431
|
-
styles hook (`getCustomStylesCallback`).
|
|
464
|
+
styles hook (`getCustomStylesCallback`). An element a demo leaves undefined on
|
|
465
|
+
purpose (registered on a button press) carries `data-kmwc-undefined`.
|
|
432
466
|
|
|
433
467
|
**Package contents.** Doctor runs `npm pack --dry-run` and fails on anything
|
|
434
468
|
dev-only that would reach a consumer's `node_modules`: `CLAUDE.md` at any
|
|
@@ -524,6 +558,7 @@ registry edits), and move the component's own code into that layout with
|
|
|
524
558
|
npm install
|
|
525
559
|
npm test # unit tests (config, registry, templates, sync, checks, intake, plugin, kit)
|
|
526
560
|
npm run test:e2e # playground: the kit + scaffold in a real browser, dev server AND built site
|
|
561
|
+
npm run test:e2e:coverage # the same specs on the dev server + line coverage of the kit's browser code
|
|
527
562
|
npm run readme # regenerate the catalogue
|
|
528
563
|
npm run check:spotlight-mirror # what changed in web-commandpalette since the spotlight was aligned
|
|
529
564
|
make dev # the playground's dev server
|
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-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",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"readme:check": "node src/cli/kmwc.js readme --check",
|
|
48
48
|
"test": "node --test \"test/**/*.test.js\"",
|
|
49
49
|
"test:e2e": "cd playground && npx playwright test",
|
|
50
|
+
"test:e2e:coverage": "node scripts/e2e-coverage.js",
|
|
50
51
|
"check:contract": "base-contract-check src templates playground",
|
|
51
52
|
"check:spotlight-mirror": "node scripts/check-spotlight-mirror.js",
|
|
52
53
|
"playground": "cd playground && vite",
|
|
@@ -23,6 +23,8 @@ const SETTLE_MS = 600;
|
|
|
23
23
|
const VITE_CONFIG_FILES = ['vite.config.ts', 'vite.config.mts', 'vite.config.mjs', 'vite.config.js'];
|
|
24
24
|
const MAX_OFFENDERS = 3;
|
|
25
25
|
const LOCAL_STYLESHEET_FILE = 'examples-local.css';
|
|
26
|
+
// An element a demo leaves undefined on purpose (registered on a button press) opts out.
|
|
27
|
+
export const UNDEFINED_ON_PURPOSE_ATTRIBUTE = 'data-kmwc-undefined';
|
|
26
28
|
// Requests a page doesn't control (favicon probes, analytics in a built site).
|
|
27
29
|
const IGNORED_REQUEST_PATTERN = /favicon\.ico|stats\.keenmate\.services|plausible/;
|
|
28
30
|
|
|
@@ -96,7 +98,7 @@ export function getPageProblems(pageReport) {
|
|
|
96
98
|
for (const { selector, host } of pageReport.shadowOnlyRules || []) {
|
|
97
99
|
problems.push(`page CSS "${selector}" only matches inside <${host}>'s shadow root — page styles can't reach it (use a CSS variable, ::part or the component's styles hook)`);
|
|
98
100
|
}
|
|
99
|
-
if (pageReport.undefinedElements.length) problems.push(`never defined: <${pageReport.undefinedElements.join('>, <')}
|
|
101
|
+
if (pageReport.undefinedElements.length) problems.push(`never defined: <${pageReport.undefinedElements.join('>, <')}> (left undefined on purpose? add ${UNDEFINED_ON_PURPOSE_ATTRIBUTE})`);
|
|
100
102
|
if (pageReport.scrollWidth > pageReport.clientWidth) {
|
|
101
103
|
const offenderText = pageReport.offenders.length ? ` — widest: ${pageReport.offenders.join(', ')}` : '';
|
|
102
104
|
problems.push(`page is ${pageReport.scrollWidth}px wide in a ${pageReport.clientWidth}px viewport${offenderText}`);
|
|
@@ -108,9 +110,10 @@ export function getPageProblems(pageReport) {
|
|
|
108
110
|
* Runs in the page: overflow, undefined custom elements. A culprit is the deepest
|
|
109
111
|
* element — or the text — past the right edge, outside anything that scrolls or
|
|
110
112
|
* clips on purpose (a code block, a table wrapper) and outside hidden or fixed
|
|
111
|
-
* layers (a closed nav panel, a toast), which don't widen the page.
|
|
113
|
+
* layers (a closed nav panel, a toast), which don't widen the page. Undefined elements
|
|
114
|
+
* carrying `undefinedAttribute` are left undefined on purpose and don't count.
|
|
112
115
|
*/
|
|
113
|
-
function inspectPage(maxOffenders) {
|
|
116
|
+
function inspectPage({ maxOffenders, undefinedAttribute }) {
|
|
114
117
|
const viewportWidth = document.documentElement.clientWidth;
|
|
115
118
|
// Shadow roots count: a component's own internals can widen the page too.
|
|
116
119
|
const getParent = (element) => element.parentElement || element.getRootNode().host || null;
|
|
@@ -157,7 +160,7 @@ function inspectPage(maxOffenders) {
|
|
|
157
160
|
offenders.push(`${describe(parent)} "${textNode.textContent.trim().slice(0, 40)}"`);
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
|
-
const undefinedElements = [...new Set([...document.querySelectorAll(
|
|
163
|
+
const undefinedElements = [...new Set([...document.querySelectorAll(`:not(:defined):not([${undefinedAttribute}])`)].map((element) => element.localName).filter((name) => name.includes('-')))];
|
|
161
164
|
return {
|
|
162
165
|
scrollWidth: document.documentElement.scrollWidth,
|
|
163
166
|
clientWidth: viewportWidth,
|
|
@@ -182,7 +185,7 @@ async function loadPageReport(browser, pageUrl, width, pageRules = []) {
|
|
|
182
185
|
try {
|
|
183
186
|
await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
184
187
|
await page.waitForTimeout(SETTLE_MS);
|
|
185
|
-
const inspection = await page.evaluate(inspectPage, MAX_OFFENDERS);
|
|
188
|
+
const inspection = await page.evaluate(inspectPage, { maxOffenders: MAX_OFFENDERS, undefinedAttribute: UNDEFINED_ON_PURPOSE_ATTRIBUTE });
|
|
186
189
|
const shadowOnlyRules = pageRules.length ? await page.evaluate(findShadowOnlyRules, pageRules) : [];
|
|
187
190
|
return { width, errors: [...new Set(errors)], ...inspection, shadowOnlyRules };
|
|
188
191
|
} catch (error) {
|
|
@@ -77,18 +77,14 @@ export function checkManifestVariables({ cssText, manifest, prefix, internalVari
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
80
|
+
/**
|
|
81
|
+
* The repo's manifest checked against its CSS sources → { verdict, prefix }, or { skippedReason }
|
|
82
|
+
* when there's nothing to check. Shared by `kmwc check-vars` and doctor.
|
|
83
|
+
*/
|
|
84
|
+
export function loadManifestVerdict(config) {
|
|
85
|
+
if (!config.cssVariables.isEnabled) return { skippedReason: 'off (kmwc.cssVariables is false)' };
|
|
87
86
|
const manifestPath = resolve(config.root, config.cssVariables.manifest);
|
|
88
|
-
if (!existsSync(manifestPath)) {
|
|
89
|
-
console.log(`kmwc check-vars: no ${config.cssVariables.manifest} — nothing to check.`);
|
|
90
|
-
return 0;
|
|
91
|
-
}
|
|
87
|
+
if (!existsSync(manifestPath)) return { skippedReason: `no ${config.cssVariables.manifest}` };
|
|
92
88
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
93
89
|
const prefix = config.prefix || manifest.prefix;
|
|
94
90
|
if (!prefix) throw new Error('no variable prefix — set kmwc.prefix or "prefix" in the manifest');
|
|
@@ -101,6 +97,17 @@ export async function run(args) {
|
|
|
101
97
|
internalVariables: config.cssVariables.internalVariables,
|
|
102
98
|
checkMode: config.cssVariables.checkMode,
|
|
103
99
|
});
|
|
100
|
+
return { verdict, prefix };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function run(args) {
|
|
104
|
+
const config = getKmwcConfig(args.root);
|
|
105
|
+
const isStrict = !!args.strict;
|
|
106
|
+
const { verdict, prefix, skippedReason } = loadManifestVerdict(config);
|
|
107
|
+
if (skippedReason) {
|
|
108
|
+
console.log(`kmwc check-vars: ${skippedReason} — nothing to check.`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
104
111
|
|
|
105
112
|
const presenceWord = config.cssVariables.checkMode === 'consumed' ? 'consumed via var()' : 'declared or read';
|
|
106
113
|
const renderSection = (title, names) => `${title} (${names.length}):\n` + names.map((name) => ` --${name}`).join('\n');
|
|
@@ -14,11 +14,12 @@
|
|
|
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';
|
|
21
21
|
import { checkIsFragment } from '../../vite/page-composer.js';
|
|
22
|
+
import { loadManifestVerdict } from './check-vars.js';
|
|
22
23
|
import {
|
|
23
24
|
VALIDATION_NOTES_FILE,
|
|
24
25
|
loadAcceptedCheckIds,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
getPortBlockRange,
|
|
29
30
|
getScriptPortMismatches,
|
|
30
31
|
loadStalePortMentions,
|
|
32
|
+
OTHER_PORTS_MARKER,
|
|
31
33
|
loadSiblingPortCollisions,
|
|
32
34
|
loadOwnPortsOutsideBlock,
|
|
33
35
|
loadPlausibleSiteId,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
FIXED_COLORS_MARKER,
|
|
39
41
|
getHardcodedColors,
|
|
40
42
|
loadCoreLoader,
|
|
43
|
+
getLoaderClassVerdict,
|
|
41
44
|
loadStyledLoaderClasses,
|
|
42
45
|
} from '../repo-checks.js';
|
|
43
46
|
|
|
@@ -63,8 +66,9 @@ export const RULEBOOK_SUITES = [
|
|
|
63
66
|
{ name: 'readme-structure', isPrefixed: false, isComponentOnly: false },
|
|
64
67
|
{ name: 'custom-elements-manifest', isPrefixed: false, isComponentOnly: true },
|
|
65
68
|
{ name: 'responsive-overlay', isPrefixed: true, isComponentOnly: true },
|
|
66
|
-
|
|
67
|
-
{ name: '
|
|
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 },
|
|
68
72
|
];
|
|
69
73
|
|
|
70
74
|
const LIBRARY_KIND = 'library';
|
|
@@ -98,15 +102,40 @@ export function getSuiteFailures(suiteOutput) {
|
|
|
98
102
|
.map((match) => ({ checkId: match[1], reason: match[2].trim() }));
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
|
|
105
|
+
const GIT_BASH_RELATIVE_PATH = join('bin', 'bash.exe');
|
|
106
|
+
// PATH entries Git for Windows adds — each sits one or two levels under the install root.
|
|
107
|
+
const GIT_PATH_ENTRY_PATTERN = /^(.*[\\/]Git)[\\/](?:cmd|bin|usr[\\/]bin|mingw64[\\/]bin)[\\/]?$/i;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The bash that runs the rulebook scripts. On Windows a bare `bash` can resolve to WSL's
|
|
111
|
+
* (`System32\bash.exe`, `WindowsApps\bash.exe`), which can't read `C:/…` paths: every suite
|
|
112
|
+
* exits 127 and the rulebook never runs. So: KMWC_BASH when set, else Git for Windows'
|
|
113
|
+
* bash (found from PATH or the usual install roots), else `bash`. Exported for tests.
|
|
114
|
+
*/
|
|
115
|
+
export function getBashPath({ platform = process.platform, env = process.env, checkExists = existsSync } = {}) {
|
|
116
|
+
if (env.KMWC_BASH) return env.KMWC_BASH;
|
|
117
|
+
if (platform !== 'win32') return 'bash';
|
|
118
|
+
const pathEntries = (env.PATH || env.Path || '').split(';').filter(Boolean);
|
|
119
|
+
const gitRoots = [
|
|
120
|
+
...pathEntries.map((entry) => entry.match(GIT_PATH_ENTRY_PATTERN)?.[1]).filter(Boolean),
|
|
121
|
+
...[env.ProgramFiles, env.ProgramW6432, env.LOCALAPPDATA && join(env.LOCALAPPDATA, 'Programs')].filter(Boolean).map((directory) => join(directory, 'Git')),
|
|
122
|
+
];
|
|
123
|
+
return gitRoots.map((gitRoot) => join(gitRoot, GIT_BASH_RELATIVE_PATH)).find((bashPath) => checkExists(bashPath)) || 'bash';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function runRulebookSuite(config, suite, checkedRoot) {
|
|
102
127
|
const scriptPath = join(config.rulebook.path, `${suite.name}.checks.sh`).replace(/\\/g, '/');
|
|
103
|
-
const scriptArgs = [scriptPath,
|
|
128
|
+
const scriptArgs = [scriptPath, checkedRoot.replace(/\\/g, '/')];
|
|
104
129
|
if (suite.isPrefixed && config.prefix) scriptArgs.push(config.prefix);
|
|
105
|
-
return spawnSync(
|
|
130
|
+
return spawnSync(getBashPath(), scriptArgs, { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
106
131
|
}
|
|
107
132
|
|
|
108
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Run every check; returns [{ level: 'pass'|'warning'|'error', checkName, message, fixHint? }].
|
|
135
|
+
* `doctorConfig.registry` replaces components.json (tests use a registry of fixture repos).
|
|
136
|
+
*/
|
|
109
137
|
export function checkComponent(config, doctorConfig = {}) {
|
|
138
|
+
const registry = doctorConfig.registry || getRegistry();
|
|
110
139
|
const results = [];
|
|
111
140
|
const addPass = (checkName, message) => results.push({ level: 'pass', checkName, message });
|
|
112
141
|
const addWarning = (checkName, message, fixHint) => results.push({ level: 'warning', checkName, message, fixHint });
|
|
@@ -240,7 +269,7 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
240
269
|
}
|
|
241
270
|
|
|
242
271
|
// ── registry cross-check ─────────────────────────────────────────────────
|
|
243
|
-
const registryEntry =
|
|
272
|
+
const registryEntry = registry.components.find((component) => component.id === config.id);
|
|
244
273
|
if (!registryEntry) {
|
|
245
274
|
addWarning('registry', `"${config.id}" is not in the devkit components.json`, 'add an entry (README catalogue + port allocation)');
|
|
246
275
|
} else {
|
|
@@ -254,7 +283,7 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
254
283
|
`set package.json#kmwc.ports to { "dev": ${registryPorts.dev}, "image": ${registryPorts.image} } (components.json port block ${registryEntry.port})`
|
|
255
284
|
);
|
|
256
285
|
} else addPass('registry', `ports match the registry (dev ${config.ports.dev}, image ${config.ports.image})`);
|
|
257
|
-
checkPorts(config, registryEntry.port, { addPass, addWarning, addError });
|
|
286
|
+
checkPorts(config, registryEntry.port, registry, { addPass, addWarning, addError });
|
|
258
287
|
}
|
|
259
288
|
if (registryEntry.prefix && config.prefix && registryEntry.prefix !== config.prefix) {
|
|
260
289
|
addError('registry', `kmwc.prefix "${config.prefix}" ≠ registry prefix "${registryEntry.prefix}"`, 'use the reserved prefix');
|
|
@@ -274,12 +303,22 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
274
303
|
if (!outputMismatches.length) addPass('library', 'package.json targets match the library build outputs');
|
|
275
304
|
}
|
|
276
305
|
|
|
306
|
+
// ── monorepo: the workspace packages doctor checks ───────────────────────
|
|
307
|
+
checkWorkspacePackages(config, { addPass, addWarning, addError });
|
|
308
|
+
|
|
277
309
|
// ── core's default loader class ──────────────────────────────────────────
|
|
278
|
-
if (config.prefix && config.kind !== LIBRARY_KIND)
|
|
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
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── variable manifest (what `kmwc check-vars` checks; it only runs in the build) ──
|
|
317
|
+
if (config.hasPackageConfig) checkVariableManifest(config, { addPass, addWarning, addError });
|
|
279
318
|
|
|
280
319
|
// ── analytics ────────────────────────────────────────────────────────────
|
|
281
320
|
const plausibleSiteId = loadPlausibleSiteId(config.root);
|
|
282
|
-
const sharingRepoIds = loadSharedPlausibleSiteIds(config,
|
|
321
|
+
const sharingRepoIds = loadSharedPlausibleSiteIds(config, registry, plausibleSiteId);
|
|
283
322
|
if (sharingRepoIds.length) {
|
|
284
323
|
addError(
|
|
285
324
|
'analytics',
|
|
@@ -289,7 +328,9 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
289
328
|
} else if (plausibleSiteId) addPass('analytics', `Plausible site id ${plausibleSiteId} is this repo's own`);
|
|
290
329
|
|
|
291
330
|
// ── package contents ─────────────────────────────────────────────────────
|
|
292
|
-
if (doctorConfig.shouldCheckPackage !== false)
|
|
331
|
+
if (doctorConfig.shouldCheckPackage !== false) {
|
|
332
|
+
for (const checkedPackage of config.packages) checkPackageContents(checkedPackage, { addPass, addWarning, addError });
|
|
333
|
+
}
|
|
293
334
|
|
|
294
335
|
// ── rulebook suites ──────────────────────────────────────────────────────
|
|
295
336
|
if (doctorConfig.shouldRunRulebook !== false) checkRulebook(config, { addPass, addWarning, addError });
|
|
@@ -298,7 +339,7 @@ export function checkComponent(config, doctorConfig = {}) {
|
|
|
298
339
|
}
|
|
299
340
|
|
|
300
341
|
/** Ports: script --port flags, own configs and docs outside the block, siblings inside it. */
|
|
301
|
-
function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
|
|
342
|
+
function checkPorts(config, portBlock, registry, { addPass, addWarning, addError }) {
|
|
302
343
|
const [firstPort, lastPort] = getPortBlockRange(portBlock);
|
|
303
344
|
const blockLabel = `${firstPort}–${lastPort}`;
|
|
304
345
|
const scriptPortMismatches = getScriptPortMismatches(config.packageJson.scripts, config.ports);
|
|
@@ -307,7 +348,7 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
|
|
|
307
348
|
}
|
|
308
349
|
const ownStrayPorts = loadOwnPortsOutsideBlock(config, portBlock).filter(({ file }) => !file.startsWith('package.json#scripts.'));
|
|
309
350
|
for (const { port, file } of ownStrayPorts) addError('ports', `${file} uses port ${port}, outside this repo's block ${blockLabel}`, 'take ports from kmwc.ports (the devkit presets do)');
|
|
310
|
-
const siblingCollisions = loadSiblingPortCollisions(config,
|
|
351
|
+
const siblingCollisions = loadSiblingPortCollisions(config, registry);
|
|
311
352
|
for (const { id, port, file } of siblingCollisions) {
|
|
312
353
|
addError('ports', `${id} uses port ${port} (${file}) — inside this repo's block ${blockLabel}`, `move ${id} to its own components.json block`);
|
|
313
354
|
}
|
|
@@ -316,7 +357,7 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
|
|
|
316
357
|
addWarning(
|
|
317
358
|
'ports',
|
|
318
359
|
`docs mention ports outside ${blockLabel}: ${stalePortMentions.map(({ file, port }) => `${file} ${port}`).join(', ')}`,
|
|
319
|
-
`update them to ${getPortsForBlock(portBlock).dev} (dev) / ${getPortsForBlock(portBlock).image} (image)
|
|
360
|
+
`update them to ${getPortsForBlock(portBlock).dev} (dev) / ${getPortsForBlock(portBlock).image} (image); a doc that names other repos' ports on purpose gets <!-- ${OTHER_PORTS_MARKER}: why -->`
|
|
320
361
|
);
|
|
321
362
|
}
|
|
322
363
|
if (!scriptPortMismatches.length && !ownStrayPorts.length && !siblingCollisions.length && !stalePortMentions.length) {
|
|
@@ -330,13 +371,23 @@ function checkPorts(config, portBlock, { addPass, addWarning, addError }) {
|
|
|
330
371
|
* before (or after) the core bump leaves the default loader unstyled, and no rulebook
|
|
331
372
|
* check sees that (a VALIDATION-NOTES `## C-NC-8` even hides a leftover old class).
|
|
332
373
|
*/
|
|
333
|
-
function checkLoaderClass(config, { addPass, addError }) {
|
|
334
|
-
const coreLoader = loadCoreLoader(
|
|
374
|
+
function checkLoaderClass(config, checkedPackage, { addPass, addWarning, addError }) {
|
|
375
|
+
const coreLoader = loadCoreLoader(checkedPackage.root);
|
|
335
376
|
if (!coreLoader?.separator) return;
|
|
336
377
|
const coreClassName = `${config.prefix}${coreLoader.separator}loader`;
|
|
337
|
-
const styledLoaderClasses = loadStyledLoaderClasses(
|
|
338
|
-
|
|
339
|
-
|
|
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);
|
|
383
|
+
for (const { file, className } of collidingClasses) {
|
|
384
|
+
addWarning(
|
|
385
|
+
'loader',
|
|
386
|
+
`${file} styles .${className} next to core's .${coreClassName} — from core 6d6b768 on, core draws its default loader as <span class="${className}"> and it picks up these rules`,
|
|
387
|
+
`rename the component's own .${className} element (e.g. .${config.prefix}__loading) in the same change as the core bump`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
for (const { file, className } of renamedClasses) {
|
|
340
391
|
addError(
|
|
341
392
|
'loader',
|
|
342
393
|
`${file} styles .${className}, but core ${coreLoader.version} draws the default loader as <span class="${coreClassName}"> — it renders unstyled`,
|
|
@@ -345,7 +396,30 @@ function checkLoaderClass(config, { addPass, addError }) {
|
|
|
345
396
|
: `keep .${coreClassName} until the component builds on a core that draws <prefix>__loader, then rename in the same change`
|
|
346
397
|
);
|
|
347
398
|
}
|
|
348
|
-
if (
|
|
399
|
+
if (isCoreClassStyled && !renamedClasses.length) addPass('loader', `CSS styles .${coreClassName}, the class core ${coreLoader.version} draws`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The variable manifest matches the CSS — the same verdict as `kmwc check-vars` (undocumented = warning). */
|
|
403
|
+
function checkVariableManifest(config, { addPass, addWarning, addError }) {
|
|
404
|
+
let manifestVerdict;
|
|
405
|
+
try {
|
|
406
|
+
manifestVerdict = loadManifestVerdict(config);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
addError('vars', `kmwc check-vars can't run: ${error.message}`, 'fix kmwc.cssVariables (sources / manifest / prefix)');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const { verdict, prefix, skippedReason } = manifestVerdict;
|
|
412
|
+
if (skippedReason) return;
|
|
413
|
+
const fixHint = 'edit the manifest to match the CSS (npm run check:vars lists every name)';
|
|
414
|
+
const listNames = (names) => names.map((name) => `--${name}`).join(', ');
|
|
415
|
+
if (verdict.missingBaseVariables.length) addError('vars', `--base-* read in CSS but missing from the manifest: ${listNames(verdict.missingBaseVariables)}`, fixHint);
|
|
416
|
+
if (verdict.unusedBaseVariables.length) addError('vars', `--base-* in the manifest but not read in CSS: ${listNames(verdict.unusedBaseVariables)}`, fixHint);
|
|
417
|
+
if (verdict.unusedComponentVariables.length) addError('vars', `--${prefix}-* in the manifest but not in CSS: ${listNames(verdict.unusedComponentVariables)}`, fixHint);
|
|
418
|
+
if (verdict.undocumentedComponentVariables.length) {
|
|
419
|
+
addWarning('vars', `--${prefix}-* in CSS but missing from the manifest: ${listNames(verdict.undocumentedComponentVariables)}`, `${fixHint}; runtime internals go in kmwc.cssVariables.internalVariables`);
|
|
420
|
+
}
|
|
421
|
+
const isInSync = !['missingBaseVariables', 'unusedBaseVariables', 'unusedComponentVariables', 'undocumentedComponentVariables'].some((key) => verdict[key].length);
|
|
422
|
+
if (isInSync) addPass('vars', `variable manifest in sync — ${verdict.counts.base} base, ${verdict.counts.component} component vars`);
|
|
349
423
|
}
|
|
350
424
|
|
|
351
425
|
/** Packed paths that break PACKAGE_LEAK_RULES → [{ path, label }]. Pure — exported for tests. */
|
|
@@ -358,10 +432,56 @@ export function getPackageLeaks(packedPaths) {
|
|
|
358
432
|
});
|
|
359
433
|
}
|
|
360
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
|
+
|
|
361
481
|
/** The file list `npm publish` would ship (no lifecycle scripts, nothing written). */
|
|
362
|
-
function getPackedPaths(
|
|
482
|
+
function getPackedPaths(packageRoot) {
|
|
363
483
|
const packResult = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
|
|
364
|
-
cwd:
|
|
484
|
+
cwd: packageRoot,
|
|
365
485
|
encoding: 'utf8',
|
|
366
486
|
shell: process.platform === 'win32',
|
|
367
487
|
});
|
|
@@ -370,26 +490,37 @@ function getPackedPaths(config) {
|
|
|
370
490
|
return packReport[0].files.map((file) => file.path);
|
|
371
491
|
}
|
|
372
492
|
|
|
373
|
-
|
|
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
|
+
}
|
|
374
505
|
let packedPaths;
|
|
375
506
|
try {
|
|
376
|
-
packedPaths = getPackedPaths(
|
|
507
|
+
packedPaths = getPackedPaths(checkedPackage.root);
|
|
377
508
|
} catch {
|
|
378
509
|
packedPaths = null;
|
|
379
510
|
}
|
|
380
511
|
if (!packedPaths) {
|
|
381
|
-
addWarning('package',
|
|
512
|
+
addWarning('package', `${packageLabel}npm pack --dry-run failed — package contents not checked`);
|
|
382
513
|
return;
|
|
383
514
|
}
|
|
384
515
|
const packageLeaks = getPackageLeaks(packedPaths);
|
|
385
516
|
for (const packageLeak of packageLeaks) {
|
|
386
517
|
addError(
|
|
387
518
|
'package',
|
|
388
|
-
`${packageLeak.path} would be published (${packageLeak.label})`,
|
|
389
|
-
`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`
|
|
390
521
|
);
|
|
391
522
|
}
|
|
392
|
-
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`);
|
|
393
524
|
}
|
|
394
525
|
|
|
395
526
|
function checkRulebook(config, { addPass, addWarning, addError }) {
|
|
@@ -398,39 +529,55 @@ function checkRulebook(config, { addPass, addWarning, addError }) {
|
|
|
398
529
|
return;
|
|
399
530
|
}
|
|
400
531
|
const skippedSuites = new Set(config.rulebook.skippedChecks);
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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 });
|
|
406
549
|
}
|
|
407
|
-
for (const suite of
|
|
550
|
+
for (const { suite, checkedRoot, checkName, acceptedCheckIds } of suiteRuns) {
|
|
408
551
|
if (skippedSuites.has(suite.name)) continue;
|
|
409
552
|
if (!existsSync(join(config.rulebook.path, `${suite.name}.checks.sh`))) continue;
|
|
410
|
-
const suiteResult = runRulebookSuite(config, suite);
|
|
553
|
+
const suiteResult = runRulebookSuite(config, suite, checkedRoot);
|
|
411
554
|
if (suiteResult.error) {
|
|
412
555
|
addWarning('rulebook', `cannot run bash (${suiteResult.error.code}) — rulebook checks skipped`, 'install Git Bash / run from a POSIX shell');
|
|
413
556
|
return;
|
|
414
557
|
}
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
558
|
+
const failures = getSuiteFailures(suiteResult.stdout);
|
|
559
|
+
// A suite passes with exit 0 and fails with exit 1 + FAIL lines. Anything else (2 = usage,
|
|
560
|
+
// 127 = the script wasn't found — WSL's bash on a C:/ path) means it never ran: say so,
|
|
561
|
+
// never count it as a pass.
|
|
562
|
+
const hasRun = suiteResult.status === 0 || (suiteResult.status === 1 && failures.length > 0);
|
|
563
|
+
if (!hasRun) {
|
|
564
|
+
const reason = mapToPlainText(suiteResult.stderr || suiteResult.stdout).trim().split(/\r?\n/)[0] || 'no output';
|
|
565
|
+
addWarning(checkName, `suite could not run (exit ${suiteResult.status}): ${reason}`, `bash used: ${getBashPath()} — set KMWC_BASH to Git for Windows' bash.exe if this is WSL's`);
|
|
419
566
|
continue;
|
|
420
567
|
}
|
|
421
|
-
const failures = getSuiteFailures(suiteResult.stdout);
|
|
422
568
|
for (const failure of failures) {
|
|
423
569
|
// An architectural decision recorded in VALIDATION-NOTES.md (the rulebook's accepted-deviation file).
|
|
424
570
|
if (acceptedCheckIds.has(failure.checkId)) addPass(checkName, `${failure.checkId} accepted deviation (${VALIDATION_NOTES_FILE}): ${failure.reason}`);
|
|
425
571
|
else addError(checkName, `${failure.checkId} ${failure.reason}`, `see ${suite.name}.checks.md → ${failure.checkId}`);
|
|
426
572
|
}
|
|
427
|
-
if (!failures.length) addPass(checkName,
|
|
573
|
+
if (!failures.length) addPass(checkName, 'all auto checks pass');
|
|
428
574
|
}
|
|
429
575
|
}
|
|
430
576
|
|
|
431
|
-
|
|
577
|
+
/** `registry` is for tests (fixture repos); the CLI always uses components.json. */
|
|
578
|
+
export async function run(args, { registry } = {}) {
|
|
432
579
|
const config = getKmwcConfig(args.root);
|
|
433
|
-
const results = checkComponent(config, { shouldRunRulebook: !args['no-rulebook'], shouldCheckPackage: !args['no-pack'] });
|
|
580
|
+
const results = checkComponent(config, { shouldRunRulebook: !args['no-rulebook'], shouldCheckPackage: !args['no-pack'], registry });
|
|
434
581
|
const errorCount = results.filter((result) => result.level === 'error').length;
|
|
435
582
|
const warningCount = results.filter((result) => result.level === 'warning').length;
|
|
436
583
|
|
package/src/cli/repo-checks.js
CHANGED
|
@@ -109,6 +109,12 @@ export function getMentionedPorts(text) {
|
|
|
109
109
|
return getUniqueSorted(patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => Number(match[1]))));
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* A doc that names other repos' ports on purpose (a dated plan, a cross-repo review) carries
|
|
114
|
+
* `<!-- kmwc-other-ports: why -->` and isn't scanned.
|
|
115
|
+
*/
|
|
116
|
+
export const OTHER_PORTS_MARKER = 'kmwc-other-ports';
|
|
117
|
+
|
|
112
118
|
/** Mentions in README.md / CLAUDE.md / docs/*.md of ports outside the repo's block → [{ file, port }]. */
|
|
113
119
|
export function loadStalePortMentions(root, portBlock) {
|
|
114
120
|
const blockRange = getPortBlockRange(portBlock);
|
|
@@ -116,7 +122,7 @@ export function loadStalePortMentions(root, portBlock) {
|
|
|
116
122
|
const docFiles = [...DOC_FILES, ...(existsSync(docsDir) ? readdirSync(docsDir).filter((name) => name.endsWith('.md')).map((name) => `${DOCS_DIR}/${name}`) : [])];
|
|
117
123
|
return docFiles.flatMap((file) => {
|
|
118
124
|
const text = readText(join(root, file));
|
|
119
|
-
return text ? getMentionedPorts(text).filter((port) => !checkIsInRange(port, blockRange)).map((port) => ({ file, port })) : [];
|
|
125
|
+
return text && !text.includes(OTHER_PORTS_MARKER) ? getMentionedPorts(text).filter((port) => !checkIsInRange(port, blockRange)).map((port) => ({ file, port })) : [];
|
|
120
126
|
});
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -252,7 +258,8 @@ export function getKitDuplicates(pageText, kitClassNames, { isStylesheet = false
|
|
|
252
258
|
|
|
253
259
|
/**
|
|
254
260
|
* Fixed colours on purpose (a theme gallery, a mockup, a test fixture): a page `<style>`
|
|
255
|
-
* block with the attribute, or one rule right
|
|
261
|
+
* block or an element with a `style="…"` that carries the attribute, or one rule right
|
|
262
|
+
* after a `/* kmwc-fixed-colors *\/` comment.
|
|
256
263
|
*/
|
|
257
264
|
export const FIXED_COLORS_ATTRIBUTE = 'data-kmwc-fixed-colors';
|
|
258
265
|
export const FIXED_COLORS_MARKER = 'kmwc-fixed-colors';
|
|
@@ -283,7 +290,7 @@ export function checkIsHardcodedColor(property, value) {
|
|
|
283
290
|
* Hard-coded colours in one page's `<style>` blocks and `style="…"` attributes (or in
|
|
284
291
|
* examples-local.css): they stay the same in dark mode while the kit around them flips.
|
|
285
292
|
* `var()` fallbacks and `light-dark()` pairs follow the theme; a `<style data-kmwc-fixed-colors>`
|
|
286
|
-
* block and a rule marked `/* kmwc-fixed-colors *\/` are skipped. Returns [{ found }]. Pure.
|
|
293
|
+
* block, an element carrying the attribute and a rule marked `/* kmwc-fixed-colors *\/` are skipped. Returns [{ found }]. Pure.
|
|
287
294
|
*/
|
|
288
295
|
export function getHardcodedColors(pageText, { isStylesheet = false } = {}) {
|
|
289
296
|
const styleText = isStylesheet
|
|
@@ -302,7 +309,12 @@ export function getHardcodedColors(pageText, { isStylesheet = false } = {}) {
|
|
|
302
309
|
}
|
|
303
310
|
}
|
|
304
311
|
if (isStylesheet) return hardcodedColors;
|
|
305
|
-
for (const
|
|
312
|
+
for (const match of pageText.matchAll(/\sstyle="([^"]*)"/g)) {
|
|
313
|
+
// The element's own attribute opts its style="…" out: markup a render callback draws
|
|
314
|
+
// inside a shadow root can't be reached by page CSS, so inline is the only way there.
|
|
315
|
+
const tagText = pageText.slice(pageText.lastIndexOf('<', match.index), pageText.indexOf('>', match.index + match[0].length) + 1);
|
|
316
|
+
if (tagText.includes(FIXED_COLORS_ATTRIBUTE)) continue;
|
|
317
|
+
const inlineStyle = match[1];
|
|
306
318
|
for (const declaration of inlineStyle.split(';')) {
|
|
307
319
|
const [property, ...valueParts] = declaration.split(':');
|
|
308
320
|
const value = valueParts.join(':').trim();
|
|
@@ -358,6 +370,24 @@ function getSourceStylesheets(directory, relativeDirectory = SOURCE_DIR) {
|
|
|
358
370
|
});
|
|
359
371
|
}
|
|
360
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Styled loader classes vs the separator core draws → { isCoreClassStyled, renamedClasses,
|
|
375
|
+
* collidingClasses }. A class with the other separator is a rename done too early / too late —
|
|
376
|
+
* unless core is still on `-` and its class is styled too: then the `__loader` is the
|
|
377
|
+
* component's own element (web-multiselect's loading-state row), which collides once core
|
|
378
|
+
* draws `<prefix>__loader`. Pure.
|
|
379
|
+
*/
|
|
380
|
+
export function getLoaderClassVerdict(coreSeparator, styledLoaderClasses) {
|
|
381
|
+
const isCoreClassStyled = styledLoaderClasses.some(({ separator }) => separator === coreSeparator);
|
|
382
|
+
const otherClasses = styledLoaderClasses.filter(({ separator }) => separator !== coreSeparator);
|
|
383
|
+
const isOwnLoaderElement = coreSeparator === '-' && isCoreClassStyled;
|
|
384
|
+
return {
|
|
385
|
+
isCoreClassStyled,
|
|
386
|
+
renamedClasses: isOwnLoaderElement ? [] : otherClasses,
|
|
387
|
+
collidingClasses: isOwnLoaderElement ? otherClasses : [],
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
361
391
|
/** Loader classes styled in the component's src CSS: [{ file, className, separator }]. */
|
|
362
392
|
export function loadStyledLoaderClasses(root, prefix) {
|
|
363
393
|
return getSourceStylesheets(join(root, SOURCE_DIR)).flatMap((file) =>
|
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
|
|
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
|
+
}
|
|
@@ -120,9 +120,10 @@
|
|
|
120
120
|
--kmwc-console-err-color: #fca5a5;
|
|
121
121
|
/* Dark: the chip is a step up from the main surface (slate on a slate page, not a
|
|
122
122
|
neutral grey), and the text keeps the accent hue but at least 0.8 lightness, so a
|
|
123
|
-
mid-dark accent (treeview's #059669) stays readable on it.
|
|
123
|
+
mid-dark accent (treeview's #059669) stays readable on it. Light: the same hue at
|
|
124
|
+
most 0.5 lightness — the raw accent read at ~3.3:1 on the pale chip. */
|
|
124
125
|
--kmwc-inline-code-bg: light-dark(#edf2f7, oklch(from var(--kmwc-main-bg) calc(l + 0.07) c h));
|
|
125
|
-
--kmwc-inline-code-color: light-dark(var(--kmwc-accent-color), oklch(from var(--kmwc-accent-color) max(l, 0.8) c h));
|
|
126
|
+
--kmwc-inline-code-color: light-dark(oklch(from var(--kmwc-accent-color) min(l, 0.5) c h), oklch(from var(--kmwc-accent-color) max(l, 0.8) c h));
|
|
126
127
|
|
|
127
128
|
/* ======================================================================
|
|
128
129
|
COLORS — SEMANTIC
|
|
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
-
|
|
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
|
|
package/src/examples/index.d.ts
CHANGED
|
@@ -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
|
|
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[];
|
package/src/examples/index.js
CHANGED
|
@@ -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';
|
package/src/examples/kit.js
CHANGED
|
@@ -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
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|
|
@@ -39,7 +39,11 @@ const INCLUDE_PATTERN = /<kmwc-include\b([^>]*?)(?:\/>|>([\s\S]*?)<\/kmwc-includ
|
|
|
39
39
|
const DEMO_PATTERN = /<kmwc-demo\b([^>]*)>([\s\S]*?)<\/kmwc-demo>/g;
|
|
40
40
|
const SHOWN_SCRIPT_PATTERN = /<script\b([^>]*\bdata-kmwc-show\b[^>]*)>([\s\S]*?)<\/script>/g;
|
|
41
41
|
const PAGE_META_PATTERN = /<meta\s+name="kmwc:([a-z-]+)"\s+content="([^"]*)"\s*\/?>\s*/g;
|
|
42
|
-
|
|
42
|
+
// A <style> element, or ANY start tag (quoted attribute values may hold `>` and `<link>`):
|
|
43
|
+
// matching whole tags left to right means the text `<link>` inside a tooltip's title=""
|
|
44
|
+
// is consumed with its tag and never read as an element. Only <style> and <link> move.
|
|
45
|
+
const HEAD_ELEMENT_PATTERN = /<style(?=[\s>])(?:"[^"]*"|'[^']*'|[^>"'])*>[\s\S]*?<\/style>\s*|<([a-zA-Z][\w-]*)(?:"[^"]*"|'[^']*'|[^>"'])*>\s*/g;
|
|
46
|
+
const HEAD_ELEMENT_TAGS = ['style', 'link'];
|
|
43
47
|
const ATTRIBUTE_PATTERN = /([a-zA-Z][\w-]*)(?:\s*=\s*"([^"]*)")?/g;
|
|
44
48
|
|
|
45
49
|
const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
|
|
@@ -201,7 +205,8 @@ export function renderLayout(fragmentHtml, { config, pagePath }) {
|
|
|
201
205
|
const bodyContent = mapOutsideComments(
|
|
202
206
|
htmlWithoutMeta,
|
|
203
207
|
(text) =>
|
|
204
|
-
text.replace(HEAD_ELEMENT_PATTERN, (element) => {
|
|
208
|
+
text.replace(HEAD_ELEMENT_PATTERN, (element, tagName = 'style') => {
|
|
209
|
+
if (!HEAD_ELEMENT_TAGS.includes(tagName.toLowerCase())) return element;
|
|
205
210
|
headElements.push(` ${element.trim()}`);
|
|
206
211
|
return '';
|
|
207
212
|
}),
|
|
@@ -31,7 +31,8 @@ locally — every local fork is how they drifted apart.
|
|
|
31
31
|
too — no inline styles for them. Page colours come from the kit's `--kmwc-*`
|
|
32
32
|
tokens or `light-dark()` — a literal doesn't follow dark mode (doctor warns);
|
|
33
33
|
a demo that shows fixed colours on purpose goes in `<style data-kmwc-fixed-colors>`
|
|
34
|
-
(one rule: a `/* kmwc-fixed-colors: why */` comment before it
|
|
34
|
+
(one rule: a `/* kmwc-fixed-colors: why */` comment before it; an element's own
|
|
35
|
+
`style="…"`: the attribute on the element).
|
|
35
36
|
If a second component needs a pattern, it belongs in the devkit.
|
|
36
37
|
- **Example pages are fragments:** no `<html>`/`<head>`/`<header>` — start with
|
|
37
38
|
`<meta name="kmwc:title" content="…">`, the devkit layout adds the rest. Shared text
|