@jay-framework/jay-stack-cli 0.24.7 → 0.25.0

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.
@@ -148,8 +148,8 @@ component's **own** tags, and an optional `<style>` of **plain component CSS**.
148
148
  ```
149
149
 
150
150
  > **Author plain CSS here, not `@scope`.** In the template you write ordinary class rules
151
- > (`.ds-card { … }`). When a page flattens the element, `sync` copies this CSS into the page and rewrites it
152
- > into the canonical `@scope (.<ref>)` / `:scope` form for you — that scoped shape is a _page-side_ concern
151
+ > (`.ds-card { … }`). When a page flattens the element, `sync` copies these rules into the page verbatim and
152
+ > wraps them in a canonical `@scope (.<ref>)` block for you — that scoped wrapper is a _page-side_ concern
153
153
  > (see [The shape of a region's CSS](#the-shape-of-a-regions-css)); you don't write it in the template.
154
154
 
155
155
  ### 4. Choose the right axis of variation
@@ -217,7 +217,7 @@ the page's `<style>`, `@scope`-wrapped by the region's `ref`:
217
217
 
218
218
  ```html
219
219
  <jay:card ref="promo" heading="{title}">
220
- <div class="card promo">
220
+ <div class="card">
221
221
  <h3 class="card-heading">{heading}</h3>
222
222
  <p class="card-body">Default body</p>
223
223
  </div>
@@ -226,7 +226,7 @@ the page's `<style>`, `@scope`-wrapped by the region's `ref`:
226
226
 
227
227
  ```css
228
228
  @scope (.promo) {
229
- :scope {
229
+ .card {
230
230
  border: 1px solid #ccc;
231
231
  padding: 16px;
232
232
  }
@@ -236,8 +236,9 @@ the page's `<style>`, `@scope`-wrapped by the region's `ref`:
236
236
  }
237
237
  ```
238
238
 
239
- Note two things sync did to the CSS — both required for the styles to actually apply. **Write region CSS in
240
- this shape yourself**; the rules are below in [The shape of a region's CSS](#the-shape-of-a-regions-css).
239
+ The flattened body is the template body **verbatim** — no synthetic class on the root — and the CSS is the
240
+ component's own plain rules wrapped in one `@scope (.<ref>)` block. **Write region CSS in this shape
241
+ yourself**; the rules are below in [The shape of a region's CSS](#the-shape-of-a-regions-css).
241
242
 
242
243
  > A bare `<jay:X>` with **no** flattened body is a hard error at build time — flatten it with `jay-stack
243
244
  sync` (first-fill is just the no-edits case of sync; there is no separate `add` command).
@@ -292,9 +293,9 @@ the second import its own region tag with `as=`:
292
293
 
293
294
  ## The shape of a region's CSS
294
295
 
295
- A region's CSS is the component's own CSS, copied into the page `<style>` and rewritten into a canonical
296
- form. `sync` produces this form and `validate` enforces it — but when you **hand-write or edit** a region's
297
- CSS, write it this way directly:
296
+ A region's CSS is the component's own CSS, copied into the page `<style>` and wrapped in one `@scope` block.
297
+ `sync` produces this form and `validate` enforces it — but when you **hand-write or edit** a region's CSS,
298
+ write it this way directly:
298
299
 
299
300
  ### 1. Wrap the component's rules in `@scope (.<ref>)`
300
301
 
@@ -302,52 +303,47 @@ All of a region's rules live inside one `@scope` block keyed by the region's `re
302
303
 
303
304
  ```css
304
305
  @scope (.promo) {
305
- /* …the card's rules… */
306
+ /* …the card's rules, as plain class selectors… */
306
307
  }
307
308
  ```
308
309
 
309
310
  This isolates the component's styles to that region — rules inside never leak out, and page rules outside
310
311
  never bleed in.
311
312
 
312
- ### 2. The `ref` is a real class on the region root (scope-anchor)
313
-
314
- A jay `ref` is not emitted to the DOM, so `@scope (.promo)` would have nothing to match. The region root
315
- therefore carries the ref **as an actual class** — `<div class="card promo">` above. Keep that class on the
316
- root when you edit markup; it is what anchors the scope. (validate's drift check ignores this synthetic
317
- class, so it never shows up as drift.)
313
+ ### 2. Write plain class selectors — the ref is _not_ a class on your markup
318
314
 
319
- ### 3. The component's own root rule targets `:scope`, not its block class
315
+ A jay `ref` is not emitted to the DOM, so `@scope (.promo)` would have nothing to match. The compiler
316
+ therefore synthesizes a `<div class="promo" style="display: contents">` **scope-anchor wrapper** around the
317
+ region body at build time (DL#206). It is layout-transparent (`display:contents` generates no box) and you
318
+ **never write it, see it in source, or style it** — it exists only so `.promo` has a real element to root at.
320
319
 
321
- This is the one that trips people up. **Inside `@scope (.<ref>) { … }`, a scoped selector matches
322
- _descendants_ of the scope root only — the scope root element itself is reachable solely through `:scope`.**
323
- So a rule for the region's own root element must be written as `:scope`, _not_ as the root's class:
320
+ Because the wrapper is the scope root, your real roots are ordinary **descendants** of it. So every rule —
321
+ including the component's own root element — is a **plain class selector**, exactly as authored in the
322
+ template. Keep the template's classes on your markup unchanged; add **no** ref class:
324
323
 
325
324
  ```css
326
325
  @scope (.promo) {
327
- :scope {
328
- border: 1px solid #ccc;
329
- } /* ✅ styles the region root (<div class="card promo">) */
330
326
  .card {
331
327
  border: 1px solid #ccc;
332
- } /* ❌ never matches — .card IS the scope root, not a descendant */
328
+ } /* ✅ the region root — a plain class selector, matched as a descendant of the wrapper */
333
329
  .card-heading {
334
330
  color: black;
335
- } /* ✅ descendant — plain class selector is correct */
331
+ } /* ✅ descendant — also a plain class selector */
336
332
  }
