@cssdoc/core 0.2.0 → 0.3.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.
package/dist/index.mjs CHANGED
@@ -1,11 +1,35 @@
1
1
  import postcss from "postcss";
2
+ import { CSSDOC_TAGS, CSSDOC_TAG_NAMES } from "@cssdoc/spec";
2
3
  //#region src/modifier.ts
4
+ /**
5
+ * The pseudo-classes cssdoc treats as component states by default — form and UI states a component
6
+ * meaningfully declares. Ubiquitous interaction pseudos (`:hover`, `:focus`, `:active`) are omitted so
7
+ * incidental rules don't become documented states; add them via `statePseudoClasses` if you want them.
8
+ */
9
+ const DEFAULT_STATE_PSEUDO_CLASSES = [
10
+ "checked",
11
+ "disabled",
12
+ "enabled",
13
+ "indeterminate",
14
+ "default",
15
+ "open",
16
+ "placeholder-shown",
17
+ "read-only",
18
+ "read-write",
19
+ "required",
20
+ "optional",
21
+ "valid",
22
+ "invalid",
23
+ "in-range",
24
+ "out-of-range"
25
+ ];
3
26
  /** The built-in convention presets. Other schemes use the custom object (see the docs). */
4
27
  const MODIFIER_PRESETS = {
5
- /** BEM / SUIT — `.button--primary`. The default. */
28
+ /** BEM / SUIT — `.button--primary`, with `.button__element` sub-elements. The default. */
6
29
  bem: {
7
30
  structure: "suffix",
8
31
  separator: "--",
32
+ elementSeparator: "__",
9
33
  propValue: false
10
34
  },
11
35
  /** rscss — `.button.-color-secondary`, split into `prop`/`value`. */
@@ -39,6 +63,9 @@ function resolveModifierConvention(input) {
39
63
  return {
40
64
  structure: base.structure,
41
65
  separator: base.separator,
66
+ ...base.elementSeparator !== void 0 ? { elementSeparator: base.elementSeparator } : {},
67
+ ...base.statePrefixes !== void 0 ? { statePrefixes: base.statePrefixes } : {},
68
+ statePseudoClasses: base.statePseudoClasses ?? [...DEFAULT_STATE_PSEUDO_CLASSES],
42
69
  propValue: base.propValue ?? false,
43
70
  propValueSeparator: base.propValueSeparator ?? "-"
44
71
  };
@@ -54,10 +81,45 @@ const unquote$1 = (v) => v.trim().replace(/^(["'])([\s\S]*)\1$/u, "$2");
54
81
  */
55
82
  var ModifierMatcher = class {
56
83
  convention;
57
- sepEsc;
84
+ /** The separator(s), longest-first so overlapping prefixes (e.g. `--` before `-`) match greedily. */
85
+ separators;
86
+ /** The separators as a non-capturing regex alternation, e.g. `(?:is-|has-)` (or `(?:)` when empty). */
87
+ sepAlt;
88
+ /** BEM-style element separators (longest-first), or empty when the convention has none. */
89
+ elementSeparators;
90
+ /** The element separators as a non-capturing alternation, or `""` when there are none. */
91
+ elementSepAlt;
92
+ /** State-class prefixes (longest-first), or empty when the convention has none. */
93
+ statePrefixes;
94
+ /** Native pseudo-classes (no `:`) recognized as states. */
95
+ statePseudoClasses;
58
96
  constructor(convention) {
59
97
  this.convention = convention;
60
- this.sepEsc = escapeRe(convention.separator);
98
+ const longestFirst = (value) => (Array.isArray(value) ? value : [value]).slice().sort((a, b) => b.length - a.length);
99
+ this.separators = longestFirst(convention.separator);
100
+ this.sepAlt = `(?:${this.separators.map(escapeRe).join("|")})`;
101
+ this.elementSeparators = convention.elementSeparator ? longestFirst(convention.elementSeparator).filter((s) => s !== "") : [];
102
+ this.elementSepAlt = this.elementSeparators.length ? `(?:${this.elementSeparators.map(escapeRe).join("|")})` : "";
103
+ this.statePrefixes = (convention.statePrefixes ?? []).filter((p) => p !== "").slice().sort((a, b) => b.length - a.length);
104
+ this.statePseudoClasses = new Set(convention.statePseudoClasses ?? []);
105
+ }
106
+ /** Does a class name (no leading dot) start with one of the convention's state prefixes? */
107
+ isStateClass(name) {
108
+ return this.statePrefixes.some((p) => name.startsWith(p));
109
+ }
110
+ /** Strip a leading chained-class separator from `name` (the longest that matches), else return it. */
111
+ stripPrefix(name) {
112
+ for (const s of this.separators) if (name.startsWith(s)) return name.slice(s.length);
113
+ return name;
114
+ }
115
+ /** Return the suffix body — the part after the first (longest) non-empty separator occurrence. */
116
+ stripToBody(name) {
117
+ for (const s of this.separators) {
118
+ if (s === "") continue;
119
+ const at = name.indexOf(s);
120
+ if (at !== -1) return name.slice(at + s.length);
121
+ }
122
+ return name;
61
123
  }
62
124
  /**
63
125
  * Every modifier attached to `baseNoDot` within one selector. `selector` should have its pseudos
@@ -76,7 +138,7 @@ var ModifierMatcher = class {
76
138
  });
77
139
  };
78
140
  if (this.convention.structure === "suffix") {
79
- const re = new RegExp(`\\.(${baseEsc}${this.sepEsc}[\\w-]+)`, "gu");
141
+ const re = new RegExp(`\\.(${baseEsc}${this.sepAlt}[\\w-]+)`, "gu");
80
142
  for (const m of selector.matchAll(re)) push(m[1]);
81
143
  return hits;
82
144
  }
@@ -88,12 +150,93 @@ var ModifierMatcher = class {
88
150
  }
89
151
  return hits;
90
152
  }
91
- const cls = `\\.${this.sepEsc}[\\w-]+`;
153
+ const cls = `\\.${this.sepAlt}[\\w-]+`;
92
154
  const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:${cls})+)`, "gu");
93
- const inner = new RegExp(`\\.(${this.sepEsc}[\\w-]+)`, "gu");
94
- for (const m of selector.matchAll(chain)) for (const c of m[1].matchAll(inner)) push(c[1]);
155
+ const inner = new RegExp(`\\.(${this.sepAlt}[\\w-]+)`, "gu");
156
+ for (const m of selector.matchAll(chain)) for (const c of m[1].matchAll(inner)) if (!this.isStateClass(c[1])) push(c[1]);
95
157
  return hits;
96
158
  }
159
+ /**
160
+ * Every BEM-style element attached to `baseNoDot` within one selector (`.base<elementSep><name>`),
161
+ * as parts, each with any element-scoped modifiers (`.base__element--mod` → element `base__element`
162
+ * with modifier `mod`). Only meaningful for `suffix` conventions with an `elementSeparator`.
163
+ */
164
+ elementsIn(selector, baseNoDot) {
165
+ if (this.convention.structure !== "suffix" || this.elementSepAlt === "") return [];
166
+ const baseEsc = escapeRe(baseNoDot);
167
+ const re = new RegExp(`\\.(${baseEsc}${this.elementSepAlt}[\\w-]+)`, "gu");
168
+ const byName = /* @__PURE__ */ new Map();
169
+ for (const m of selector.matchAll(re)) {
170
+ const { element, modifier } = this.splitElementModifier(m[1], baseNoDot);
171
+ const mods = byName.get(element) ?? [];
172
+ if (modifier && !mods.some((x) => x.name === modifier.name)) mods.push(modifier);
173
+ byName.set(element, mods);
174
+ }
175
+ return [...byName].map(([name, modifiers]) => ({
176
+ name,
177
+ modifiers
178
+ }));
179
+ }
180
+ /**
181
+ * Split `.base__element--mod`-style tokens into the element class (`base__element`) and, if a
182
+ * modifier separator follows the element name, the element-scoped modifier.
183
+ */
184
+ splitElementModifier(token, baseNoDot) {
185
+ const elsep = this.elementSeparators.find((s) => token.startsWith(baseNoDot + s));
186
+ if (!elsep) return { element: token };
187
+ const afterElement = baseNoDot.length + elsep.length;
188
+ let at = -1;
189
+ for (const sep of this.separators) {
190
+ if (sep === "") continue;
191
+ const i = token.indexOf(sep, afterElement);
192
+ if (i !== -1 && (at === -1 || i < at)) at = i;
193
+ }
194
+ if (at === -1) return { element: token };
195
+ return {
196
+ element: token.slice(0, at),
197
+ modifier: {
198
+ name: token,
199
+ ...this.analyze(token)
200
+ }
201
+ };
202
+ }
203
+ /**
204
+ * Every state class chained to `baseNoDot` within one selector — a class whose name starts with one
205
+ * of the convention's {@link ModifierConvention.statePrefixes} (e.g. `.tabs.is-open` → `is-open`).
206
+ * Empty when the convention sets no state prefixes.
207
+ */
208
+ statesIn(selector, baseNoDot) {
209
+ if (this.statePrefixes.length === 0) return [];
210
+ const baseEsc = escapeRe(baseNoDot);
211
+ const prefixAlt = `(?:${this.statePrefixes.map(escapeRe).join("|")})`;
212
+ const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:\\.[\\w-]+)+)`, "gu");
213
+ const inner = new RegExp(`\\.(${prefixAlt}[\\w-]+)`, "gu");
214
+ const seen = /* @__PURE__ */ new Set();
215
+ const out = [];
216
+ for (const m of selector.matchAll(chain)) for (const c of m[1].matchAll(inner)) if (!seen.has(c[1])) {
217
+ seen.add(c[1]);
218
+ out.push({ name: c[1] });
219
+ }
220
+ return out;
221
+ }
222
+ /**
223
+ * Every native pseudo-class on the selector recognized as a state — a `:name` whose `name` is in the
224
+ * convention's {@link ModifierConvention.statePseudoClasses} (e.g. `.tab:disabled` → `disabled`).
225
+ * Pseudo-elements (`::part`) and pseudos not in the set are ignored.
226
+ */
227
+ pseudoStatesIn(selector) {
228
+ if (this.statePseudoClasses.size === 0) return [];
229
+ const seen = /* @__PURE__ */ new Set();
230
+ const out = [];
231
+ for (const m of selector.matchAll(/(?<!:):([\w-]+)/gu)) {
232
+ const name = m[1];
233
+ if (this.statePseudoClasses.has(name) && !seen.has(name)) {
234
+ seen.add(name);
235
+ out.push({ name });
236
+ }
237
+ }
238
+ return out;
239
+ }
97
240
  /** Derive `prop`/`value` for a modifier `name` (as returned by {@link modifiersIn} or authored). */
98
241
  analyze(name) {
99
242
  if (this.convention.structure === "attribute") {
@@ -101,15 +244,11 @@ var ModifierMatcher = class {
101
244
  const eq = canonical.indexOf("=");
102
245
  const attr = eq === -1 ? canonical : canonical.slice(0, eq);
103
246
  return {
104
- prop: attr.startsWith(this.convention.separator) ? attr.slice(this.convention.separator.length) : attr,
247
+ prop: this.stripPrefix(attr),
105
248
  value: eq === -1 ? void 0 : unquote$1(canonical.slice(eq + 1))
106
249
  };
107
250
  }
108
- let body;
109
- if (this.convention.structure === "suffix") {
110
- const at = name.indexOf(this.convention.separator);
111
- body = at === -1 ? name : name.slice(at + this.convention.separator.length);
112
- } else body = this.convention.separator && name.startsWith(this.convention.separator) ? name.slice(this.convention.separator.length) : name;
251
+ const body = this.convention.structure === "suffix" ? this.stripToBody(name) : this.stripPrefix(name);
113
252
  if (!this.convention.propValue) return { prop: body };
114
253
  const sep = this.convention.propValueSeparator ?? "-";
115
254
  const at = body.indexOf(sep);
@@ -138,12 +277,25 @@ var ModifierMatcher = class {
138
277
  looksLikeUsage(token, baseNoDot) {
139
278
  if (this.convention.structure === "attribute") return this.attributeMatches(this.normalizeMember(token));
140
279
  const name = token.replace(/^\./u, "");
280
+ if (this.isStateClass(name)) return false;
141
281
  if (this.convention.structure === "suffix") {
142
- if (baseNoDot) return name.startsWith(`${baseNoDot}${this.convention.separator}`);
143
- return this.convention.separator !== "" && name.includes(this.convention.separator);
282
+ if (baseNoDot) return this.separators.some((s) => name.startsWith(`${baseNoDot}${s}`));
283
+ return this.separators.some((s) => s !== "" && name.includes(s));
144
284
  }
145
285
  if (baseNoDot && name === baseNoDot) return false;
146
- return name.startsWith(this.convention.separator);
286
+ return this.separators.some((s) => name.startsWith(s));
287
+ }
288
+ /**
289
+ * Classify a host-document class token relative to `baseNoDot`: a `modifier` usage, a `state` class
290
+ * (a `statePrefixes` prefix), a BEM `element` class (`base<elementSep>…`), or `undefined` if it's
291
+ * none of those. Consumer-side linting routes each kind to the right "unknown-…" check.
292
+ */
293
+ usageKind(token, baseNoDot) {
294
+ if (this.convention.structure === "attribute") return this.looksLikeUsage(token, baseNoDot) ? "modifier" : void 0;
295
+ const name = token.replace(/^\./u, "");
296
+ if (this.isStateClass(name)) return "state";
297
+ if (baseNoDot && this.convention.structure === "suffix" && this.elementSeparators.some((s) => name.startsWith(`${baseNoDot}${s}`))) return "element";
298
+ return this.looksLikeUsage(token, baseNoDot) ? "modifier" : void 0;
147
299
  }
148
300
  /** Canonicalize an attribute expression (bracket-inner): normalize quotes to double, trim. */
149
301
  normalizeAttribute(inner) {
@@ -152,15 +304,29 @@ var ModifierMatcher = class {
152
304
  if (eq === -1) return trimmed;
153
305
  return `${trimmed.slice(0, eq + 1)}"${unquote$1(trimmed.slice(eq + 1))}"`;
154
306
  }
155
- /** Does an attribute-expression's name carry the convention's required prefix? */
307
+ /** Does an attribute-expression's name carry one of the convention's required prefixes? */
156
308
  attributeMatches(name) {
157
309
  const eq = name.indexOf("=");
158
310
  const attr = (eq === -1 ? name : name.slice(0, eq)).replace(/[~|^$*]$/u, "");
159
- return attr.length > 0 && attr.startsWith(this.convention.separator);
311
+ return attr.length > 0 && this.separators.some((s) => attr.startsWith(s));
160
312
  }
161
313
  };
162
314
  //#endregion
163
315
  //#region src/configuration.ts
316
+ /**
317
+ * The tag-definition registry — cssdoc's analog to `@microsoft/tsdoc`'s `TSDocConfiguration` /
318
+ * `TSDocTagDefinition`. It names the vocabulary a parse understands: the built-in **standard** tags
319
+ * (seeded automatically) plus any **custom** tags registered on top (typically loaded from a
320
+ * `cssdoc.json` by `@cssdoc/config`). The parser consults the configuration to decide which record
321
+ * tags open a boundary, which standard tags are active, and which unknown tags to capture as custom
322
+ * blocks rather than ignore.
323
+ *
324
+ * The vocabulary is expansive and modeled on TSDoc's kind taxonomy — `record` / `block` / `modifier` /
325
+ * `inline` — and covers the modern CSSOM surface. See `@cssdoc/spec`'s `grammar/CssDoc.grammarkdown`
326
+ * for the formal shape of each tag.
327
+ *
328
+ * @module
329
+ */
164
330
  const TAG_NAME_RE = /^@?[a-zA-Z][a-zA-Z0-9-]*$/u;
165
331
  /** One tag in the vocabulary: its name, kind, and how it may be used. */
166
332
  var CssDocTagDefinition = class {
@@ -269,258 +435,17 @@ var CssDocConfiguration = class CssDocConfiguration {
269
435
  setNoStandardTags() {
270
436
  this.setSupportForTags(CssDocConfiguration.standardTagNames.map((n) => this._byName.get(n)), false);
271
437
  }
272
- /** The names (without `@`) of every standard tag. */
273
- static standardTagNames = [
274
- "component",
275
- "name",
276
- "utility",
277
- "rule",
278
- "declaration",
279
- "class",
280
- "summary",
281
- "remarks",
282
- "privateRemarks",
283
- "deprecated",
284
- "example",
285
- "see",
286
- "since",
287
- "group",
288
- "category",
289
- "defaultValue",
290
- "modifier",
291
- "part",
292
- "csspart",
293
- "cssproperty",
294
- "property",
295
- "cssstate",
296
- "slot",
297
- "function",
298
- "keyframes",
299
- "animation",
300
- "layer",
301
- "container",
302
- "supports",
303
- "media",
304
- "responsive",
305
- "a11y",
306
- "accessibility",
307
- "structure",
308
- "demo",
309
- "alpha",
310
- "beta",
311
- "experimental",
312
- "internal",
313
- "public",
314
- "link",
315
- "inheritDoc",
316
- "label"
317
- ];
318
- /** A fresh set of the standard tag definitions (new instances on each call). */
438
+ /** The names (without `@`) of every standard tag — the canonical vocabulary from `@cssdoc/spec`. */
439
+ static standardTagNames = CSSDOC_TAG_NAMES;
440
+ /** A fresh set of the standard tag definitions (new instances on each call), built from the spec. */
319
441
  static standardTags() {
320
- return [
321
- def({
322
- tagName: "component",
323
- syntaxKind: "record",
324
- recordKind: "component"
325
- }),
326
- def({
327
- tagName: "name",
328
- syntaxKind: "record",
329
- recordKind: "component"
330
- }),
331
- def({
332
- tagName: "utility",
333
- syntaxKind: "record",
334
- recordKind: "utility"
335
- }),
336
- def({
337
- tagName: "rule",
338
- syntaxKind: "record",
339
- recordKind: "rule"
340
- }),
341
- def({
342
- tagName: "declaration",
343
- syntaxKind: "record",
344
- recordKind: "declaration"
345
- }),
346
- def({
347
- tagName: "class",
348
- syntaxKind: "block"
349
- }),
350
- def({
351
- tagName: "summary",
352
- syntaxKind: "block"
353
- }),
354
- def({
355
- tagName: "remarks",
356
- syntaxKind: "block"
357
- }),
358
- def({
359
- tagName: "privateRemarks",
360
- syntaxKind: "block"
361
- }),
362
- def({
363
- tagName: "deprecated",
364
- syntaxKind: "block"
365
- }),
366
- def({
367
- tagName: "example",
368
- syntaxKind: "block",
369
- allowMultiple: true
370
- }),
371
- def({
372
- tagName: "see",
373
- syntaxKind: "block",
374
- allowMultiple: true
375
- }),
376
- def({
377
- tagName: "since",
378
- syntaxKind: "block"
379
- }),
380
- def({
381
- tagName: "group",
382
- syntaxKind: "block"
383
- }),
384
- def({
385
- tagName: "category",
386
- syntaxKind: "block",
387
- aliasFor: "group"
388
- }),
389
- def({
390
- tagName: "defaultValue",
391
- syntaxKind: "block"
392
- }),
393
- def({
394
- tagName: "modifier",
395
- syntaxKind: "block",
396
- allowMultiple: true
397
- }),
398
- def({
399
- tagName: "part",
400
- syntaxKind: "block",
401
- allowMultiple: true
402
- }),
403
- def({
404
- tagName: "csspart",
405
- syntaxKind: "block",
406
- allowMultiple: true,
407
- aliasFor: "part"
408
- }),
409
- def({
410
- tagName: "cssproperty",
411
- syntaxKind: "block",
412
- allowMultiple: true
413
- }),
414
- def({
415
- tagName: "property",
416
- syntaxKind: "block",
417
- allowMultiple: true,
418
- aliasFor: "cssproperty"
419
- }),
420
- def({
421
- tagName: "cssstate",
422
- syntaxKind: "block",
423
- allowMultiple: true
424
- }),
425
- def({
426
- tagName: "slot",
427
- syntaxKind: "block",
428
- allowMultiple: true
429
- }),
430
- def({
431
- tagName: "function",
432
- syntaxKind: "block",
433
- allowMultiple: true
434
- }),
435
- def({
436
- tagName: "keyframes",
437
- syntaxKind: "block",
438
- allowMultiple: true
439
- }),
440
- def({
441
- tagName: "animation",
442
- syntaxKind: "block",
443
- allowMultiple: true,
444
- aliasFor: "keyframes"
445
- }),
446
- def({
447
- tagName: "layer",
448
- syntaxKind: "block",
449
- allowMultiple: true
450
- }),
451
- def({
452
- tagName: "container",
453
- syntaxKind: "block",
454
- allowMultiple: true
455
- }),
456
- def({
457
- tagName: "supports",
458
- syntaxKind: "block",
459
- allowMultiple: true
460
- }),
461
- def({
462
- tagName: "media",
463
- syntaxKind: "block",
464
- allowMultiple: true
465
- }),
466
- def({
467
- tagName: "responsive",
468
- syntaxKind: "block",
469
- allowMultiple: true,
470
- aliasFor: "media"
471
- }),
472
- def({
473
- tagName: "a11y",
474
- syntaxKind: "block",
475
- allowMultiple: true
476
- }),
477
- def({
478
- tagName: "accessibility",
479
- syntaxKind: "block",
480
- allowMultiple: true,
481
- aliasFor: "a11y"
482
- }),
483
- def({
484
- tagName: "structure",
485
- syntaxKind: "block"
486
- }),
487
- def({
488
- tagName: "demo",
489
- syntaxKind: "block"
490
- }),
491
- def({
492
- tagName: "alpha",
493
- syntaxKind: "modifier"
494
- }),
495
- def({
496
- tagName: "beta",
497
- syntaxKind: "modifier"
498
- }),
499
- def({
500
- tagName: "experimental",
501
- syntaxKind: "modifier"
502
- }),
503
- def({
504
- tagName: "internal",
505
- syntaxKind: "modifier"
506
- }),
507
- def({
508
- tagName: "public",
509
- syntaxKind: "modifier"
510
- }),
511
- def({
512
- tagName: "link",
513
- syntaxKind: "inline"
514
- }),
515
- def({
516
- tagName: "inheritDoc",
517
- syntaxKind: "inline"
518
- }),
519
- def({
520
- tagName: "label",
521
- syntaxKind: "inline"
522
- })
523
- ];
442
+ return CSSDOC_TAGS.map((tag) => def({
443
+ tagName: tag.name,
444
+ syntaxKind: tag.kind,
445
+ ...tag.recordKind ? { recordKind: tag.recordKind } : {},
446
+ ...tag.aliasFor ? { aliasFor: tag.aliasFor } : {},
447
+ ...tag.allowMultiple ? { allowMultiple: true } : {}
448
+ }));
524
449
  }
525
450
  };
526
451
  //#endregion
@@ -532,9 +457,9 @@ var CssDocConfiguration = class CssDocConfiguration {
532
457
  * Manifest names (`@cssproperty`, `@csspart`, `@cssstate`) where they exist, so the vocabulary is
533
458
  * standards-aligned.
534
459
  *
535
- * The grammar is specified formally in `grammar/CssDoc.grammarkdown` (RFC-style, modeling TSDoc's
536
- * `DeclarationReference.grammarkdown`); the functions here are hand-written to conform to those
537
- * productions, and `tests/grammar.test.ts` keeps the spec valid. Which tags are active — and which
460
+ * The grammar is specified formally in `@cssdoc/spec`'s `grammar/CssDoc.grammarkdown` (RFC-style,
461
+ * modeling TSDoc's `DeclarationReference.grammarkdown`); the functions here are hand-written to conform
462
+ * to those productions, and a test in `@cssdoc/spec` keeps the spec valid. Which tags are active — and which
538
463
  * custom tags to capture — is governed by a {@link CssDocConfiguration}.
539
464
  *
540
465
  * A block looks like:
@@ -615,6 +540,7 @@ function parseDocComment(raw, configuration = new CssDocConfiguration()) {
615
540
  const doc = {
616
541
  modifiers: /* @__PURE__ */ new Map(),
617
542
  parts: /* @__PURE__ */ new Map(),
543
+ cssParts: /* @__PURE__ */ new Map(),
618
544
  cssProperties: [],
619
545
  cssStates: /* @__PURE__ */ new Map(),
620
546
  slots: /* @__PURE__ */ new Map(),
@@ -674,9 +600,12 @@ function applyBlockTag(doc, canonical, tagName, rest) {
674
600
  case "a11y":
675
601
  doc.accessibility = rest.trim();
676
602
  break;
677
- case "structure":
678
- doc.structure = parseStructure(rest);
603
+ case "structure": {
604
+ const { description, css } = splitStructureBody(rest);
605
+ doc.structure = parseStructure(css);
606
+ if (description) doc.structureDescription = description;
679
607
  break;
608
+ }
680
609
  case "modifier": {
681
610
  const { head, description } = splitDesc(rest);
682
611
  doc.modifiers.set(head.replace(/^\./u, ""), parseModifierBody(description));
@@ -687,6 +616,11 @@ function applyBlockTag(doc, canonical, tagName, rest) {
687
616
  doc.parts.set(head.replace(/^\./u, ""), description ?? "");
688
617
  break;
689
618
  }
619
+ case "csspart": {
620
+ const { head, description } = splitDesc(rest);
621
+ doc.cssParts.set(head.replace(/^\./u, ""), description ?? "");
622
+ break;
623
+ }
690
624
  case "cssproperty": {
691
625
  const propMatch = rest.match(/^(--[\w-]+)\s*(<[^>]+>)?\s*(?:(?:—|-{1,2})\s*(.*))?$/u);
692
626
  if (propMatch) doc.cssProperties.push({
@@ -772,37 +706,51 @@ function recordNameOf(commentText, configuration) {
772
706
  return commentText.match(new RegExp(`@(?:${tagNames.join("|")})\\s+(\\S+)`, "u"))?.[1];
773
707
  }
774
708
  /**
775
- * Parse a `@structure` body an indentation-nested list of element selectors — into a
776
- * {@link StructureNode} tree. Indentation depth (any consistent width) sets nesting; blank lines are
777
- * ignored.
709
+ * Peel an optional leading prose description off a `@structure` body. The nested CSS begins at the
710
+ * first line that opens a rule (contains `{`); any prose before it is the description. If the body
711
+ * begins with a selector — including a multi-line selector like `.a,\n.b {` — there is no description
712
+ * and the whole body is CSS.
713
+ *
714
+ * @param raw - The `@structure` body (description and/or nested CSS).
715
+ * @returns The split `description` (when present) and the `css` to parse.
716
+ */
717
+ function splitStructureBody(raw) {
718
+ const lines = raw.split("\n");
719
+ const braceLine = lines.findIndex((l) => l.includes("{"));
720
+ if (braceLine <= 0) return { css: raw };
721
+ const lead = lines.slice(0, braceLine);
722
+ if (/^\s*[.#:[*&>+~]/u.test(lead[0])) return { css: raw };
723
+ const description = lead.join("\n").trim();
724
+ return description ? {
725
+ description,
726
+ css: lines.slice(braceLine).join("\n")
727
+ } : { css: raw };
728
+ }
729
+ /**
730
+ * Parse a `@structure` body — nested CSS (brace-delimited rules) — into a {@link StructureNode} tree.
731
+ * Each rule's selector becomes a node; nested rules become its children. Because a node is a real
732
+ * compound selector, `:has()` (contains), `:is()` / selector-lists (one-of), and `:not()` (not) express
733
+ * relationships natively. Leaf nodes are written as empty rules (`.tab {}`). A malformed body parses to
734
+ * an empty tree rather than throwing.
778
735
  *
779
736
  * @example
780
737
  * ```
781
- * .tabs
782
- * .list
783
- * .tab
784
- * .panel
738
+ * .tabs {
739
+ * .list { .tab {} }
740
+ * .panel {}
741
+ * }
785
742
  * ```
786
743
  */
787
744
  function parseStructure(raw) {
788
- const roots = [];
789
- const stack = [];
790
- for (const line of raw.split("\n")) {
791
- if (!line.trim()) continue;
792
- const indent = line.length - line.trimStart().length;
793
- const node = {
794
- selector: line.trim(),
795
- children: []
796
- };
797
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
798
- if (stack.length) stack[stack.length - 1].node.children.push(node);
799
- else roots.push(node);
800
- stack.push({
801
- indent,
802
- node
803
- });
745
+ const build = (nodes) => nodes.filter((n) => n.type === "rule").map((rule) => ({
746
+ selector: rule.selector.trim(),
747
+ children: build(rule.nodes ?? [])
748
+ }));
749
+ try {
750
+ return build(postcss.parse(raw).nodes);
751
+ } catch {
752
+ return [];
804
753
  }
805
- return roots;
806
754
  }
807
755
  //#endregion
808
756
  //#region src/parse.ts
@@ -908,7 +856,15 @@ function collect(nodes, acc, matcher, baseNoDot, prefixNoDot, inScope) {
908
856
  }
909
857
  if (node.type === "rule") {
910
858
  for (const selector of node.selector.split(",")) {
911
- for (const s of selector.matchAll(/:state\(\s*([\w-]+)\s*\)/gu)) if (!acc.states.has(s[1])) acc.states.set(s[1], { name: s[1] });
859
+ for (const s of selector.matchAll(/:state\(\s*([\w-]+)\s*\)/gu)) if (!acc.states.has(s[1])) acc.states.set(s[1], {
860
+ name: s[1],
861
+ kind: "custom"
862
+ });
863
+ for (const ps of matcher.pseudoStatesIn(selector)) if (!acc.states.has(ps.name)) acc.states.set(ps.name, {
864
+ name: ps.name,
865
+ kind: "pseudo-class"
866
+ });
867
+ for (const sp of selector.matchAll(/::part\(\s*([\w-]+)\s*\)/gu)) if (!acc.shadowParts.has(sp[1])) acc.shadowParts.set(sp[1], { name: sp[1] });
912
868
  const bare = selector.replace(/::?[\w-]+(\([^)]*\))?/gu, "");
913
869
  const mods = matcher.modifiersIn(bare, baseNoDot);
914
870
  const modNames = new Set(mods.map((mod) => mod.name));
@@ -921,6 +877,22 @@ function collect(nodes, acc, matcher, baseNoDot, prefixNoDot, inScope) {
921
877
  if (pendingCanonical) entry.deprecated = { canonical: pendingCanonical };
922
878
  acc.modifiers.set(mod.name, entry);
923
879
  }
880
+ for (const el of matcher.elementsIn(bare, baseNoDot)) {
881
+ const part = acc.parts.get(el.name) ?? { name: el.name };
882
+ for (const m of el.modifiers) {
883
+ part.modifiers ??= [];
884
+ if (!part.modifiers.some((x) => x.name === m.name)) part.modifiers.push({
885
+ name: m.name,
886
+ prop: m.prop,
887
+ value: m.value
888
+ });
889
+ }
890
+ acc.parts.set(el.name, part);
891
+ }
892
+ for (const st of matcher.statesIn(bare, baseNoDot)) if (!acc.states.has(st.name)) acc.states.set(st.name, {
893
+ name: st.name,
894
+ kind: "class"
895
+ });
924
896
  if (inScope) for (const m of bare.matchAll(/\.([a-z][\w-]*)/gu)) {
925
897
  const part = m[1];
926
898
  if (modNames.has(part)) continue;
@@ -948,6 +920,7 @@ function buildEntry(name, doc, nodes, matcher) {
948
920
  className,
949
921
  modifiers: /* @__PURE__ */ new Map(),
950
922
  parts: /* @__PURE__ */ new Map(),
923
+ shadowParts: /* @__PURE__ */ new Map(),
951
924
  states: /* @__PURE__ */ new Map(),
952
925
  consumed: /* @__PURE__ */ new Set(),
953
926
  declared: /* @__PURE__ */ new Map(),
@@ -988,11 +961,22 @@ function buildEntry(name, doc, nodes, matcher) {
988
961
  description
989
962
  });
990
963
  }
991
- for (const [state, description] of doc.cssStates) {
964
+ for (const [part, description] of doc.cssParts) {
965
+ const existing = acc.shadowParts.get(part);
966
+ if (existing) existing.description = description || existing.description;
967
+ else acc.shadowParts.set(part, {
968
+ name: part,
969
+ description: description || void 0
970
+ });
971
+ }
972
+ for (const [rawState, description] of doc.cssStates) {
973
+ const isPseudo = rawState.startsWith(":");
974
+ const state = isPseudo ? rawState.slice(1) : rawState;
992
975
  const existing = acc.states.get(state);
993
976
  if (existing) existing.description = description || existing.description;
994
977
  else acc.states.set(state, {
995
978
  name: state,
979
+ kind: isPseudo ? "pseudo-class" : "custom",
996
980
  description: description || void 0
997
981
  });
998
982
  }
@@ -1053,7 +1037,11 @@ function buildEntry(name, doc, nodes, matcher) {
1053
1037
  group: doc.group,
1054
1038
  accessibility: doc.accessibility,
1055
1039
  modifiers,
1056
- parts: [...acc.parts.values()].sort(byName),
1040
+ parts: [...acc.parts.values()].sort(byName).map((p) => p.modifiers ? {
1041
+ ...p,
1042
+ modifiers: [...p.modifiers].sort(byName)
1043
+ } : p),
1044
+ shadowParts: [...acc.shadowParts.values()].sort(byName),
1057
1045
  states: [...acc.states.values()].sort(byName),
1058
1046
  slots: [...doc.slots].map(([slotName, description]) => ({
1059
1047
  name: slotName,
@@ -1067,6 +1055,7 @@ function buildEntry(name, doc, nodes, matcher) {
1067
1055
  conditions: acc.conditions,
1068
1056
  examples: doc.examples,
1069
1057
  structure: doc.structure,
1058
+ structureDescription: doc.structureDescription,
1070
1059
  demo: doc.demo,
1071
1060
  deprecated: doc.deprecated,
1072
1061
  see: doc.see,
@@ -1094,7 +1083,7 @@ function parseCssDocs(css, options = {}) {
1094
1083
  const configuration = options.configuration ?? new CssDocConfiguration();
1095
1084
  const matcher = new ModifierMatcher(resolveModifierConvention(options.modifierConvention ?? configuration.modifierConvention));
1096
1085
  const boundary = options.isRecordBoundary ?? ((text) => recordNameOf(text, configuration));
1097
- const root = postcss.parse(css);
1086
+ const root = (options.parse ?? postcss.parse)(css);
1098
1087
  const records = [];
1099
1088
  let current = null;
1100
1089
  for (const node of root.nodes) {
@@ -1162,4 +1151,4 @@ function toJson(model) {
1162
1151
  return `${JSON.stringify(model, null, 2)}\n`;
1163
1152
  }
1164
1153
  //#endregion
1165
- export { CssDocConfiguration, CssDocTagDefinition, DEFAULT_MODIFIER_CONVENTION, MODIFIER_PRESETS, ModifierMatcher, RECORD_TAGS, parseCssDocs, parseDocComment, parseStructure, recordNameOf, resolveModifierConvention, stripCommentFraming, toJson, toMermaid };
1154
+ export { CssDocConfiguration, CssDocTagDefinition, DEFAULT_MODIFIER_CONVENTION, DEFAULT_STATE_PSEUDO_CLASSES, MODIFIER_PRESETS, ModifierMatcher, RECORD_TAGS, parseCssDocs, parseDocComment, parseStructure, recordNameOf, resolveModifierConvention, stripCommentFraming, toJson, toMermaid };