@webjsdev/ui 0.3.13 → 0.3.15
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 +1 -0
- package/package.json +3 -2
- package/packages/registry/components/alert-dialog.ts +19 -7
- package/packages/registry/components/dialog.ts +18 -7
- package/packages/registry/components/sonner.ts +51 -17
- package/src/commands/lint.js +93 -0
- package/src/index.js +3 -1
- package/src/lint/grammar.js +839 -0
- package/src/lint/index.js +205 -0
- package/src/lint/rules/index.js +12 -0
- package/src/lint/rules/no-arbitrary-values.js +33 -0
- package/src/lint/rules/no-raw-colors.js +86 -0
- package/src/lint/rules/no-restyle.js +66 -0
- package/src/lint/scan.js +446 -0
- package/src/lint/theme-tokens.js +75 -0
- package/src/registry/extract.js +166 -0
- package/src/registry/schema.js +38 -0
package/README.md
CHANGED
|
@@ -127,6 +127,7 @@ always compares against the live upstream.
|
|
|
127
127
|
| `webjsui diff [name]` | Show diff between your local copy and the live registry |
|
|
128
128
|
| `webjsui info` | Print project diagnostics |
|
|
129
129
|
| `webjsui build` | (For registry authors) Compile a custom registry |
|
|
130
|
+
| `webjsui lint` | Opt-in design-system linter. Reports raw palette colors, arbitrary values and classes composed over a kit helper, at the line, with a message built from your own theme tokens and helper variants. Configured by a `lint` block in `components.json`; with no block it reports nothing and exits 0. `--json` for an agent loop, `--max-warnings <n>` to pin a count. |
|
|
130
131
|
|
|
131
132
|
## Tag convention
|
|
132
133
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The AI-first component library for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Class-helper functions for visuals, custom elements only where state matters, source-copied into your repo so you own it.",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"test": "node --test test/*.test.js",
|
|
24
|
-
"test:e2e:touch": "node test/e2e/touch.e2e.mjs"
|
|
24
|
+
"test:e2e:touch": "node test/e2e/touch.e2e.mjs",
|
|
25
|
+
"test:e2e:a11y-tree": "node test/e2e/a11y-tree.e2e.mjs"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"commander": "^14.0.0",
|
|
@@ -55,6 +55,11 @@
|
|
|
55
55
|
* Treat that as a bug in your markup rather than a feature: this dialog
|
|
56
56
|
* interrupts the user to demand an explicit choice and blocks Escape, so
|
|
57
57
|
* naming it "Alert dialog" tells them nothing about what they are deciding.
|
|
58
|
+
* `role="alertdialog"` and the name both sit on the NATIVE `<dialog>`, a
|
|
59
|
+
* valid ARIA-in-HTML override of that element's implicit `dialog` role, so
|
|
60
|
+
* exactly one dialog-family node is exposed rather than an `alertdialog`
|
|
61
|
+
* nested inside a `dialog`. There is no `aria-modal`: the platform already
|
|
62
|
+
* exposes a `showModal()`-opened dialog as modal.
|
|
58
63
|
*
|
|
59
64
|
* Design tokens used: --background, --border, --muted-foreground.
|
|
60
65
|
*
|
|
@@ -417,18 +422,23 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
417
422
|
panel.setAttribute('aria-describedby', authored);
|
|
418
423
|
return;
|
|
419
424
|
}
|
|
425
|
+
// Clear the previous open's value before resolving again. The panel keeps
|
|
426
|
+
// its attributes between opens, so a guard that skips when the attribute is
|
|
427
|
+
// already present can never re-resolve, and a removed description node
|
|
428
|
+
// leaves a stale aria-describedby pointing at a dead IDREF.
|
|
429
|
+
panel.removeAttribute('aria-describedby');
|
|
420
430
|
const desc =
|
|
421
431
|
this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
|
|
422
|
-
if (desc
|
|
432
|
+
if (desc) {
|
|
423
433
|
panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
|
|
424
434
|
}
|
|
425
435
|
}
|
|
426
436
|
|
|
427
437
|
_wireLabels(): void {
|
|
428
|
-
const panel = this.querySelector('[data-slot="alert-dialog-
|
|
438
|
+
const panel = this.querySelector('dialog[data-slot="alert-dialog-native"]');
|
|
429
439
|
if (!panel) return;
|
|
430
440
|
// A name the author put on <ui-alert-dialog-content> is where they
|
|
431
|
-
// naturally write it, but role="alertdialog" lives on the
|
|
441
|
+
// naturally write it, but role="alertdialog" lives on the native <dialog>.
|
|
432
442
|
//
|
|
433
443
|
// Each authored-name branch RETURNS rather than falling through to the
|
|
434
444
|
// title wiring. Falling through would set aria-labelledby from the title
|
|
@@ -449,9 +459,13 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
449
459
|
this._wireDescription(panel);
|
|
450
460
|
return;
|
|
451
461
|
}
|
|
462
|
+
// Same re-resolve rule: clear what a previous open wrote, or a removed
|
|
463
|
+
// title node leaves a stale aria-labelledby that points at a dead IDREF AND
|
|
464
|
+
// suppresses the floor below, which exists to prevent exactly that state.
|
|
465
|
+
panel.removeAttribute('aria-labelledby');
|
|
452
466
|
const title =
|
|
453
467
|
this.querySelector('[data-slot="alert-dialog-title"]') ?? this.querySelector('h1, h2, h3');
|
|
454
|
-
if (title
|
|
468
|
+
if (title) {
|
|
455
469
|
panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-alert-title'));
|
|
456
470
|
}
|
|
457
471
|
this._wireDescription(panel);
|
|
@@ -472,15 +486,13 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
472
486
|
const parentOpen = !!this._parent()?.open;
|
|
473
487
|
return html`<dialog
|
|
474
488
|
data-slot="alert-dialog-native"
|
|
489
|
+
role="alertdialog"
|
|
475
490
|
class=${NATIVE_DIALOG_CLASS}
|
|
476
491
|
${ref(this.#dialog)}
|
|
477
492
|
@cancel=${this._onNativeCancel}
|
|
478
493
|
@close=${this._onNativeClose}
|
|
479
494
|
><div
|
|
480
495
|
data-slot="alert-dialog-content"
|
|
481
|
-
role="alertdialog"
|
|
482
|
-
aria-modal="true"
|
|
483
|
-
tabindex="-1"
|
|
484
496
|
data-size=${this.size}
|
|
485
497
|
data-state=${parentOpen ? 'open' : 'closed'}
|
|
486
498
|
class=${alertDialogContentClass()}
|
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
* floor to make an unnamed modal impossible, NOT a substitute: give the
|
|
48
48
|
* dialog a real title, since "Dialog" tells a screen reader user nothing
|
|
49
49
|
* about what they have been interrupted with.
|
|
50
|
+
* `role="dialog"` and the name both sit on the NATIVE `<dialog>`, not on the
|
|
51
|
+
* inner content div, so exactly one dialog-family node is exposed in the
|
|
52
|
+
* accessibility tree. There is no `aria-modal`: a `showModal()`-opened
|
|
53
|
+
* native dialog is already exposed as modal by the platform.
|
|
50
54
|
*
|
|
51
55
|
* Design tokens used: --background, --border, --muted-foreground.
|
|
52
56
|
*
|
|
@@ -111,9 +115,14 @@ function wireDialogDescription(host: Element, panel: Element): void {
|
|
|
111
115
|
panel.setAttribute('aria-describedby', authored);
|
|
112
116
|
return;
|
|
113
117
|
}
|
|
118
|
+
// Clear the previous open's value before resolving again. The panel keeps its
|
|
119
|
+
// attributes between opens, so a guard that skips when the attribute is
|
|
120
|
+
// already present can never re-resolve: remove the description node and
|
|
121
|
+
// re-open, and the stale aria-describedby survives pointing at a dead IDREF.
|
|
122
|
+
panel.removeAttribute('aria-describedby');
|
|
114
123
|
const desc =
|
|
115
124
|
host.querySelector('[data-slot="dialog-description"]') ?? host.querySelector('p');
|
|
116
|
-
if (desc
|
|
125
|
+
if (desc) {
|
|
117
126
|
panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-dialog-desc'));
|
|
118
127
|
}
|
|
119
128
|
}
|
|
@@ -122,7 +131,7 @@ export function wireDialogLabels(host: Element, panelSelector: string): void {
|
|
|
122
131
|
const panel = host.querySelector(panelSelector);
|
|
123
132
|
if (!panel) return;
|
|
124
133
|
// A name the author put on <ui-dialog-content> is where they naturally write
|
|
125
|
-
// it, but role="dialog" lives on the
|
|
134
|
+
// it, but role="dialog" lives on the native <dialog>, so forward it there.
|
|
126
135
|
//
|
|
127
136
|
// This RETURNS once an authored name is forwarded, rather than falling
|
|
128
137
|
// through to the title wiring below. Falling through would set
|
|
@@ -144,9 +153,13 @@ export function wireDialogLabels(host: Element, panelSelector: string): void {
|
|
|
144
153
|
wireDialogDescription(host, panel);
|
|
145
154
|
return;
|
|
146
155
|
}
|
|
156
|
+
// Same re-resolve rule: clear what a previous open wrote, or a removed title
|
|
157
|
+
// node leaves a stale aria-labelledby that points at a dead IDREF AND
|
|
158
|
+
// suppresses the floor below, which exists to prevent exactly that state.
|
|
159
|
+
panel.removeAttribute('aria-labelledby');
|
|
147
160
|
const title =
|
|
148
161
|
host.querySelector('[data-slot="dialog-title"]') ?? host.querySelector('h1, h2, h3');
|
|
149
|
-
if (title
|
|
162
|
+
if (title) {
|
|
150
163
|
panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-dialog-title'));
|
|
151
164
|
}
|
|
152
165
|
wireDialogDescription(host, panel);
|
|
@@ -505,7 +518,7 @@ export class UiDialogContent extends WebComponent({
|
|
|
505
518
|
}
|
|
506
519
|
|
|
507
520
|
showModal(): void {
|
|
508
|
-
wireDialogLabels(this, '[data-slot="dialog-
|
|
521
|
+
wireDialogLabels(this, 'dialog[data-slot="dialog-native"]');
|
|
509
522
|
const native = this._native();
|
|
510
523
|
if (native && !native.open) native.showModal();
|
|
511
524
|
}
|
|
@@ -520,15 +533,13 @@ export class UiDialogContent extends WebComponent({
|
|
|
520
533
|
const parentOpen = !!this._parent()?.open;
|
|
521
534
|
return html`<dialog
|
|
522
535
|
data-slot="dialog-native"
|
|
536
|
+
role="dialog"
|
|
523
537
|
class=${NATIVE_DIALOG_CLASS}
|
|
524
538
|
${ref(this.#dialog)}
|
|
525
539
|
@close=${this._onNativeClose}
|
|
526
540
|
@click=${this._onNativeBackdropClick}
|
|
527
541
|
><div
|
|
528
542
|
data-slot="dialog-content"
|
|
529
|
-
role="dialog"
|
|
530
|
-
aria-modal="true"
|
|
531
|
-
tabindex="-1"
|
|
532
543
|
data-state=${parentOpen ? 'open' : 'closed'}
|
|
533
544
|
class=${dialogContentClass()}
|
|
534
545
|
>
|
|
@@ -32,14 +32,21 @@
|
|
|
32
32
|
*
|
|
33
33
|
* A11y (owned by the element, nothing to supply):
|
|
34
34
|
* The viewport is a persistent `aria-live="polite"` region labelled
|
|
35
|
-
* "Notifications", so a toast is announced as it is inserted
|
|
36
|
-
*
|
|
35
|
+
* "Notifications", so a toast is announced as it is inserted, and it is the
|
|
36
|
+
* ONLY live root an ordinary toast resolves under: a non-error toast carries
|
|
37
|
+
* no role of its own, because `role="status"` is itself a live region and
|
|
38
|
+
* nesting one inside the polite viewport bought nothing while making some
|
|
39
|
+
* readers announce the toast twice. An `error` toast does carry
|
|
40
|
+
* `role="alert"` so it is announced assertively, which is the only way to
|
|
41
|
+
* make one item assertive inside a polite viewport, and its second live
|
|
42
|
+
* root is the accepted cost of that urgency. Every toast
|
|
37
43
|
* also ships a labelled close button, so a toast can always be dismissed by
|
|
38
44
|
* hand rather than only by its timer or a programmatic `toast.dismiss(id)`.
|
|
39
45
|
* That matters most for a toast that never auto-dismisses, which
|
|
40
46
|
* `toast.loading()` is by default (`duration: 0`) and which was otherwise
|
|
41
47
|
* impossible to get rid of from the UI. Each toast's icon is decorative and
|
|
42
|
-
* aria-hidden; the meaning is in the text and the
|
|
48
|
+
* aria-hidden; the meaning is in the text, and urgency in the viewport's
|
|
49
|
+
* politeness plus `role="alert"` on an error toast.
|
|
43
50
|
*
|
|
44
51
|
* Design tokens used: --popover, --popover-foreground, --border, --radius.
|
|
45
52
|
*
|
|
@@ -247,9 +254,16 @@ export class UiSonner extends WebComponent({
|
|
|
247
254
|
const pos = POSITIONS[this.position] ?? POSITIONS['bottom-right'];
|
|
248
255
|
// The container is a persistent live region: it is in the DOM from the
|
|
249
256
|
// first render (even with zero toasts), so a screen reader announces
|
|
250
|
-
// each toast as it is inserted. It
|
|
251
|
-
//
|
|
252
|
-
//
|
|
257
|
+
// each toast as it is inserted. It is polite, and it is the ONLY live
|
|
258
|
+
// root an ordinary toast resolves under.
|
|
259
|
+
//
|
|
260
|
+
// A non-error toast therefore carries no role of its own. It used to carry
|
|
261
|
+
// role="status", which is itself a live region, so the toast resolved under
|
|
262
|
+
// TWO nested live roots (measured over CDP, #1245) and some readers
|
|
263
|
+
// double-announce that. The inner role bought nothing, because the viewport
|
|
264
|
+
// is already polite. An `error` toast keeps role="alert", which is
|
|
265
|
+
// load-bearing: it is the only way to make ONE item assertive inside a
|
|
266
|
+
// polite viewport, and its second live root is the accepted cost.
|
|
253
267
|
return html`<div
|
|
254
268
|
data-slot="sonner"
|
|
255
269
|
role="region"
|
|
@@ -262,12 +276,33 @@ export class UiSonner extends WebComponent({
|
|
|
262
276
|
${repeat(
|
|
263
277
|
this.items.get(),
|
|
264
278
|
(item) => item.id,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
279
|
+
// The role is BRANCHED rather than written as a nullish hole. A hole
|
|
280
|
+
// that evaluates to null serves role="" from the server renderer while
|
|
281
|
+
// the client renderer removes the attribute, which is the divergence
|
|
282
|
+
// test/ssr-aria.test.js exists to catch, and an empty role is worse
|
|
283
|
+
// than no role: it is not a role, so the toast falls back to `generic`
|
|
284
|
+
// in a way no attribute assertion would notice.
|
|
285
|
+
(item) => (item.type === 'error'
|
|
286
|
+
? html`<div
|
|
287
|
+
data-slot="sonner-toast"
|
|
288
|
+
class=${TOAST_ITEM_BASE}
|
|
289
|
+
data-type=${item.type}
|
|
290
|
+
role="alert"
|
|
291
|
+
>${this._toastBody(item)}</div>`
|
|
292
|
+
: html`<div
|
|
293
|
+
data-slot="sonner-toast"
|
|
294
|
+
class=${TOAST_ITEM_BASE}
|
|
295
|
+
data-type=${item.type}
|
|
296
|
+
>${this._toastBody(item)}</div>`),
|
|
297
|
+
)}
|
|
298
|
+
</div>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The toast's contents, shared by both arms of the role branch above.
|
|
302
|
+
// A METHOD, not a module-scope value: a registry module does no work at
|
|
303
|
+
// module scope.
|
|
304
|
+
_toastBody(item: ToastItem) {
|
|
305
|
+
return html`
|
|
271
306
|
<div class=${`${TYPE_ICON_COLOR[item.type]} pt-0.5`}>${unsafeHTML(ICONS[item.type])}</div>
|
|
272
307
|
<div class="flex-1">
|
|
273
308
|
<div class="font-medium">${item.message}</div>
|
|
@@ -304,9 +339,7 @@ export class UiSonner extends WebComponent({
|
|
|
304
339
|
class="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
305
340
|
@click=${() => this._remove(item.id)}
|
|
306
341
|
>${unsafeHTML(CLOSE_SVG)}</button>
|
|
307
|
-
|
|
308
|
-
)}
|
|
309
|
-
</div>`;
|
|
342
|
+
`;
|
|
310
343
|
}
|
|
311
344
|
}
|
|
312
345
|
UiSonner.register('ui-sonner');
|
|
@@ -316,8 +349,9 @@ UiSonner.register('ui-sonner');
|
|
|
316
349
|
const CLOSE_SVG =
|
|
317
350
|
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
318
351
|
|
|
319
|
-
// Type glyphs, all decorative: the toast's text carries the meaning, and
|
|
320
|
-
//
|
|
352
|
+
// Type glyphs, all decorative: the toast's text carries the meaning, and
|
|
353
|
+
// urgency is conveyed by role="alert" on an error toast, not by the picture.
|
|
354
|
+
// An ordinary toast carries no role and is announced by the polite viewport.
|
|
321
355
|
const ICONS: Record<ToastType, string> = {
|
|
322
356
|
default: '',
|
|
323
357
|
success:
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { getConfig } from '../utils/get-config.js';
|
|
3
|
+
import { lintApp } from '../lint/index.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run the linter and shape its report, with no printing and no exit, so the
|
|
8
|
+
* command test calls this directly. `code` is 0 or 1 only, matching `webjs
|
|
9
|
+
* check`'s posture that an agent gates on non-zero: 1 when any error-level
|
|
10
|
+
* violation exists, when the warning count exceeds `maxWarnings` (-1 is no
|
|
11
|
+
* cap), or when the command cannot run at all (no `components.json`, or a
|
|
12
|
+
* config that fails the schema).
|
|
13
|
+
*
|
|
14
|
+
* @param {{ cwd?: string, json?: boolean, maxWarnings?: number|string }} [opts]
|
|
15
|
+
* @returns {{ lines: string[], code: number, report: null | { violations: any[], summary: any, warnings: string[] } }}
|
|
16
|
+
*/
|
|
17
|
+
export function runLint(opts = {}) {
|
|
18
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
19
|
+
const maxWarnings = Number(opts.maxWarnings ?? -1);
|
|
20
|
+
/** @type {string[]} */
|
|
21
|
+
const lines = [];
|
|
22
|
+
// A run that cannot start still answers in the requested shape, so an agent
|
|
23
|
+
// loop parsing stdout under --json receives an error document, never text.
|
|
24
|
+
const refuse = (error) => {
|
|
25
|
+
if (opts.json) {
|
|
26
|
+
lines.push(JSON.stringify({ error, violations: [], summary: { count: 0, errors: 0, warnings: 0, byRule: {} }, warnings: [], configured: false }, null, 2));
|
|
27
|
+
} else {
|
|
28
|
+
lines.push(`webjsui lint: ${error}`);
|
|
29
|
+
}
|
|
30
|
+
return { lines, code: 1, report: null };
|
|
31
|
+
};
|
|
32
|
+
if (!Number.isInteger(maxWarnings) || maxWarnings < -1) {
|
|
33
|
+
return refuse(`--max-warnings expects an integer (-1 for no cap), got ${JSON.stringify(String(opts.maxWarnings))}`);
|
|
34
|
+
}
|
|
35
|
+
let config;
|
|
36
|
+
try {
|
|
37
|
+
config = getConfig(cwd);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return refuse(`components.json is invalid: ${firstIssue(e)}`);
|
|
40
|
+
}
|
|
41
|
+
if (!config) return refuse('components.json not found (run `npx @webjsdev/ui init`)');
|
|
42
|
+
|
|
43
|
+
const { violations, warnings, configured } = lintApp(cwd, config);
|
|
44
|
+
const errors = violations.filter((v) => v.severity === 'error').length;
|
|
45
|
+
const warns = violations.length - errors;
|
|
46
|
+
/** @type {Record<string, number>} */
|
|
47
|
+
const byRule = {};
|
|
48
|
+
for (const v of violations) byRule[v.rule] = (byRule[v.rule] ?? 0) + 1;
|
|
49
|
+
const report = {
|
|
50
|
+
violations,
|
|
51
|
+
summary: { count: violations.length, errors, warnings: warns, byRule },
|
|
52
|
+
warnings,
|
|
53
|
+
configured,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
lines.push(JSON.stringify(report, null, 2));
|
|
58
|
+
} else if (!configured) {
|
|
59
|
+
lines.push('webjsui lint: no rules configured (add a "lint" block to components.json)');
|
|
60
|
+
} else {
|
|
61
|
+
for (const w of warnings) lines.push(`⚠ ${w}`);
|
|
62
|
+
if (violations.length === 0) {
|
|
63
|
+
lines.push('webjsui lint: all checks pass ✓');
|
|
64
|
+
} else {
|
|
65
|
+
lines.push(`webjsui lint: ${violations.length} problem(s) found (${errors} error${errors === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'})`);
|
|
66
|
+
for (const v of violations) {
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(` ${v.severity === 'error' ? '✗' : '⚠'} [${v.rule}] ${v.file}:${v.line}:${v.column}`);
|
|
69
|
+
lines.push(` ${v.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const code = errors > 0 || (maxWarnings >= 0 && warns > maxWarnings) ? 1 : 0;
|
|
74
|
+
return { lines, code, report };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function firstIssue(e) {
|
|
78
|
+
const issue = e?.issues?.[0];
|
|
79
|
+
if (issue) return `${issue.path?.join('.') || '(root)'}: ${issue.message}`;
|
|
80
|
+
return String(e?.message ?? e);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const lint = new Command()
|
|
84
|
+
.name('lint')
|
|
85
|
+
.description('Check the app against its design system (opt-in, configured in components.json)')
|
|
86
|
+
.option('-c, --cwd <cwd>', 'the working directory', process.cwd())
|
|
87
|
+
.option('--json', 'emit structured violations for an agent loop')
|
|
88
|
+
.option('--max-warnings <n>', 'fail when warnings exceed this count', '-1')
|
|
89
|
+
.action((opts) => {
|
|
90
|
+
const { lines, code } = runLint(opts);
|
|
91
|
+
for (const l of lines) logger.info(l);
|
|
92
|
+
if (code !== 0) process.exitCode = code;
|
|
93
|
+
});
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { view } from './commands/view.js';
|
|
|
9
9
|
import { diff } from './commands/diff.js';
|
|
10
10
|
import { info } from './commands/info.js';
|
|
11
11
|
import { build } from './commands/build.js';
|
|
12
|
+
import { lint } from './commands/lint.js';
|
|
12
13
|
|
|
13
14
|
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
|
|
14
15
|
|
|
@@ -27,6 +28,7 @@ program
|
|
|
27
28
|
.addCommand(view)
|
|
28
29
|
.addCommand(diff)
|
|
29
30
|
.addCommand(info)
|
|
30
|
-
.addCommand(build)
|
|
31
|
+
.addCommand(build)
|
|
32
|
+
.addCommand(lint);
|
|
31
33
|
|
|
32
34
|
program.parse();
|