337
333
  ```
338
334
 
339
- Rule of thumb: the component's **root block class** → `:scope`; keep compounds and descendants on that
340
- class (`.card.active` → `:scope.active`, `.card .card-heading` → `:scope .card-heading`), and leave every
341
- **descendant/element** selector as its ordinary class.
335
+ > **No `:scope` rewrite.** In the old model the root rule was rewritten to `:scope` and the ref was stamped
336
+ > onto the root as a class. DL#206 removed both: page region CSS is now byte-for-byte the template's plain
337
+ > rules, just wrapped in `@scope (.<ref>)`. `:scope` would now refer to the invisible wrapper — don't use it.
342
338
 
343
- ### 4. Instances of the same component coalesce into one block
339
+ ### 3. Instances of the same component coalesce into one block
344
340
 
345
341
  When a page holds several regions flattened from the _same_ template, they share one `@scope` block with a
346
342
  selector list — never one duplicated block per `ref`:
347
343
 
348
344
  ```css
349
345
  @scope (.cardStarter, .cardPro) {
350
- :scope {
346
+ .card {
351
347
  border: 1px solid #ccc;
352
348
  }
353
349
  .card-heading {
@@ -359,6 +355,21 @@ selector list — never one duplicated block per `ref`:
359
355
  Regions from _different_ templates stay in separate blocks. If you add a second instance by hand, fold its
360
356
  ref into the existing block's selector list rather than copying the block.
361
357
 
358
+ ### 4. A CSS-shipping region must have a `ref` (error)
359
+
360
+ The `@scope (.<ref>)` anchor is derived from the region's `ref=`. A region whose `template=` ships CSS but
361
+ whose `<jay:X>` tag has **no `ref`** has nothing to scope to — the materialiser cannot wrap the CSS and
362
+ `jay-stack sync` silently drops it. `validate` catches this as a **hard error** (`REGION-CSS-NO-REF`, DL#209):
363
+
364
+ ```
365
+ <jay:card> flattens template="./components/card/card.jay-html" which ships CSS, but the region has no ref=
366
+ — so its CSS cannot be scoped and `jay-stack sync` will silently drop it.
367
+ → Add a ref= to the <jay:card> so sync can scope its CSS as an @scope (.<ref>) block and preserve it.
368
+ ```
369
+
370
+ Fix it by adding a `ref` — there is no suppression, because the alternative (keeping the CSS unscoped) would
371
+ leak the region's styles across the whole page.
372
+
362
373
  ## Drift: what `jay-stack validate` tells you
363
374
 
364
375
  Because the copy has a known source (its `template=`), `jay-stack validate` diffs the two and reports each
@@ -490,7 +501,8 @@ CSS reconciles the same way as markup — drift is overwritten, marked facets ar
490
501
  never rewritten.
491
502
  - A block that **diverges without any pragma** is unmarked CSS drift: `sync` overwrites it back to the
492
503
  canonical form, exactly as it re-flattens unmarked markup. (This is what lets `sync` _migrate_ a page to a
493
- new canonical form — e.g. an older `.card { … }` root rule is rewritten to `:scope { … }`.)
504
+ new canonical form — e.g. an older `:scope { … }` / ref-on-root block is rewritten to the DL#206 form:
505
+ plain class rules wrapped in `@scope (.<ref>)`.)
494
506
 
495
507
  So after one `sync` a page's CSS is in canonical form, and a `validate`-clean page is one `sync` leaves
496
508
  unchanged. To keep a hand-edit through `sync`, mark it with a `jay:override` pragma.
@@ -19,12 +19,13 @@ Add `<style>` blocks in `<head>`:
19
19
  </head>
20
20
  ```
21
21
 
22
- ## Styling a design-system region (`@scope` + `:scope`)
22
+ ## Styling a design-system region (`@scope`)
23
23
 
24
24
  CSS for a `<jay:X>` **design-system element** (a region with `template=` provenance) is not written as plain
25
- page rules — it lives in an `@scope (.<ref>)` block, and the component's own root rule must target `:scope`,
26
- not its block class (inside `@scope`, a class selector matches descendants of the scope root only). This has
27
- its own rules and is covered in full in
25
+ page rules — it lives in an `@scope (.<ref>)` block keyed by the region's `ref`. Inside that block you write
26
+ the component's **plain class rules** exactly as authored in the template (`.ds-card { … }`); the compiler
27
+ synthesizes a `display:contents` scope-anchor so `.<ref>` has something to match — you never write it or use
28
+ `:scope`. This has its own rules and is covered in full in
28
29
  [design-system-guide.md → The shape of a region's CSS](design-system-guide.md#the-shape-of-a-regions-css).
29
30
  The plain-`<style>` patterns on this page apply to ordinary page markup.
30
31
 
@@ -27,6 +27,11 @@ So a warning about an `<img>` might come from a full-stack component's template,
27
27
  - **Errors** block the build. They must be fixed — there is no way to suppress them.
28
28
  - **Warnings** must be either fixed or explicitly suppressed. Do not ignore warnings — each one has a clear resolution path (add the missing attribute, or suppress via `<script type="application/jay-validations">`).
29
29
 
30
+ One design-system rule is a **hard error**, not a warning: `REGION-CSS-NO-REF` (DL#209) — a `<jay:X>` region
31
+ whose `template=` ships CSS but which has no `ref=`. Without a `ref` the CSS has no scope anchor, so `sync`
32
+ silently drops it; the fix is to add a `ref` (see `design-system-guide.md` → _The shape of a region's CSS_).
33
+ It is unsuppressible because the alternative would leak the region's styles across the whole page.
34
+
30
35
  ## How to Read Warnings
31
36
 
32
37
  Each warning has:
@@ -135,10 +140,38 @@ The core `jay-stack validate` emits four warnings that nudge you to reuse UI thr
135
140
 
136
141
  If a warning comes from dynamic content (`{post.content}`) or a generated file, you can't suppress it in the template. This is a validator limitation — the warning is a false positive. Don't loop trying to fix it.
137
142
 
143
+ ## Design-system scorecard (metrics to optimize)
144
+
145
+ Below the warnings, `validate` prints a **report-only scorecard** (DL#207). It is **never** a pass/fail — it
146
+ emits no warnings and never affects the exit code. It turns "prefer design-system elements" from a nudge into
147
+ two numbers you can deliberately raise:
148
+
149
+ - **Tag coverage** — of every contract tag/ref a page's regions _could_ bind, how many it actually binds.
150
+ - **Design-system coverage %** — what share of a page's DOM elements come from `template=`-backed
151
+ design-system regions (vs. hand-authored markup). Higher = more of the page is built from the design system.
152
+ - **Reuse** — how many catalogued templates are used **more than once** across the project. A design system
153
+ whose templates are each used once isn't really a design system.
154
+
155
+ The healthy target: **most of each page is composed of design-system elements, and each template is reused
156
+ more than once.** When building a page, prefer linking an existing template (raising coverage and reuse) over
157
+ hand-authoring markup.
158
+
159
+ By default the scorecard prints **one-line totals** for the whole project:
160
+
161
+ ```
162
+ 📦 Tag coverage: 100% (43/43 tags used across 10 page(s))
163
+ 📊 Design-system scorecard: 80% element coverage (74/92), 2 of 5 catalogued templates reused > 1
164
+ Run validate -v for per-page details.
165
+ ```
166
+
167
+ Run with `-v` / `--verbose` to expand the **per-page** breakdown (coverage per file, which tags are unused,
168
+ per-template reuse counts, and `⚠ low` flags on pages under 50% coverage).
169
+
138
170
  ## Running Validation
139
171
 
140
172
  ```bash
141
- jay-stack validate # validate all pages
173
+ jay-stack validate # validate all pages (+ one-line scorecard totals)
174
+ jay-stack validate -v # add per-page tag coverage + scorecard detail
142
175
  jay-stack validate --strict # treat warnings as errors
143
176
  ```
144
177
 
package/dist/index.js CHANGED
@@ -4253,11 +4253,10 @@ function firstColon(decl) {
4253
4253
  }
4254
4254
  function diffBodies(sourceParent, regionParent) {
4255
4255
  const out = [];
4256
- const ignoreClass = isRegionTag(regionParent) ? readAttr(regionParent, "ref") : void 0;
4257
- diffChildren([], sourceParent, regionParent, ignoreClass, out);
4256
+ diffChildren([], sourceParent, regionParent, out);
4258
4257
  return out;
4259
4258
  }
4260
- function diffChildren(parentPath, sParent, rParent, ignoreClass, out) {
4259
+ function diffChildren(parentPath, sParent, rParent, out) {
4261
4260
  const rSup = isElement(rParent) ? parseOverride(rParent) : NO_SUPPRESSION;
4262
4261
  const sSup = isElement(sParent) ? parseContent(sParent) : NO_SUPPRESSION;
4263
4262
  const sup = unionSuppression(rSup, sSup);
@@ -4280,24 +4279,23 @@ function diffChildren(parentPath, sParent, rParent, ignoreClass, out) {
4280
4279
  if (rc.nodeType !== NodeType.ELEMENT_NODE) continue;
4281
4280
  const re = rc;
4282
4281
  if (isRegionTag(re)) continue;
4283
- const childIgnoreClass = parentPath.length === 0 ? ignoreClass : void 0;
4284
- diffElement([...parentPath, i], s2[i], re, childIgnoreClass, out);
4282
+ diffElement([...parentPath, i], s2[i], re, out);
4285
4283
  }
4286
4284
  }
4287
- function diffElement(path2, se, re, ignoreClass, out) {
4285
+ function diffElement(path2, se, re, out) {
4288
4286
  const sup = unionSuppression(parseOverride(re), parseContent(se));
4289
4287
  if (sup.all) return;
4290
- compareAttributes(path2, se, re, sup, ignoreClass, out);
4288
+ compareAttributes(path2, se, re, sup, out);
4291
4289
  compareStyle(path2, se, re, sup, out);
4292
- diffChildren(path2, se, re, void 0, out);
4290
+ diffChildren(path2, se, re, out);
4293
4291
  }
4294
- function compareAttributes(path2, se, re, sup, ignoreClass, out) {
4292
+ function compareAttributes(path2, se, re, sup, out) {
4295
4293
  const sm = attrMap(se);
4296
4294
  const rm = attrMap(re);
4297
4295
  const names = [.../* @__PURE__ */ new Set([...sm.keys(), ...rm.keys()])].sort();
4298
4296
  for (const name of names) {
4299
- const sv = name === "class" ? stripClassToken(sm.get(name), ignoreClass) : sm.get(name);
4300
- const rv = name === "class" ? stripClassToken(rm.get(name), ignoreClass) : rm.get(name);
4297
+ const sv = sm.get(name);
4298
+ const rv = rm.get(name);
4301
4299
  const change = scalarChange(sv, rv, (a, b) => normalizeExpr(a) === normalizeExpr(b));
4302
4300
  if (!change) continue;
4303
4301
  const facet = { kind: "attribute", path: path2, element: tagOf(re), name };
@@ -4319,11 +4317,6 @@ function compareStyle(path2, se, re, sup, out) {
4319
4317
  out.push({ facet, change, sourceValue: sv, regionValue: rv });
4320
4318
  }
4321
4319
  }
4322
- function stripClassToken(value, token) {
4323
- if (value === void 0 || !token) return value;
4324
- const kept = value.split(/\s+/).filter((t) => t && t !== token);
4325
- return kept.length ? kept.join(" ") : void 0;
4326
- }
4327
4320
  function scalarChange(sv, rv, equal) {
4328
4321
  if (sv === void 0 && rv === void 0) return void 0;
4329
4322
  if (sv === void 0) return "added";
@@ -4541,7 +4534,7 @@ function coalesceCss(contributions) {
4541
4534
  (g) => g.selectors.length > 0 ? scopeWrap(g.css, g.selectors.join(", "), g.to) : g.css
4542
4535
  ).join("\n\n");
4543
4536
  }
4544
- function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = false) {
4537
+ function fillRegions(container, stack, opts, errors, cssBlocks) {
4545
4538
  for (const region of directRegions(container)) {
4546
4539
  const name = contractName(region);
4547
4540
  const templatePath = opts.resolveTemplate(name);
@@ -4559,15 +4552,11 @@ function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = f
4559
4552
  );
4560
4553
  continue;
4561
4554
  }
4562
- const existing = region.innerHTML;
4563
- const filled = opts.preserveOverrides ? mergeOverrides(template.body, existing) : template.body;
4555
+ const filled = opts.preserveOverrides ? mergeOverrides(template.body, region.innerHTML) : template.body;
4564
4556
  region.set_content(filled);
4565
- const ref = readAttr(region, "ref");
4566
- let scoped = false;
4567
4557
  if (template.css) {
4568
4558
  const selector = (opts.scopeSelector ?? defaultScopeSelector)(region);
4569
4559
  if (selector) {
4570
- scoped = true;
4571
4560
  const to = [
4572
4561
  ...new Set(
4573
4562
  directRegions(region).filter((c) => opts.resolveTemplate(contractName(c)) !== null).map((c) => readAttr(c, "ref")).filter((r) => !!r).map((r) => `.${r}`)
@@ -4576,15 +4565,14 @@ function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = f
4576
4565
  cssBlocks.push({
4577
4566
  key: `${templatePath}\0${[...to].sort().join(",")}`,
4578
4567
  selector,
4579
- css: scopeReadyCss(template.body, template.css),
4568
+ css: template.css,
4580
4569
  to
4581
4570
  });
4582
4571
  } else {
4583
4572
  cssBlocks.push({ key: templatePath, selector: null, css: template.css });
4584
4573
  }
4585
4574
  }
4586
- if (ref && (scoped || parentScoped)) stampScopeAnchor(region, ref);
4587
- fillRegions(region, [...stack, templatePath], opts, errors, cssBlocks, scoped);
4575
+ fillRegions(region, [...stack, templatePath], opts, errors, cssBlocks);
4588
4576
  }
4589
4577
  }
4590
4578
  function directRegions(container) {
@@ -4612,39 +4600,6 @@ function scopeWrap(css, selector, to) {
4612
4600
  ${css}
4613
4601
  }`;
4614
4602
  }
4615
- function rootClassesOf(templateBody) {
4616
- const classes = /* @__PURE__ */ new Set();
4617
- for (const child of parse(templateBody).childNodes) {
4618
- if (child instanceof HTMLElement) {
4619
- for (const c of child.classList.values()) classes.add(c);
4620
- }
4621
- }
4622
- return classes;
4623
- }
4624
- function scopeReadyCss(templateBody, css) {
4625
- const rootClasses = rootClassesOf(templateBody);
4626
- if (rootClasses.size === 0) return css;
4627
- try {
4628
- const root = postcss.parse(css);
4629
- root.walkRules((rule) => {
4630
- rule.selectors = rule.selectors.map((sel) => rewriteRootSelector(sel, rootClasses));
4631
- });
4632
- return root.toString();
4633
- } catch {
4634
- return css;
4635
- }
4636
- }
4637
- function rewriteRootSelector(selector, rootClasses) {
4638
- return selector.replace(
4639
- /\.([A-Za-z0-9_-]+)/g,
4640
- (match, name) => rootClasses.has(name) ? ":scope" : match
4641
- );
4642
- }
4643
- function stampScopeAnchor(region, className) {
4644
- for (const child of region.childNodes) {
4645
- if (child instanceof HTMLElement) child.classList.add(className);
4646
- }
4647
- }
4648
4603
  function mergeOverrides(templateBody, existingBody) {
4649
4604
  const tRoot = parse(templateBody);
4650
4605
  const eRoot = parse(existingBody);
@@ -5369,8 +5324,7 @@ function checkRegionCssDrift(jayHtml, loadTemplate) {
5369
5324
  if (content === void 0) {
5370
5325
  templateCss = null;
5371
5326
  } else {
5372
- const body = parse(content).querySelector("body");
5373
- templateCss = scopeReadyCss(body ? body.innerHTML : "", extractStyleCss(content));
5327
+ templateCss = extractStyleCss(content);
5374
5328
  }
5375
5329
  templateCssCache.set(imp.template, templateCss);
5376
5330
  }
@@ -5462,6 +5416,33 @@ function checkNestedRegionRefs(jayHtml, loadTemplate) {
5462
5416
  }
5463
5417
  return warnings;
5464
5418
  }
5419
+ function checkRegionCssNoRef(jayHtml, loadTemplate) {
5420
+ const importsWithTemplate = jayHtml.headlessImports.filter((imp) => imp.template);
5421
+ if (importsWithTemplate.length === 0) return [];
5422
+ const findings = [];
5423
+ const shipsCssCache = /* @__PURE__ */ new Map();
5424
+ const templateShipsCss = (rel) => {
5425
+ let v = shipsCssCache.get(rel);
5426
+ if (v === void 0) {
5427
+ const content = loadTemplate(rel);
5428
+ v = content !== void 0 && extractStyleCss(content).trim() !== "";
5429
+ shipsCssCache.set(rel, v);
5430
+ }
5431
+ return v;
5432
+ };
5433
+ for (const region of collectRegionElements(jayHtml.body)) {
5434
+ const contractName2 = (region.rawTagName ?? "").toLowerCase().substring(4);
5435
+ const imp = importsWithTemplate.find((i) => i.contractName === contractName2);
5436
+ if (!imp?.template) continue;
5437
+ if (region.getAttribute("ref")) continue;
5438
+ if (!templateShipsCss(imp.template)) continue;
5439
+ findings.push({
5440
+ message: `<jay:${contractName2}> flattens template="${imp.template}" which ships CSS, but the region has no ref= — so its CSS cannot be scoped and \`jay-stack sync\` will silently drop it.`,
5441
+ suggestion: `Add a ref= to the <jay:${contractName2}> so sync can scope its CSS as an @scope (.<ref>) block and preserve it.`
5442
+ });
5443
+ }
5444
+ return findings;
5445
+ }
5465
5446
  const CONTENT_TAGS = /* @__PURE__ */ new Set([
5466
5447
  // inline text semantics
5467
5448
  "span",
@@ -5888,6 +5869,9 @@ async function validateJayFiles(options = {}) {
5888
5869
  const parsedFiles = [];
5889
5870
  let regionsSeen = 0;
5890
5871
  let templateImportsSeen = 0;
5872
+ const designSystemCoverage = [];
5873
+ const reuseByTemplate = /* @__PURE__ */ new Map();
5874
+ const cataloguedContractFiles = /* @__PURE__ */ new Set();
5891
5875
  const pageJayHtmlFiles = await findJayFiles(scanDir);
5892
5876
  const componentJayHtmlFiles = await findJayFiles(componentsDir).catch(() => []);
5893
5877
  const jayHtmlFiles = [...pageJayHtmlFiles, ...componentJayHtmlFiles];
@@ -5895,6 +5879,7 @@ async function validateJayFiles(options = {}) {
5895
5879
  const componentContractFiles = await findContractFiles(componentsDir).catch(
5896
5880
  () => []
5897
5881
  );
5882
+ for (const cf of componentContractFiles) cataloguedContractFiles.add(cf);
5898
5883
  const contractFiles = [...await findContractFiles(scanDir), ...componentContractFiles];
5899
5884
  const contractNameByFile = /* @__PURE__ */ new Map();
5900
5885
  if (options.verbose) {
@@ -6044,10 +6029,45 @@ async function validateJayFiles(options = {}) {
6044
6029
  suggestion: finding.suggestion
6045
6030
  });
6046
6031
  }
6032
+ const cssNoRefErrors = checkRegionCssNoRef(parsedFile.val, readTemplateRel);
6033
+ for (const finding of cssNoRefErrors) {
6034
+ errors.push({
6035
+ file: relativePath,
6036
+ message: finding.message,
6037
+ stage: "generate",
6038
+ suggestion: finding.suggestion
6039
+ });
6040
+ }
6047
6041
  regionsSeen += collectRegionElements(parsedFile.val.body).length;
6048
6042
  templateImportsSeen += parsedFile.val.headlessImports.filter(
6049
6043
  (imp) => imp.template
6050
6044
  ).length;
6045
+ if (!componentJayHtmlFileSet.has(jayFile)) {
6046
+ const body = parsedFile.val.body;
6047
+ const total = body.querySelectorAll("*").length;
6048
+ const coveredNodes = /* @__PURE__ */ new Set();
6049
+ for (const region of directChildRegions(body)) {
6050
+ const contractName2 = regionContractName(region);
6051
+ const imp = parsedFile.val.headlessImports.find(
6052
+ (i) => i.contractName === contractName2
6053
+ );
6054
+ if (!imp?.template) continue;
6055
+ coveredNodes.add(region);
6056
+ for (const descendant of region.querySelectorAll("*")) {
6057
+ coveredNodes.add(descendant);
6058
+ }
6059
+ const templateAbs = path$1.resolve(dirname, imp.template);
6060
+ reuseByTemplate.set(templateAbs, (reuseByTemplate.get(templateAbs) ?? 0) + 1);
6061
+ if (imp.contractPath) cataloguedContractFiles.add(imp.contractPath);
6062
+ }
6063
+ const covered = coveredNodes.size;
6064
+ designSystemCoverage.push({
6065
+ file: relativePath,
6066
+ coveragePct: total ? covered / total : 0,
6067
+ covered,
6068
+ total
6069
+ });
6070
+ }
6051
6071
  const notLinkedWarnings = componentJayHtmlFileSet.has(jayFile) ? [] : checkRegionNotLinked(
6052
6072
  parsedFile.val,
6053
6073
  (imp) => listTemplatesForContractFile(imp.contractPath).map((t) => ({
@@ -6198,6 +6218,23 @@ async function validateJayFiles(options = {}) {
6198
6218
  suggestion: noDesignSystem.suggestion
6199
6219
  });
6200
6220
  }
6221
+ const cataloguedTemplatePaths = /* @__PURE__ */ new Set();
6222
+ for (const contractFile of cataloguedContractFiles) {
6223
+ for (const t of listTemplatesForContractFile(contractFile)) {
6224
+ cataloguedTemplatePaths.add(path$1.resolve(t.path));
6225
+ }
6226
+ }
6227
+ const rel = (p) => "./" + path$1.relative(projectRoot, p).replace(/\\/g, "/");
6228
+ const perTemplate = {};
6229
+ for (const [abs, count] of reuseByTemplate) perTemplate[rel(abs)] = count;
6230
+ const designSystemReuse = {
6231
+ perTemplate,
6232
+ catalogued: cataloguedTemplatePaths.size,
6233
+ reusedMoreThanOnce: [...reuseByTemplate.values()].filter((n) => n >= 2).length,
6234
+ reusedOfCatalogued: [...cataloguedTemplatePaths].filter(
6235
+ (p) => (reuseByTemplate.get(p) ?? 0) >= 2
6236
+ ).length
6237
+ };
6201
6238
  const pluginValidators = await runPluginValidators(projectRoot, parsedFiles, errors, warnings);
6202
6239
  return {
6203
6240
  valid: errors.length === 0,
@@ -6206,7 +6243,9 @@ async function validateJayFiles(options = {}) {
6206
6243
  errors,
6207
6244
  warnings,
6208
6245
  coverage,
6209
- pluginValidators
6246
+ pluginValidators,
6247
+ designSystemCoverage,
6248
+ designSystemReuse
6210
6249
  };
6211
6250
  }
6212
6251
  function printJayValidationResult(result, options) {
@@ -6280,23 +6319,84 @@ function printJayValidationResult(result, options) {
6280
6319
  }
6281
6320
  }
6282
6321
  }
6283
- if (options.verbose && result.coverage.length > 0) {
6284
- logger.important("");
6285
- logger.important(chalk.bold("📦 Tag Coverage"));
6286
- for (const fileCov of result.coverage) {
6287
- logger.important(` ${fileCov.file}`);
6288
- for (const contract of fileCov.contracts) {
6289
- const label = contract.key ? `${contract.key} (${contract.contractName})` : contract.contractName;
6290
- logger.important(
6291
- ` ${label}: ${contract.usedTags}/${contract.totalTags} tags used`
6292
- );
6293
- if (contract.unusedTags.length > 0) {
6322
+ const { designSystemCoverage, designSystemReuse } = result;
6323
+ const reuseTemplates = Object.keys(designSystemReuse.perTemplate);
6324
+ const hasTagCoverage = result.coverage.length > 0;
6325
+ const hasScorecard = designSystemCoverage.length > 0 || reuseTemplates.length > 0;
6326
+ let totalUsedTags = 0;
6327
+ let totalTags = 0;
6328
+ for (const fileCov of result.coverage) {
6329
+ for (const contract of fileCov.contracts) {
6330
+ totalUsedTags += contract.usedTags;
6331
+ totalTags += contract.totalTags;
6332
+ }
6333
+ }
6334
+ let totalCovered = 0;
6335
+ let totalElements = 0;
6336
+ for (const cov of designSystemCoverage) {
6337
+ totalCovered += cov.covered;
6338
+ totalElements += cov.total;
6339
+ }
6340
+ if (options.verbose) {
6341
+ if (hasTagCoverage) {
6342
+ logger.important("");
6343
+ logger.important(chalk.bold("📦 Tag Coverage"));
6344
+ for (const fileCov of result.coverage) {
6345
+ logger.important(` ${fileCov.file}`);
6346
+ for (const contract of fileCov.contracts) {
6347
+ const label = contract.key ? `${contract.key} (${contract.contractName})` : contract.contractName;
6294
6348
  logger.important(
6295
- chalk.gray(` Unused: ${contract.unusedTags.join(", ")}`)
6349
+ ` ${label}: ${contract.usedTags}/${contract.totalTags} tags used`
6296
6350
  );
6351
+ if (contract.unusedTags.length > 0) {
6352
+ logger.important(
6353
+ chalk.gray(` Unused: ${contract.unusedTags.join(", ")}`)
6354
+ );
6355
+ }
6297
6356
  }
6298
6357
  }
6299
6358
  }
6359
+ if (hasScorecard) {
6360
+ logger.important("");
6361
+ logger.important(chalk.bold("📊 Design-system scorecard"));
6362
+ for (const cov of designSystemCoverage) {
6363
+ const pct = Math.round(cov.coveragePct * 100);
6364
+ const lowFlag = pct < 50 ? chalk.yellow(" ⚠ low") : "";
6365
+ logger.important(
6366
+ chalk.gray(
6367
+ ` Coverage: ${cov.file} ${pct}% (${cov.covered}/${cov.total} elements)`
6368
+ ) + lowFlag
6369
+ );
6370
+ }
6371
+ if (reuseTemplates.length > 0) {
6372
+ const parts = reuseTemplates.map((t) => {
6373
+ const n = designSystemReuse.perTemplate[t];
6374
+ return n < 2 ? `${t} ×${n}${chalk.yellow(" ⚠ single-use")}` : `${t} ×${n}`;
6375
+ });
6376
+ logger.important(chalk.gray(` Reuse: ${parts.join(" ")}`));
6377
+ }
6378
+ logger.important(
6379
+ chalk.gray(
6380
+ ` ${designSystemReuse.reusedOfCatalogued} of ${designSystemReuse.catalogued} catalogued templates reused > 1`
6381
+ )
6382
+ );
6383
+ }
6384
+ } else if (hasTagCoverage || hasScorecard) {
6385
+ logger.important("");
6386
+ if (hasTagCoverage) {
6387
+ const pct = totalTags ? Math.round(totalUsedTags / totalTags * 100) : 0;
6388
+ logger.important(
6389
+ chalk.bold("📦 Tag coverage: ") + `${pct}% (${totalUsedTags}/${totalTags} tags used across ${result.coverage.length} page(s))`
6390
+ );
6391
+ }
6392
+ if (hasScorecard) {
6393
+ const pct = totalElements ? Math.round(totalCovered / totalElements * 100) : 0;
6394
+ const lowFlag = pct < 50 ? chalk.yellow(" ⚠ low") : "";
6395
+ logger.important(
6396
+ chalk.bold("📊 Design-system scorecard: ") + `${pct}% element coverage (${totalCovered}/${totalElements}), ${designSystemReuse.reusedOfCatalogued} of ${designSystemReuse.catalogued} catalogued templates reused > 1` + lowFlag
6397
+ );
6398
+ }
6399
+ logger.important(chalk.gray(" Run validate -v for per-page details."));
6300
6400
  }
6301
6401
  logger.important("");
6302
6402
  if (result.valid && result.warnings.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jay-framework/jay-stack-cli",
3
- "version": "0.24.7",
3
+ "version": "0.25.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,17 +25,17 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@inquirer/prompts": "^8.5.2",
28
- "@jay-framework/compiler-inline-composition": "^0.24.5",
29
- "@jay-framework/compiler-jay-html": "^0.24.7",
30
- "@jay-framework/compiler-shared": "^0.24.7",
31
- "@jay-framework/dev-server": "^0.24.7",
32
- "@jay-framework/fullstack-component": "^0.24.7",
33
- "@jay-framework/logger": "^0.24.7",
34
- "@jay-framework/plugin-validator": "^0.24.7",
35
- "@jay-framework/production-build": "^0.24.7",
36
- "@jay-framework/production-server": "^0.24.7",
37
- "@jay-framework/stack-server-build": "^0.24.7",
38
- "@jay-framework/stack-server-runtime": "^0.24.7",
28
+ "@jay-framework/compiler-inline-composition": "^0.25.0",
29
+ "@jay-framework/compiler-jay-html": "^0.25.0",
30
+ "@jay-framework/compiler-shared": "^0.25.0",
31
+ "@jay-framework/dev-server": "^0.25.0",
32
+ "@jay-framework/fullstack-component": "^0.25.0",
33
+ "@jay-framework/logger": "^0.25.0",
34
+ "@jay-framework/plugin-validator": "^0.25.0",
35
+ "@jay-framework/production-build": "^0.25.0",
36
+ "@jay-framework/production-server": "^0.25.0",
37
+ "@jay-framework/stack-server-build": "^0.25.0",
38
+ "@jay-framework/stack-server-runtime": "^0.25.0",
39
39
  "chalk": "^4.1.2",
40
40
  "commander": "^14.0.0",
41
41
  "express": "^5.0.1",
@@ -46,7 +46,7 @@
46
46
  "yaml": "^2.3.4"
47
47
  },
48
48
  "devDependencies": {
49
- "@jay-framework/dev-environment": "^0.24.7",
49
+ "@jay-framework/dev-environment": "^0.25.0",
50
50
  "@types/express": "^5.0.2",
51
51
  "@types/node": "^22.15.21",
52
52
  "nodemon": "^3.0.3",