eslint-plugin-kerfjs 5.0.0-beta.56 → 5.0.0-beta.58
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/README.md +31 -20
- package/docs/rules/ui-component-ownership.md +43 -0
- package/docs/rules/ui-preferences.md +11 -0
- package/docs/rules/ui-public-boundaries.md +44 -3
- package/docs/rules/ui-wiring.md +1 -1
- package/index.js +4 -1
- package/lib/html-template-classes.js +104 -0
- package/lib/rules/ui-component-ownership.js +199 -0
- package/lib/rules/ui-preferences.js +9 -1
- package/lib/rules/ui-public-boundaries.js +119 -6
- package/lib/ui-contract.js +86 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,15 +13,17 @@ This plugin sits alongside two other defense layers shipped by [`kerfjs`](https:
|
|
|
13
13
|
| Opt-in dev-warns (`KERF_DEV_WARN_*`) | Hard Rules 4 (rebuilt listeners), 7 (untracked signals), 8 (narrow set) | Runtime |
|
|
14
14
|
| **This plugin** | Hard Rules 2, 5, 6, 10, 12 — AST-shaped antipatterns; plus rename-safety / `raw()`-audit nudges and a project-hygiene check for the bundled AI-assistant configs | **Edit time** |
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
The core syntax rules analyze the AST without TypeScript parser services. `ai-assistant-configs` reads the filesystem, and the optional UI rules also resolve catalog and profile artifacts. Consumers configure their own parser; no `@typescript-eslint/parser` _service_ is required.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
npm install --save-dev eslint-plugin-kerfjs
|
|
22
|
-
npm install @kerfjs/ui
|
|
23
22
|
```
|
|
24
23
|
|
|
24
|
+
Install `@kerfjs/ui` as well when using `recommended-ui` or `strict-ui`; the
|
|
25
|
+
core `recommended` preset does not require it.
|
|
26
|
+
|
|
25
27
|
### Supported ESLint versions
|
|
26
28
|
|
|
27
29
|
`peerDependencies` declares `^9.0.0 || ^10.0.0`, and that is a **tested** range rather than an optimistic one: the rule suite runs against the latest release of each of those majors in CI on every push. A major is added to the range only after the suite has passed on it.
|
|
@@ -59,14 +61,14 @@ export default [
|
|
|
59
61
|
];
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
The UI rules resolve the installed `@kerfjs/ui`
|
|
64
|
+
The UI rules resolve the installed `@kerfjs/ui` selection catalog (`component-catalog.json`), composition catalog (`component-composition.json`), and application profile defaults. They then merge `.kerf-ui-profile.json` files from the workspace root toward the linted file. Package authors and monorepos can provide explicit artifacts without duplicating their facts:
|
|
63
65
|
|
|
64
66
|
```js
|
|
65
67
|
settings: {
|
|
66
68
|
kerfjs: {
|
|
67
69
|
ui: {
|
|
68
70
|
workspaceRoot: import.meta.dirname,
|
|
69
|
-
catalogPath: './vendor/component-
|
|
71
|
+
catalogPath: './vendor/component-composition.json',
|
|
70
72
|
selectionCatalogPath: './vendor/component-catalog.json',
|
|
71
73
|
profileDefaultsPath: './vendor/application-ui-profile.defaults.json',
|
|
72
74
|
profileContractPath: './vendor/application-ui-profile-sync.cjs',
|
|
@@ -75,13 +77,20 @@ settings: {
|
|
|
75
77
|
}
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
For deterministic generated configs, the same settings accept parsed `catalog`, `selectionCatalog`, and `profile` objects. File-based precedence is package defaults, then workspace profile, then root-to-leaf directory profiles. The rules load `application-ui-profile-sync.cjs` from the installed `@kerfjs/ui` package by default because ESLint rule creation is synchronous; `profileContractPath` is only needed when artifacts are vendored. Every raw layer is schema- and catalog-reference-validated before merge, so a child cannot hide malformed or stale parent policy. Exceptions use exact `KUI-L*` diagnostic ids and repository-relative file or directory targets.
|
|
80
|
+
For deterministic generated configs, the same settings accept parsed `catalog`, `selectionCatalog`, and `profile` objects. File-based precedence is package defaults, then workspace profile, then root-to-leaf directory profiles. The rules load `application-ui-profile-sync.cjs` from the installed `@kerfjs/ui` package by default because ESLint rule creation is synchronous; `profileContractPath` is only needed when artifacts are vendored, and then its directory must also hold `application-ui-diagnostic-ids-v1.json` and `component-class-owners.cjs` (the class-to-component rule `KUI-L103` shares with `@kerfjs/ui`'s generated component pages) from the same `@kerfjs/ui` release. Every raw layer is schema- and catalog-reference-validated before merge, so a child cannot hide malformed or stale parent policy. Exceptions use exact `KUI-L*` diagnostic ids and repository-relative file or directory targets.
|
|
79
81
|
|
|
80
82
|
`ui-css-values` consumes each catalog entry's property-specific grammar and
|
|
81
83
|
emits `KUI-L013`–`KUI-L017` with a preferred shorthand or helper. It covers JSX,
|
|
82
84
|
direct component calls, nested catalog paths, and explicitly configured
|
|
83
85
|
third-party catalogs; dynamic values remain governed by TypeScript.
|
|
84
86
|
|
|
87
|
+
`ui-component-ownership` keeps code out of another package's component
|
|
88
|
+
internals: `KUI-L020` reports a reference to a component's private `--_*`
|
|
89
|
+
variable and `KUI-L021` an assignment to a token a typed prop sets (use the
|
|
90
|
+
prop). Each message ends by routing a real need with no prop to a component-gap
|
|
91
|
+
report instead of an override. `kerf-ui-analyze` reports the same ids, plus the
|
|
92
|
+
stylesheet-only `KUI-L019` / `KUI-L022`, for CSS files.
|
|
93
|
+
|
|
85
94
|
`KUI-L090` is reserved for catalog/profile loading and configuration failures. The supported-ESLint matrix installs real packed `eslint-plugin-kerfjs` and `@kerfjs/ui` tarballs in a downstream flat-config fixture, ensuring these defaults resolve from the consumer rather than this repository's source tree.
|
|
86
95
|
|
|
87
96
|
## Legacy `.eslintrc` configs are not supported
|
|
@@ -92,21 +101,22 @@ Use flat config (`eslint.config.js`), shown above. The `legacy-recommended` expo
|
|
|
92
101
|
|
|
93
102
|
## Rules
|
|
94
103
|
|
|
95
|
-
| Rule | Hard Rule
|
|
96
|
-
| -------------------------------------------------------------------------------- |
|
|
97
|
-
| [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 10 — use `data-action` + `delegate()`
|
|
98
|
-
| [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item
|
|
99
|
-
| [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md) | 5 — capture `delegate()` disposers when scope < page
|
|
100
|
-
| [`no-nested-mount`](docs/rules/no-nested-mount.md) | 6 — one `mount()` per root
|
|
101
|
-
| [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global
|
|
102
|
-
| [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors)
|
|
103
|
-
| [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail)
|
|
104
|
-
| [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene)
|
|
105
|
-
| [`ui-public-boundaries`](docs/rules/ui-public-boundaries.md) | — (cataloged
|
|
106
|
-
| [`ui-
|
|
107
|
-
| [`ui-
|
|
108
|
-
| [`ui-
|
|
109
|
-
| [`ui-
|
|
104
|
+
| Rule | Hard Rule | Severity (recommended) |
|
|
105
|
+
| -------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------- |
|
|
106
|
+
| [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 10 — use `data-action` + `delegate()` | `error` |
|
|
107
|
+
| [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
|
|
108
|
+
| [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md) | 5 — capture `delegate()` disposers when scope < page | `warn` |
|
|
109
|
+
| [`no-nested-mount`](docs/rules/no-nested-mount.md) | 6 — one `mount()` per root | `error` |
|
|
110
|
+
| [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global | `error` |
|
|
111
|
+
| [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
|
|
112
|
+
| [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
|
|
113
|
+
| [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
|
|
114
|
+
| [`ui-public-boundaries`](docs/rules/ui-public-boundaries.md) | — (cataloged classes/tokens; component anatomy on app elements) | `error` |
|
|
115
|
+
| [`ui-component-ownership`](docs/rules/ui-component-ownership.md) | — (configure components, never override them) | `error` |
|
|
116
|
+
| [`ui-composition`](docs/rules/ui-composition.md) | — (cataloged parents and zones) | `error` |
|
|
117
|
+
| [`ui-css-values`](docs/rules/ui-css-values.md) | — (cataloged property-specific value grammar) | `error` |
|
|
118
|
+
| [`ui-preferences`](docs/rules/ui-preferences.md) | — (application component choices) | `warn` |
|
|
119
|
+
| [`ui-wiring`](docs/rules/ui-wiring.md) | — (required setup and cleanup) | `warn` |
|
|
110
120
|
|
|
111
121
|
The "Hard Rule" column refers to the numbered rules in [`docs/ai/usage-guide.md`](../docs/ai/usage-guide.md) on the main kerf repo. `no-raw-with-dynamic-arg` and `ai-assistant-configs` don't map to numbered Hard Rules — the former creates an audit trail for every dynamic `raw()` call site (potential XSS); the latter checks that the bundled AI-assistant configs are installed and current. See [`docs/12-ai-assistant-configs.md`](../docs/12-ai-assistant-configs.md) on the main kerf repo for the AI-configs design.
|
|
112
122
|
|
|
@@ -121,6 +131,7 @@ When a real bug ships that the existing defense stack misses AND a new lint rule
|
|
|
121
131
|
```bash
|
|
122
132
|
npm install
|
|
123
133
|
npm test
|
|
134
|
+
npm run test:eslint-matrix # real packed-plugin loading on every supported ESLint major
|
|
124
135
|
```
|
|
125
136
|
|
|
126
137
|
The AST rules' test suites use ESLint's `RuleTester` with `@typescript-eslint/parser`. The `ai-assistant-configs` tests are filesystem-driven — they build temp project roots with fixture `node_modules/kerfjs/ai/` bundles, drive the classifier directly, and use the real ESLint API to prove plain lint is read-only while explicit `--fix` performs the external write.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# `ui-component-ownership`
|
|
2
|
+
|
|
3
|
+
Components own their styles and are configured, never overridden. This rule
|
|
4
|
+
keeps application and component-package code from reaching into another
|
|
5
|
+
package's component through its variables:
|
|
6
|
+
|
|
7
|
+
- `KUI-L020` — a string, template, object key, or `style` value references a
|
|
8
|
+
component's private `--_<root>-*` variable (`--_kui-list-gap`), or any
|
|
9
|
+
`--_kui-*` variable. Private variables are implementation; reading or writing
|
|
10
|
+
them breaks when the component changes.
|
|
11
|
+
- `KUI-L021` — code assigns a component token that a typed prop sets, in a
|
|
12
|
+
`style` string (`style="--kui-list-gap: 4px"`), a style object key
|
|
13
|
+
(`style={{ '--kui-floating-toolbar-inset': '0' }}`), or
|
|
14
|
+
`style.setProperty('--kui-disclosure-arrow-size', …)`. Set the prop instead
|
|
15
|
+
(`<List gap="xs" />`).
|
|
16
|
+
|
|
17
|
+
Both messages name the owning component and end with the same guidance: if no
|
|
18
|
+
configuration covers the need, report the component gap to the owning package
|
|
19
|
+
(open a feature request) instead of overriding it.
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
// KUI-L020 / KUI-L021
|
|
23
|
+
<div style="--_kui-list-gap: 2px; --kui-list-gap: 4px">…</div>;
|
|
24
|
+
|
|
25
|
+
// Configure the component instead.
|
|
26
|
+
<List gap="xs">…</List>;
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Ownership comes from the loaded composition catalogs, so third-party component
|
|
30
|
+
packages declared in `.kerf-ui-profile.json` get the same protection. A file
|
|
31
|
+
inside the package that owns a component (the nearest `package.json` names the
|
|
32
|
+
catalog entry's `package`) may use that component's own private variables and
|
|
33
|
+
tokens. Reading a public token (`var(--kui-list-divider-color)`) and setting a
|
|
34
|
+
theme token with no equivalent prop remain configuration and are not reported.
|
|
35
|
+
|
|
36
|
+
CSS files are outside ESLint's reach: `kerf-ui-analyze` (and `kerf-ui-doctor`)
|
|
37
|
+
report the same `KUI-L020` / `KUI-L021` ids in stylesheets, plus `KUI-L019`
|
|
38
|
+
(a rule whose subject is a cataloged component) and `KUI-L022` (a hook class
|
|
39
|
+
on a component's root). See `@kerfjs/ui/docs/ui-analyzer.md`.
|
|
40
|
+
|
|
41
|
+
Exact profile exceptions may name either id. The rule reads
|
|
42
|
+
`@kerfjs/ui/ai/component-composition.json` by default; see the plugin README for
|
|
43
|
+
alternate catalog settings.
|
|
@@ -2,4 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Reports cataloged components listed in an application UI profile preference's `avoid` set and points to the package-qualified `preferred` component. The stable diagnostic is `KUI-L301`.
|
|
4
4
|
|
|
5
|
+
A component matches whether it is rendered through an imported export (`<WaButtonGroup />`) or written directly as its custom-element tag (`<wa-dropdown>`), which resolves through the selection catalog's `customElement`. The `@kerfjs/ui` package defaults avoid every `Discouraged` Web Awesome element, so each of these reports and names the Kerf component to render instead:
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<wa-dropdown>…</wa-dropdown> // KUI-L301: prefer @kerfjs/ui:popup-menu
|
|
9
|
+
<wa-select>…</wa-select> // KUI-L301: prefer @kerfjs/ui:select
|
|
10
|
+
<wa-tab-group>…</wa-tab-group> // KUI-L301: prefer @kerfjs/ui:tab-bar
|
|
11
|
+
<wa-icon name="gear" /> // KUI-L301: prefer @kerfjs/ui:lucide-icon
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Supported Web Awesome elements (`<wa-button>`, `<wa-dialog>`, the encouraged `<wa-popup>`) and unknown custom elements are not reported. The source file of a component declared in a profile catalog is exempt: that wrapper owns the element it renders, and the preference applies to its callers.
|
|
15
|
+
|
|
5
16
|
`recommended-ui` treats the rule as a warning; `strict-ui` treats it as an error. Use a narrowly targeted `KUI-L301` profile exception when a documented application boundary cannot follow the preference. Component substitutions are not autofixed because their props and interaction contracts can differ.
|
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
# `ui-public-boundaries`
|
|
2
2
|
|
|
3
|
-
Rejects `kui-*` classes and `--kui-*` custom properties that are not listed in the versioned composition catalog. This keeps application code out of private component anatomy while allowing cataloged styling seams.
|
|
3
|
+
Rejects `kui-*` classes and `--kui-*` custom properties that are not listed in the versioned composition catalog, and cataloged component classes written onto elements the application owns. This keeps application code out of private component anatomy while allowing cataloged styling seams.
|
|
4
4
|
|
|
5
|
-
Diagnostics are stable: `KUI-L101` identifies a private/unknown class
|
|
5
|
+
Diagnostics are stable: `KUI-L101` identifies a private/unknown class, `KUI-L102` an unknown token, and `KUI-L103` a component's anatomy class placed on an application-owned element. Exact profile exceptions can name any of these ids. The rule deliberately does not rewrite selectors, tokens, or markup because there is no generally safe replacement.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Component anatomy on your own element (`KUI-L103`)
|
|
8
|
+
|
|
9
|
+
A public class such as `kui-toolbar` or `kui-pane__content` is the rendered anatomy of a component. Writing it onto your own element recreates the component by class: the element inherits (and invites you to restyle) the component's CSS without its props, behavior, accessibility, or future fixes. Components own their styles; render and configure them instead.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
<div class="kui-toolbar">…</div> // KUI-L103: render `Toolbar`
|
|
13
|
+
<aside class="kui-pane"><nav class="kui-pane__content"> // KUI-L103: render `Pane`
|
|
14
|
+
<wa-dropdown class="kui-popup-menu">…</wa-dropdown> // KUI-L103: render `PopupMenu`
|
|
15
|
+
<Text class="kui-toolbar-text" /> // KUI-L103: render `ToolbarText`
|
|
16
|
+
<div class="kui-content-item">…</div> // KUI-L103: render `ContentItem`
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The catalog marks the classes an application may place with `boundaries.placeableClasses`; these stay allowed:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
<div id="app" class="kui-app-root" /> // document root
|
|
23
|
+
<main class="kui-content kui-scroll-owner" /> // layout utilities
|
|
24
|
+
<footer class="kui-control-cluster kui-content-item" /> // item geometry on a non-div carrier
|
|
25
|
+
<Toolbar className="kui-toolbar" /> // a component's own element
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A placeable class can still be a component's own root. When a catalog entry names the `boundaries.rootElement` its component renders around its placeable classes, a plain element of that tag carrying them is the component recreated and is reported: `<div class="kui-content-item">` is exactly what `ContentItem` renders. The class stays placeable on any other carrier — a `<ul>`, a `Text`, or a control-cluster `<footer>` that must carry the geometry itself. `@kerfjs/ui`'s own `check:guidance` derives its repository check from the same catalog fields, so the two cannot disagree.
|
|
29
|
+
|
|
30
|
+
The component a diagnostic names is the export named after the class's block, tried from the whole block down to its last word (`kui-toolbar-action-link` → `ToolbarActionLink`, `kui-pane__content` → `Pane`, `acme-meter__bar` → `Meter`), else the entry's own component, else every component the entry exports. That rule ships once, as `@kerfjs/ui/ai/component-class-owners.cjs`; the rule loads it beside `application-ui-profile-sync.cjs`, and `@kerfjs/ui`'s generated `ai/components/*.md` pages render their "render X instead" lines from the same file, so the lint message and the component reference always name the same component. An `@kerfjs/ui` too old to ship it fails the contract load (`KUI-L090`) rather than guessing.
|
|
31
|
+
|
|
32
|
+
## Component packages
|
|
33
|
+
|
|
34
|
+
`KUI-L103` is not limited to `kui-*` classes. Every application or third-party composition catalog your `.kerf-ui-profile.json` declares under `catalogs` contributes its entries' public classes the same way: an `acme-meter` class on your own `<div>` is reported with the package's component to render (`Meter`), its `boundaries.placeableClasses` stay allowed, and its `boundaries.rootElement` reports a placeable class on a plain element of the tag the component renders. Component packages declare these fields in `kerf.components.json`; see the component-packages guide (`docs/13-component-packages.md` §13.7). The first catalog to list a class owns it, so `@kerfjs/ui`'s catalog wins over a declared one. `KUI-L101` (private/unknown class) still applies only to `kui-*` names.
|
|
35
|
+
|
|
36
|
+
String class values are inspected in `class` / `className` literals and in a template literal's static, whitespace-delimited names (``class={`kui-content-item ${extra}`}``); a name an interpolation completes (`` `kui-toolbar-${size}` ``) is not known and is skipped.
|
|
37
|
+
|
|
38
|
+
The no-build `kerfjs/html` tagged template writes the same markup without JSX, so its `class` attributes carry the same `KUI-L101` / `KUI-L103` contract. The rule reads the template's static parts by the runtime's own rules: a complete static value (`class="kui-pane"`, `class='…'`, or unquoted) counts, tag and attribute names are case-insensitive, and the element is the tag the markup names, so a plain `<div class="kui-content-item">` is `ContentItem` recreated there too. A hole is a whole attribute value (`class=${cls}`), so the class it supplies is unknown and skipped; comments, text, and tags or attributes whose name is a hole are not inspected. Only the `html` tag imported from `kerfjs/html` (by name, alias, or namespace) is read.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { html } from 'kerfjs/html';
|
|
42
|
+
html`<aside class="kui-pane">…</aside>`; // KUI-L103: render `Pane`
|
|
43
|
+
html`<div class="kui-content-item">…</div>`; // KUI-L103: render `ContentItem`
|
|
44
|
+
html`<ul class="kui-content-item">…</ul>`; // allowed: placeable on a <ul>
|
|
45
|
+
html`<aside class=${paneClass}>…</aside>`; // not inspected: the value is a hole
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The rule reads `@kerfjs/ui/ai/component-composition.json` by default. See the plugin README for alternate catalog settings used by monorepos and package authors.
|
package/docs/rules/ui-wiring.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
Checks wiring obligations published by the versioned composition catalog. `KUI-L401` reports a used component whose required helper or side-effect registration import is absent. `KUI-L402` reports a helper call whose disposer is discarded.
|
|
4
4
|
|
|
5
|
-
Assign, return, or otherwise retain a disposer when the binding has a shorter lifetime than the page. A `void` call is accepted as an explicit page-lifetime choice. The rule derives valid helper sources from each
|
|
5
|
+
Assign, return, or otherwise retain a disposer when the binding has a shorter lifetime than the page. A `void` call is accepted as an explicit page-lifetime choice. The rule derives valid helper sources from each component-catalog entry's `publicExports` and `wiring[].import` facts, so aliases, root named imports, root namespaces, wiring-subpath named imports, and wiring-subpath namespaces resolve without a second hard-coded export table. It does not attempt flow-sensitive cleanup proof and offers no autofix.
|
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import preferAttrSelector from './lib/rules/prefer-attr-selector.js';
|
|
|
6
6
|
import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
|
|
7
7
|
import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
|
|
8
8
|
import requireDelegateDisposer from './lib/rules/require-delegate-disposer.js';
|
|
9
|
+
import uiComponentOwnership from './lib/rules/ui-component-ownership.js';
|
|
9
10
|
import uiComposition from './lib/rules/ui-composition.js';
|
|
10
11
|
import uiCssValues from './lib/rules/ui-css-values.js';
|
|
11
12
|
import uiPreferences from './lib/rules/ui-preferences.js';
|
|
@@ -13,7 +14,7 @@ import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
|
|
|
13
14
|
import uiWiring from './lib/rules/ui-wiring.js';
|
|
14
15
|
|
|
15
16
|
const plugin = {
|
|
16
|
-
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.
|
|
17
|
+
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.58' },
|
|
17
18
|
rules: {
|
|
18
19
|
'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
|
|
19
20
|
'no-raw-with-dynamic-arg': noRawWithDynamicArg,
|
|
@@ -23,6 +24,7 @@ const plugin = {
|
|
|
23
24
|
'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
|
|
24
25
|
'prefer-attr-selector': preferAttrSelector,
|
|
25
26
|
'ai-assistant-configs': aiAssistantConfigs,
|
|
27
|
+
'ui-component-ownership': uiComponentOwnership,
|
|
26
28
|
'ui-composition': uiComposition,
|
|
27
29
|
'ui-css-values': uiCssValues,
|
|
28
30
|
'ui-preferences': uiPreferences,
|
|
@@ -63,6 +65,7 @@ plugin.configs.recommended = {
|
|
|
63
65
|
const recommendedUiRules = {
|
|
64
66
|
...recommendedRules,
|
|
65
67
|
'kerfjs/ui-public-boundaries': 'error',
|
|
68
|
+
'kerfjs/ui-component-ownership': 'error',
|
|
66
69
|
'kerfjs/ui-composition': 'error',
|
|
67
70
|
'kerfjs/ui-css-values': 'error',
|
|
68
71
|
'kerfjs/ui-preferences': 'warn',
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The `class` attributes a `kerfjs/html` tagged template writes, read from its
|
|
2
|
+
// static parts. It follows the runtime's static-part rules
|
|
3
|
+
// (`src/utils/parseTemplate.ts`): markup is text, open tags, and comments; a
|
|
4
|
+
// `${…}` hole is a whole attribute value (`class=${x}` / `class="${x}"`), so
|
|
5
|
+
// a class it supplies is unknown and skipped. A complete static value
|
|
6
|
+
// (`class="kui-pane"`) counts in full; when a hole interrupts a value (which
|
|
7
|
+
// the runtime rejects as a partial value) only the whitespace-delimited names
|
|
8
|
+
// the static text completes count, as for a JSX template literal. Tags whose
|
|
9
|
+
// name is a hole, and holes inside comments, are not markup and are skipped.
|
|
10
|
+
|
|
11
|
+
const HOLE = '\0';
|
|
12
|
+
|
|
13
|
+
const isSpace = (ch) => /\s/.test(ch);
|
|
14
|
+
|
|
15
|
+
// `{ quasi, tag, value }` for every `class` attribute, where `quasi` is the
|
|
16
|
+
// index of the static part the attribute name starts in, `tag` the lowercased
|
|
17
|
+
// element name, and `value` the attribute value with each hole as `\0`.
|
|
18
|
+
export function htmlTemplateClassAttributes(strings) {
|
|
19
|
+
const text = strings.join(HOLE);
|
|
20
|
+
// Offset where each static part starts in `text`.
|
|
21
|
+
const starts = [];
|
|
22
|
+
let offset = 0;
|
|
23
|
+
for (const part of strings) {
|
|
24
|
+
starts.push(offset);
|
|
25
|
+
offset += part.length + 1;
|
|
26
|
+
}
|
|
27
|
+
const quasiAt = (position) => {
|
|
28
|
+
let index = 0;
|
|
29
|
+
while (index + 1 < starts.length && starts[index + 1] <= position)
|
|
30
|
+
index += 1;
|
|
31
|
+
return index;
|
|
32
|
+
};
|
|
33
|
+
const attributes = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i < text.length) {
|
|
36
|
+
if (text[i] !== '<') {
|
|
37
|
+
i += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (text.startsWith('<!--', i)) {
|
|
41
|
+
const end = text.indexOf('-->', i + 4);
|
|
42
|
+
i = end === -1 ? text.length : end + 3;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!/[a-zA-Z]/.test(text[i + 1] ?? '')) {
|
|
46
|
+
// A closing tag, `<!DOCTYPE`, `<?…`, or a tag-name hole: skip it whole.
|
|
47
|
+
if (/[!/?]/.test(text[i + 1] ?? '')) {
|
|
48
|
+
const end = text.indexOf('>', i + 1);
|
|
49
|
+
i = end === -1 ? text.length : end + 1;
|
|
50
|
+
} else i += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let j = i + 1;
|
|
54
|
+
while (j < text.length && !/[\s/>]/.test(text[j]) && text[j] !== HOLE)
|
|
55
|
+
j += 1;
|
|
56
|
+
const tag = text.slice(i + 1, j).toLowerCase();
|
|
57
|
+
// A hole completing the tag name (`<div${x}>`) makes it unknown.
|
|
58
|
+
const knownTag = text[j] !== HOLE;
|
|
59
|
+
// Attributes until the tag closes.
|
|
60
|
+
while (j < text.length && text[j] !== '>') {
|
|
61
|
+
if (isSpace(text[j]) || text[j] === '/' || text[j] === HOLE) {
|
|
62
|
+
j += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const nameStart = j;
|
|
66
|
+
while (j < text.length && !/[\s"'<>/=]/.test(text[j]) && text[j] !== HOLE)
|
|
67
|
+
j += 1;
|
|
68
|
+
if (j === nameStart) {
|
|
69
|
+
// A stray quote or `=` with no name; step past it.
|
|
70
|
+
j += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const name = text.slice(nameStart, j).toLowerCase();
|
|
74
|
+
// A hole completing the name (`data-${x}=…`) makes it unknown.
|
|
75
|
+
const knownName = text[j] !== HOLE;
|
|
76
|
+
let k = j;
|
|
77
|
+
while (k < text.length && isSpace(text[k])) k += 1;
|
|
78
|
+
if (text[k] !== '=') continue;
|
|
79
|
+
k += 1;
|
|
80
|
+
while (k < text.length && isSpace(text[k])) k += 1;
|
|
81
|
+
let value;
|
|
82
|
+
if (text[k] === '"' || text[k] === "'") {
|
|
83
|
+
const end = text.indexOf(text[k], k + 1);
|
|
84
|
+
value = text.slice(k + 1, end === -1 ? text.length : end);
|
|
85
|
+
j = end === -1 ? text.length : end + 1;
|
|
86
|
+
} else {
|
|
87
|
+
let end = k;
|
|
88
|
+
while (end < text.length && !/[\s>]/.test(text[end])) end += 1;
|
|
89
|
+
value = text.slice(k, end);
|
|
90
|
+
j = end;
|
|
91
|
+
}
|
|
92
|
+
if (knownTag && knownName && name === 'class')
|
|
93
|
+
attributes.push({ quasi: quasiAt(nameStart), tag, value });
|
|
94
|
+
}
|
|
95
|
+
i = j + 1;
|
|
96
|
+
}
|
|
97
|
+
return attributes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The class names a value states outright: whitespace-delimited names no hole
|
|
101
|
+
// touches.
|
|
102
|
+
export function staticClassNames(value) {
|
|
103
|
+
return value.split(/\s+/).filter((name) => name && !name.includes(HOLE));
|
|
104
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
isExcepted,
|
|
6
|
+
loadUiContract,
|
|
7
|
+
UI_CONTRACT_LOAD_CODE,
|
|
8
|
+
UI_RULE_SCHEMA,
|
|
9
|
+
} from '../ui-contract.js';
|
|
10
|
+
|
|
11
|
+
const PRIVATE_CODE = 'KUI-L020';
|
|
12
|
+
const TYPED_CODE = 'KUI-L021';
|
|
13
|
+
|
|
14
|
+
const kebab = (value) =>
|
|
15
|
+
value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
16
|
+
|
|
17
|
+
// The same catalog-derived facts `kerf-ui-analyze` uses for KUI-L020 /
|
|
18
|
+
// KUI-L021 (`@kerfjs/ui/analyzer/component-ownership.mjs`): every root-like
|
|
19
|
+
// public class names a component, `--_<root>-*` is that component's private
|
|
20
|
+
// variable, and `--<root>-<prop>` is the token its typed `<prop>` sets.
|
|
21
|
+
const factsCache = new WeakMap();
|
|
22
|
+
function ownershipFacts(contract) {
|
|
23
|
+
if (factsCache.has(contract)) return factsCache.get(contract);
|
|
24
|
+
const privatePrefixes = [];
|
|
25
|
+
const typedTokens = new Map();
|
|
26
|
+
for (const entry of contract.entries.values()) {
|
|
27
|
+
const boundaries = entry.boundaries ?? {};
|
|
28
|
+
const roots = [
|
|
29
|
+
...new Set(
|
|
30
|
+
[boundaries.rootClass, ...(boundaries.publicClasses ?? [])].filter(
|
|
31
|
+
(name) => name && !name.includes('__') && !name.includes('--'),
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
];
|
|
35
|
+
const tokens = new Set(boundaries.publicTokens ?? []);
|
|
36
|
+
for (const root of roots) {
|
|
37
|
+
privatePrefixes.push({ prefix: `--_${root}`, entry });
|
|
38
|
+
for (const contractProp of entry.cssValueProps ?? []) {
|
|
39
|
+
const [head] = contractProp.path.split('[].');
|
|
40
|
+
const token = `--${root}-${kebab(head)}`;
|
|
41
|
+
if (tokens.has(token) && !typedTokens.has(token))
|
|
42
|
+
typedTokens.set(token, { entry, path: contractProp.path });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
privatePrefixes.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
47
|
+
const facts = { privatePrefixes, typedTokens };
|
|
48
|
+
factsCache.set(contract, facts);
|
|
49
|
+
return facts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const packageNames = new Map();
|
|
53
|
+
function ownPackage(filename) {
|
|
54
|
+
for (
|
|
55
|
+
let directory = dirname(resolve(filename));
|
|
56
|
+
;
|
|
57
|
+
directory = dirname(directory)
|
|
58
|
+
) {
|
|
59
|
+
if (packageNames.has(directory)) return packageNames.get(directory);
|
|
60
|
+
const manifest = resolve(directory, 'package.json');
|
|
61
|
+
if (existsSync(manifest)) {
|
|
62
|
+
let name;
|
|
63
|
+
try {
|
|
64
|
+
name = JSON.parse(readFileSync(manifest, 'utf8')).name;
|
|
65
|
+
} catch {
|
|
66
|
+
name = undefined;
|
|
67
|
+
}
|
|
68
|
+
if (typeof name === 'string') {
|
|
69
|
+
packageNames.set(directory, name);
|
|
70
|
+
return name;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (dirname(directory) === directory) return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const gap = (entry) =>
|
|
78
|
+
`If no configuration covers this need, report the component gap to ${entry?.package ?? '@kerfjs/ui'} (open a feature request) instead of overriding it.`;
|
|
79
|
+
|
|
80
|
+
export default {
|
|
81
|
+
meta: {
|
|
82
|
+
type: 'problem',
|
|
83
|
+
docs: {
|
|
84
|
+
description:
|
|
85
|
+
"Keep code from reaching into a Kerf UI component's private variables or overriding a token its typed prop configures.",
|
|
86
|
+
url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-component-ownership.md',
|
|
87
|
+
},
|
|
88
|
+
schema: UI_RULE_SCHEMA,
|
|
89
|
+
messages: {
|
|
90
|
+
config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
|
|
91
|
+
private: `${PRIVATE_CODE}: \`{{name}}\` is {{owner}}'s private variable; components own their styles. Configure it through its typed props, variants, or public tokens. {{gap}}`,
|
|
92
|
+
typed: `${TYPED_CODE}: \`{{name}}\` is what {{component}}'s typed \`{{prop}}\` prop sets; set the prop on {{component}} instead of overriding the token. {{gap}}`,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
create(context) {
|
|
96
|
+
const contract = loadUiContract(context);
|
|
97
|
+
const filename = context.filename ?? context.getFilename();
|
|
98
|
+
let configured = false;
|
|
99
|
+
if (contract.error)
|
|
100
|
+
return {
|
|
101
|
+
Program(node) {
|
|
102
|
+
if (configured) return;
|
|
103
|
+
configured = true;
|
|
104
|
+
context.report({
|
|
105
|
+
node,
|
|
106
|
+
messageId: 'config',
|
|
107
|
+
data: { error: contract.error },
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const facts = ownershipFacts(contract);
|
|
112
|
+
const own = ownPackage(filename);
|
|
113
|
+
const checkPrivate = !isExcepted(contract, PRIVATE_CODE, filename);
|
|
114
|
+
const checkTyped = !isExcepted(contract, TYPED_CODE, filename);
|
|
115
|
+
|
|
116
|
+
const privateOwner = (name) => {
|
|
117
|
+
const match = facts.privatePrefixes.find(
|
|
118
|
+
({ prefix }) => name === prefix || name.startsWith(`${prefix}-`),
|
|
119
|
+
);
|
|
120
|
+
if (match) return match.entry;
|
|
121
|
+
return name.startsWith('--_kui-') ? null : undefined;
|
|
122
|
+
};
|
|
123
|
+
const reportPrivate = (node, text) => {
|
|
124
|
+
if (!checkPrivate) return;
|
|
125
|
+
for (const [name] of text.matchAll(/--_[a-z0-9_-]+/gi)) {
|
|
126
|
+
const owner = privateOwner(name);
|
|
127
|
+
if (owner === undefined || (owner && owner.package === own)) continue;
|
|
128
|
+
context.report({
|
|
129
|
+
node,
|
|
130
|
+
messageId: 'private',
|
|
131
|
+
data: {
|
|
132
|
+
name,
|
|
133
|
+
owner: owner ? owner.name : 'a Kerf component',
|
|
134
|
+
gap: gap(owner),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const reportTyped = (node, token) => {
|
|
140
|
+
if (!checkTyped) return;
|
|
141
|
+
const typed = facts.typedTokens.get(token);
|
|
142
|
+
if (!typed || typed.entry.package === own) return;
|
|
143
|
+
context.report({
|
|
144
|
+
node,
|
|
145
|
+
messageId: 'typed',
|
|
146
|
+
data: {
|
|
147
|
+
name: token,
|
|
148
|
+
component: typed.entry.name,
|
|
149
|
+
prop: typed.path,
|
|
150
|
+
gap: gap(typed.entry),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
const inspectText = (node, text) => {
|
|
155
|
+
if (typeof text !== 'string') return;
|
|
156
|
+
reportPrivate(node, text);
|
|
157
|
+
// `--token: value` inside a style string assigns the token.
|
|
158
|
+
for (const [, token] of text.matchAll(/(--[a-z0-9-]+)\s*:/gi))
|
|
159
|
+
reportTyped(node, token);
|
|
160
|
+
};
|
|
161
|
+
const keyName = (property) =>
|
|
162
|
+
property.computed
|
|
163
|
+
? property.key.type === 'Literal'
|
|
164
|
+
? property.key.value
|
|
165
|
+
: undefined
|
|
166
|
+
: (property.key.name ?? property.key.value);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
Literal(node) {
|
|
170
|
+
if (node.parent?.type === 'Property' && node.parent.key === node)
|
|
171
|
+
return;
|
|
172
|
+
inspectText(node, node.value);
|
|
173
|
+
},
|
|
174
|
+
TemplateElement(node) {
|
|
175
|
+
inspectText(node, node.value.raw);
|
|
176
|
+
},
|
|
177
|
+
// `style={{ '--kui-list-gap': … }}` and any object keyed by a token.
|
|
178
|
+
Property(node) {
|
|
179
|
+
const key = keyName(node);
|
|
180
|
+
if (typeof key !== 'string' || !key.startsWith('--')) return;
|
|
181
|
+
reportPrivate(node.key, key);
|
|
182
|
+
reportTyped(node.key, key);
|
|
183
|
+
},
|
|
184
|
+
// `element.style.setProperty('--kui-list-gap', …)`.
|
|
185
|
+
CallExpression(node) {
|
|
186
|
+
const callee = node.callee;
|
|
187
|
+
const [first] = node.arguments;
|
|
188
|
+
if (
|
|
189
|
+
callee.type === 'MemberExpression' &&
|
|
190
|
+
!callee.computed &&
|
|
191
|
+
callee.property.name === 'setProperty' &&
|
|
192
|
+
first?.type === 'Literal' &&
|
|
193
|
+
typeof first.value === 'string'
|
|
194
|
+
)
|
|
195
|
+
reportTyped(first, first.value);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
1
3
|
import {
|
|
4
|
+
customElementKey,
|
|
2
5
|
importRegistry,
|
|
3
6
|
isExcepted,
|
|
4
7
|
jsxKey,
|
|
@@ -39,7 +42,12 @@ export default {
|
|
|
39
42
|
},
|
|
40
43
|
JSXOpeningElement(node) {
|
|
41
44
|
if (!registry || isExcepted(contract, CODE, filename)) return;
|
|
42
|
-
|
|
45
|
+
// A declared component's own source is the wrapper that owns the
|
|
46
|
+
// element it renders; the preference governs its callers.
|
|
47
|
+
if (contract.componentSources.has(resolve(filename))) return;
|
|
48
|
+
const actual =
|
|
49
|
+
jsxKey(node.name, registry, contract) ??
|
|
50
|
+
customElementKey(node.name, contract);
|
|
43
51
|
if (!actual) return;
|
|
44
52
|
for (const [concept, preference] of Object.entries(
|
|
45
53
|
contract.profile.preferences ?? {},
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
import {
|
|
2
|
+
importRegistry,
|
|
2
3
|
isExcepted,
|
|
4
|
+
jsxKey,
|
|
3
5
|
loadUiContract,
|
|
4
6
|
UI_CONTRACT_LOAD_CODE,
|
|
5
7
|
UI_RULE_SCHEMA,
|
|
6
8
|
} from '../ui-contract.js';
|
|
9
|
+
import {
|
|
10
|
+
htmlTemplateClassAttributes,
|
|
11
|
+
staticClassNames,
|
|
12
|
+
} from '../html-template-classes.js';
|
|
7
13
|
|
|
8
14
|
const CLASS_CODE = 'KUI-L101';
|
|
9
15
|
const TOKEN_CODE = 'KUI-L102';
|
|
16
|
+
const PLACEMENT_CODE = 'KUI-L103';
|
|
17
|
+
|
|
18
|
+
const HTML_SOURCE = 'kerfjs/html';
|
|
19
|
+
|
|
20
|
+
// The `html` tag imported from `kerfjs/html`, by name or through a namespace.
|
|
21
|
+
function isHtmlTag(tag, registry) {
|
|
22
|
+
if (tag.type === 'Identifier') {
|
|
23
|
+
const helper = registry.helpers.get(tag.name);
|
|
24
|
+
return helper?.imported === 'html' && helper.source === HTML_SOURCE;
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
tag.type === 'MemberExpression' &&
|
|
28
|
+
!tag.computed &&
|
|
29
|
+
tag.object.type === 'Identifier' &&
|
|
30
|
+
tag.property.type === 'Identifier' &&
|
|
31
|
+
tag.property.name === 'html' &&
|
|
32
|
+
registry.namespaces.get(tag.object.name) === HTML_SOURCE
|
|
33
|
+
);
|
|
34
|
+
}
|
|
10
35
|
|
|
11
36
|
export default {
|
|
12
37
|
meta: {
|
|
@@ -23,21 +48,70 @@ export default {
|
|
|
23
48
|
'KUI-L101: `{{name}}` is private or uncataloged Kerf UI anatomy. Use a cataloged public class, prop, token, or component instead.',
|
|
24
49
|
token:
|
|
25
50
|
'KUI-L102: `{{name}}` is not a cataloged public Kerf UI token. Use a public semantic/component token.',
|
|
51
|
+
component:
|
|
52
|
+
"KUI-L103: `{{name}}` is {{component}}'s rendered anatomy, not a class to place on your own element. Render {{render}} and configure it through its props; components own their styles.",
|
|
53
|
+
componentRoot:
|
|
54
|
+
'KUI-L103: a plain `<{{element}}>` carrying `{{name}}` is exactly what {{component}} renders. Render {{render}} instead; the class stays placeable only on another carrier element (such as a `<ul>` or a `<footer>`).',
|
|
26
55
|
},
|
|
27
56
|
},
|
|
28
57
|
create(context) {
|
|
29
58
|
const contract = loadUiContract(context);
|
|
30
59
|
const filename = context.filename ?? context.getFilename();
|
|
31
60
|
let configured = false;
|
|
32
|
-
|
|
61
|
+
let registry;
|
|
62
|
+
// A cataloged component's anatomy class on an element the application
|
|
63
|
+
// writes recreates the component by class instead of rendering it. The
|
|
64
|
+
// component's own element (`<Toolbar className="kui-toolbar">`) is exempt.
|
|
65
|
+
// A placeable class whose entry names a `rootElement` is reported only on
|
|
66
|
+
// a plain element of that tag (`<div class="kui-content-item">` is
|
|
67
|
+
// ContentItem); another carrier element keeps it.
|
|
68
|
+
// `element` is the JSX opening element (or `{ tag }` for markup a
|
|
69
|
+
// `kerfjs/html` template writes) that carries the class.
|
|
70
|
+
const inspectPlacement = (node, names, element) => {
|
|
71
|
+
if (isExcepted(contract, PLACEMENT_CODE, filename)) return;
|
|
72
|
+
const opening = element?.type === 'JSXOpeningElement';
|
|
73
|
+
const renderedKey =
|
|
74
|
+
registry && opening
|
|
75
|
+
? jsxKey(element.name, registry, contract)
|
|
76
|
+
: undefined;
|
|
77
|
+
const intrinsicTag = opening
|
|
78
|
+
? element.name.type === 'JSXIdentifier'
|
|
79
|
+
? element.name.name
|
|
80
|
+
: undefined
|
|
81
|
+
: element?.tag;
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const owner = contract.componentClasses?.get(name);
|
|
84
|
+
if (!owner || owner.key === renderedKey) continue;
|
|
85
|
+
if (owner.element && owner.element !== intrinsicTag) continue;
|
|
86
|
+
context.report({
|
|
87
|
+
node,
|
|
88
|
+
messageId: owner.element ? 'componentRoot' : 'component',
|
|
89
|
+
data: {
|
|
90
|
+
name,
|
|
91
|
+
element: owner.element,
|
|
92
|
+
component: owner.render.join(' / '),
|
|
93
|
+
render: owner.render.map((item) => `\`${item}\``).join(' or '),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const inspect = (
|
|
99
|
+
node,
|
|
100
|
+
value,
|
|
101
|
+
classContext = false,
|
|
102
|
+
tokens = true,
|
|
103
|
+
element = node.parent,
|
|
104
|
+
) => {
|
|
33
105
|
if (typeof value !== 'string') return;
|
|
106
|
+
const names = classContext ? value.split(/\s+/).filter(Boolean) : [];
|
|
34
107
|
if (classContext && !isExcepted(contract, CLASS_CODE, filename))
|
|
35
|
-
for (const name of
|
|
36
|
-
.
|
|
37
|
-
.filter((item) => item.startsWith('kui-')))
|
|
38
|
-
if (!contract.publicClasses.has(name))
|
|
108
|
+
for (const name of names)
|
|
109
|
+
if (name.startsWith('kui-') && !contract.publicClasses.has(name))
|
|
39
110
|
context.report({ node, messageId: 'class', data: { name } });
|
|
40
|
-
|
|
111
|
+
// Placement covers every cataloged class, not only `kui-*`: a declared
|
|
112
|
+
// component package's anatomy (`acme-meter`) is checked the same way.
|
|
113
|
+
if (classContext) inspectPlacement(node, names, element);
|
|
114
|
+
if (tokens && !isExcepted(contract, TOKEN_CODE, filename))
|
|
41
115
|
for (const match of value.matchAll(/--kui-[a-z0-9-]+/g))
|
|
42
116
|
if (!contract.publicTokens.has(match[0]))
|
|
43
117
|
context.report({
|
|
@@ -56,6 +130,8 @@ export default {
|
|
|
56
130
|
data: { error: contract.error },
|
|
57
131
|
});
|
|
58
132
|
}
|
|
133
|
+
if (!contract.error)
|
|
134
|
+
registry = importRegistry(node, contract, filename);
|
|
59
135
|
},
|
|
60
136
|
JSXAttribute(node) {
|
|
61
137
|
if (contract.error || !['class', 'className'].includes(node.name?.name))
|
|
@@ -67,6 +143,43 @@ export default {
|
|
|
67
143
|
node.value.expression.type === 'Literal'
|
|
68
144
|
)
|
|
69
145
|
inspect(node, node.value.expression.value, true);
|
|
146
|
+
// A template literal's static, whitespace-delimited class names
|
|
147
|
+
// (`class={`kui-content-item ${extra}`}`); a name an interpolation
|
|
148
|
+
// completes (`kui-toolbar-${size}`) is not known and is skipped.
|
|
149
|
+
if (
|
|
150
|
+
node.value?.type === 'JSXExpressionContainer' &&
|
|
151
|
+
node.value.expression.type === 'TemplateLiteral'
|
|
152
|
+
)
|
|
153
|
+
inspect(
|
|
154
|
+
node,
|
|
155
|
+
staticClassNames(
|
|
156
|
+
node.value.expression.quasis
|
|
157
|
+
.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
|
|
158
|
+
.join('\0'),
|
|
159
|
+
).join(' '),
|
|
160
|
+
true,
|
|
161
|
+
// Its tokens are inspected once, by TemplateElement below.
|
|
162
|
+
false,
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
// `kerfjs/html` tagged templates write the same markup without JSX:
|
|
166
|
+
// their static `class` attributes are inspected like JSX `class`
|
|
167
|
+
// values, on the plain element that carries them. Tokens are left to
|
|
168
|
+
// TemplateElement below.
|
|
169
|
+
TaggedTemplateExpression(node) {
|
|
170
|
+
if (contract.error || !registry || !isHtmlTag(node.tag, registry))
|
|
171
|
+
return;
|
|
172
|
+
const { quasis } = node.quasi;
|
|
173
|
+
for (const { quasi, tag, value } of htmlTemplateClassAttributes(
|
|
174
|
+
quasis.map((item) => item.value.cooked ?? item.value.raw),
|
|
175
|
+
))
|
|
176
|
+
inspect(
|
|
177
|
+
quasis[quasi],
|
|
178
|
+
staticClassNames(value).join(' '),
|
|
179
|
+
true,
|
|
180
|
+
false,
|
|
181
|
+
{ tag },
|
|
182
|
+
);
|
|
70
183
|
},
|
|
71
184
|
Literal(node) {
|
|
72
185
|
if (!contract.error) inspect(node, node.value, false);
|
package/lib/ui-contract.js
CHANGED
|
@@ -125,6 +125,24 @@ function discoveredProfiles(cwd, filename, settings) {
|
|
|
125
125
|
return layers;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// `@kerfjs/ui`'s class-to-component rule ships beside its profile contract.
|
|
129
|
+
// An older `@kerfjs/ui` without it cannot report KUI-L103 consistently, so it
|
|
130
|
+
// fails the contract load instead of guessing.
|
|
131
|
+
function loadClassOwnership(profileContractPath) {
|
|
132
|
+
const path = resolve(
|
|
133
|
+
dirname(profileContractPath),
|
|
134
|
+
'component-class-owners.cjs',
|
|
135
|
+
);
|
|
136
|
+
if (!existsSync(path))
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Cannot find ${path}; install an @kerfjs/ui release that matches eslint-plugin-kerfjs.`,
|
|
139
|
+
);
|
|
140
|
+
const { componentClassOwnership } = loadCommonJs(path);
|
|
141
|
+
if (typeof componentClassOwnership !== 'function')
|
|
142
|
+
throw new Error(`${path} does not export componentClassOwnership.`);
|
|
143
|
+
return componentClassOwnership;
|
|
144
|
+
}
|
|
145
|
+
|
|
128
146
|
export function loadUiContract(context) {
|
|
129
147
|
const settings = context.settings?.kerfjs?.ui ?? {};
|
|
130
148
|
const cwd = settings.workspaceRoot ?? context.cwd ?? process.cwd();
|
|
@@ -135,7 +153,7 @@ export function loadUiContract(context) {
|
|
|
135
153
|
const catalog =
|
|
136
154
|
settings.catalog ??
|
|
137
155
|
readJson(
|
|
138
|
-
settings.catalogPath ?? packageAsset(cwd, 'component-
|
|
156
|
+
settings.catalogPath ?? packageAsset(cwd, 'component-composition.json'),
|
|
139
157
|
);
|
|
140
158
|
const selection =
|
|
141
159
|
settings.selectionCatalog ??
|
|
@@ -190,6 +208,20 @@ export function loadUiContract(context) {
|
|
|
190
208
|
// `<absolute source file>\0<Export>` to the entry key.
|
|
191
209
|
const packageExports = new Map();
|
|
192
210
|
const sourceExports = new Map();
|
|
211
|
+
// Source files of declared components: a wrapper's own source may render
|
|
212
|
+
// the element it wraps (Kerf's PopupMenu renders `wa-dropdown`).
|
|
213
|
+
const componentSources = new Set();
|
|
214
|
+
// Every cataloged entry whose public classes may be a component's rendered
|
|
215
|
+
// anatomy: the base catalog's (exports from the selection catalog) and each
|
|
216
|
+
// declared application/third-party catalog's (exports on the entry).
|
|
217
|
+
const classOwnerEntries = catalog.entries.map((entry) => ({
|
|
218
|
+
key: entry.key,
|
|
219
|
+
name: entry.name,
|
|
220
|
+
boundaries: entry.boundaries,
|
|
221
|
+
exports: (
|
|
222
|
+
selection.entries.find(({ id }) => id === entry.id)?.publicExports ?? []
|
|
223
|
+
).map(exportName),
|
|
224
|
+
}));
|
|
193
225
|
for (const { artifact, root } of profileCatalogs(
|
|
194
226
|
loadedProfile,
|
|
195
227
|
selection.package,
|
|
@@ -197,6 +229,12 @@ export function loadUiContract(context) {
|
|
|
197
229
|
for (const entry of artifact.entries ?? []) {
|
|
198
230
|
const key = entry.key ?? `${artifact.package}:${entry.id}`;
|
|
199
231
|
entries.set(key, entry);
|
|
232
|
+
classOwnerEntries.push({
|
|
233
|
+
key,
|
|
234
|
+
name: entry.name,
|
|
235
|
+
boundaries: entry.boundaries,
|
|
236
|
+
exports: (entry.publicExports ?? []).map(exportName),
|
|
237
|
+
});
|
|
200
238
|
for (const item of entry.publicExports ?? []) {
|
|
201
239
|
const name = exportName(item);
|
|
202
240
|
if (!name || !isComponentExport(name)) continue;
|
|
@@ -210,12 +248,17 @@ export function loadUiContract(context) {
|
|
|
210
248
|
: (entry.package ?? artifact.package);
|
|
211
249
|
packageExports.set(`${specifier}\0${name}`, key);
|
|
212
250
|
}
|
|
213
|
-
if (entry.source)
|
|
251
|
+
if (entry.source) {
|
|
214
252
|
sourceExports.set(`${resolve(root, entry.source)}\0${name}`, key);
|
|
253
|
+
componentSources.add(resolve(root, entry.source));
|
|
254
|
+
}
|
|
215
255
|
}
|
|
216
256
|
}
|
|
217
257
|
const imports = new Map();
|
|
218
258
|
const exports = new Map();
|
|
259
|
+
// Custom-element tag -> entry key, for elements authored directly in JSX
|
|
260
|
+
// (`<wa-dropdown>`) rather than through an imported component.
|
|
261
|
+
const customElements = new Map();
|
|
219
262
|
const helperSources = new Map();
|
|
220
263
|
const addHelperSource = (name, source) => {
|
|
221
264
|
const sources = helperSources.get(name) ?? new Set();
|
|
@@ -224,6 +267,7 @@ export function loadUiContract(context) {
|
|
|
224
267
|
};
|
|
225
268
|
for (const entry of selection.entries) {
|
|
226
269
|
const key = `${selection.package}:${entry.id}`;
|
|
270
|
+
if (entry.customElement) customElements.set(entry.customElement, key);
|
|
227
271
|
if (entry.delivery?.browserImport)
|
|
228
272
|
imports.set(entry.delivery.browserImport, key);
|
|
229
273
|
if (entry.delivery?.moduleImport)
|
|
@@ -242,12 +286,18 @@ export function loadUiContract(context) {
|
|
|
242
286
|
exports,
|
|
243
287
|
packageExports,
|
|
244
288
|
sourceExports,
|
|
289
|
+
componentSources,
|
|
290
|
+
customElements,
|
|
245
291
|
helperSources,
|
|
246
292
|
profile,
|
|
247
293
|
cwd,
|
|
248
294
|
publicClasses: new Set(
|
|
249
295
|
catalog.entries.flatMap((entry) => entry.boundaries.publicClasses),
|
|
250
296
|
),
|
|
297
|
+
componentClasses: componentClassOwners(
|
|
298
|
+
classOwnerEntries,
|
|
299
|
+
loadClassOwnership(profileContractPath),
|
|
300
|
+
),
|
|
251
301
|
publicTokens: new Set(
|
|
252
302
|
catalog.entries.flatMap((entry) => entry.boundaries.publicTokens),
|
|
253
303
|
),
|
|
@@ -262,6 +312,32 @@ export function loadUiContract(context) {
|
|
|
262
312
|
}
|
|
263
313
|
}
|
|
264
314
|
|
|
315
|
+
// Public classes that are a component's rendered anatomy — `@kerfjs/ui`'s
|
|
316
|
+
// `kui-*` classes and any class a declared application or third-party catalog
|
|
317
|
+
// lists (`acme-meter`) — mapped to the entry and the export that renders it.
|
|
318
|
+
// Which classes an entry owns, and which export renders each, is
|
|
319
|
+
// `@kerfjs/ui`'s shared rule (`ai/component-class-owners.cjs`), the same one
|
|
320
|
+
// its generated component reference pages use. The first catalog to claim a
|
|
321
|
+
// class owns it, so the base catalog wins over a declared one.
|
|
322
|
+
function componentClassOwners(classOwnerEntries, componentClassOwnership) {
|
|
323
|
+
const owners = new Map();
|
|
324
|
+
for (const entry of classOwnerEntries)
|
|
325
|
+
for (const { className, render, element } of componentClassOwnership({
|
|
326
|
+
name: entry.name,
|
|
327
|
+
exports: entry.exports,
|
|
328
|
+
publicClasses: entry.boundaries?.publicClasses,
|
|
329
|
+
placeableClasses: entry.boundaries?.placeableClasses,
|
|
330
|
+
rootElement: entry.boundaries?.rootElement,
|
|
331
|
+
}))
|
|
332
|
+
if (!owners.has(className))
|
|
333
|
+
owners.set(className, {
|
|
334
|
+
key: entry.key,
|
|
335
|
+
render,
|
|
336
|
+
...(element ? { element } : {}),
|
|
337
|
+
});
|
|
338
|
+
return owners;
|
|
339
|
+
}
|
|
340
|
+
|
|
265
341
|
export function importRegistry(program, contract, filename) {
|
|
266
342
|
const locals = new Map();
|
|
267
343
|
const namespaces = new Map();
|
|
@@ -405,6 +481,14 @@ export function jsxKey(name, registry, contract) {
|
|
|
405
481
|
return undefined;
|
|
406
482
|
}
|
|
407
483
|
|
|
484
|
+
// A custom element authored directly as a lowercase JSX tag resolves to its
|
|
485
|
+
// selection-catalog entry. Kept separate from `jsxKey` so composition rules
|
|
486
|
+
// keep treating raw elements as application-owned markup.
|
|
487
|
+
export function customElementKey(name, contract) {
|
|
488
|
+
if (name.type !== 'JSXIdentifier' || !name.name.includes('-')) return;
|
|
489
|
+
return contract.customElements?.get(name.name);
|
|
490
|
+
}
|
|
491
|
+
|
|
408
492
|
export function helperCall(callee, registry, contract) {
|
|
409
493
|
let imported;
|
|
410
494
|
let source;
|