@cssdoc/core 0.1.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,5 +1,332 @@
1
1
  import postcss from "postcss";
2
+ import { CSSDOC_TAGS, CSSDOC_TAG_NAMES } from "@cssdoc/spec";
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
+ ];
26
+ /** The built-in convention presets. Other schemes use the custom object (see the docs). */
27
+ const MODIFIER_PRESETS = {
28
+ /** BEM / SUIT — `.button--primary`, with `.button__element` sub-elements. The default. */
29
+ bem: {
30
+ structure: "suffix",
31
+ separator: "--",
32
+ elementSeparator: "__",
33
+ propValue: false
34
+ },
35
+ /** rscss — `.button.-color-secondary`, split into `prop`/`value`. */
36
+ rscss: {
37
+ structure: "chained",
38
+ separator: "-",
39
+ propValue: true,
40
+ propValueSeparator: "-"
41
+ },
42
+ /** OOCSS / bare chained classes — `.button.primary` (any class chained to the base). */
43
+ bare: {
44
+ structure: "chained",
45
+ separator: "",
46
+ propValue: false
47
+ }
48
+ };
49
+ /** The default convention: BEM. */
50
+ const DEFAULT_MODIFIER_CONVENTION = MODIFIER_PRESETS.bem;
51
+ /**
52
+ * Resolve a preset name or custom object into a fully-populated {@link ModifierConvention} (defaults
53
+ * filled in). No argument resolves to the {@link DEFAULT_MODIFIER_CONVENTION} (BEM).
54
+ *
55
+ * @throws If given an unknown preset name.
56
+ */
57
+ function resolveModifierConvention(input) {
58
+ let base;
59
+ if (input === void 0) base = DEFAULT_MODIFIER_CONVENTION;
60
+ else if (typeof input === "string") base = MODIFIER_PRESETS[input];
61
+ else base = input;
62
+ if (!base) throw new Error(`Unknown modifier convention preset: ${JSON.stringify(input)}`);
63
+ return {
64
+ structure: base.structure,
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],
69
+ propValue: base.propValue ?? false,
70
+ propValueSeparator: base.propValueSeparator ?? "-"
71
+ };
72
+ }
73
+ /** Escape a string for literal use inside a `RegExp`. */
74
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
75
+ /** Strip one layer of matching quotes from an attribute value. */
76
+ const unquote$1 = (v) => v.trim().replace(/^(["'])([\s\S]*)\1$/u, "$2");
77
+ /**
78
+ * The single owner of modifier recognition for one convention: finds modifiers on selectors, derives
79
+ * `prop`/`value`, renders a modifier name back to a selector fragment, and answers "does this host-doc
80
+ * token look like a modifier usage?". Constructed once per parse from the resolved convention.
81
+ */
82
+ var ModifierMatcher = class {
83
+ convention;
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;
96
+ constructor(convention) {
97
+ this.convention = convention;
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;
123
+ }
124
+ /**
125
+ * Every modifier attached to `baseNoDot` within one selector. `selector` should have its pseudos
126
+ * already dropped (as `parse.ts`/`index.ts` do); the base is given without its leading dot.
127
+ */
128
+ modifiersIn(selector, baseNoDot) {
129
+ const baseEsc = escapeRe(baseNoDot);
130
+ const hits = [];
131
+ const seen = /* @__PURE__ */ new Set();
132
+ const push = (name) => {
133
+ if (seen.has(name)) return;
134
+ seen.add(name);
135
+ hits.push({
136
+ name,
137
+ ...this.analyze(name)
138
+ });
139
+ };
140
+ if (this.convention.structure === "suffix") {
141
+ const re = new RegExp(`\\.(${baseEsc}${this.sepAlt}[\\w-]+)`, "gu");
142
+ for (const m of selector.matchAll(re)) push(m[1]);
143
+ return hits;
144
+ }
145
+ if (this.convention.structure === "attribute") {
146
+ const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:\\[[^\\]]*\\])+)`, "gu");
147
+ for (const m of selector.matchAll(chain)) for (const a of m[1].matchAll(/\[([^\]]*)\]/gu)) {
148
+ const name = this.normalizeAttribute(a[1]);
149
+ if (name && this.attributeMatches(name)) push(name);
150
+ }
151
+ return hits;
152
+ }
153
+ const cls = `\\.${this.sepAlt}[\\w-]+`;
154
+ const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:${cls})+)`, "gu");
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]);
157
+ return hits;
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
+ }
240
+ /** Derive `prop`/`value` for a modifier `name` (as returned by {@link modifiersIn} or authored). */
241
+ analyze(name) {
242
+ if (this.convention.structure === "attribute") {
243
+ const canonical = this.normalizeAttribute(name);
244
+ const eq = canonical.indexOf("=");
245
+ const attr = eq === -1 ? canonical : canonical.slice(0, eq);
246
+ return {
247
+ prop: this.stripPrefix(attr),
248
+ value: eq === -1 ? void 0 : unquote$1(canonical.slice(eq + 1))
249
+ };
250
+ }
251
+ const body = this.convention.structure === "suffix" ? this.stripToBody(name) : this.stripPrefix(name);
252
+ if (!this.convention.propValue) return { prop: body };
253
+ const sep = this.convention.propValueSeparator ?? "-";
254
+ const at = body.indexOf(sep);
255
+ if (at === -1) return { prop: body };
256
+ return {
257
+ prop: body.slice(0, at),
258
+ value: body.slice(at + sep.length)
259
+ };
260
+ }
261
+ /** Render a modifier `name` as the selector fragment it denotes (`.name` or `[name]`). */
262
+ selectorFor(name) {
263
+ return this.convention.structure === "attribute" ? `[${name}]` : `.${name}`;
264
+ }
265
+ /** Normalize a member token/expression to its canonical `name` key (the inverse of authoring noise). */
266
+ normalizeMember(token) {
267
+ if (this.convention.structure === "attribute") {
268
+ const inner = token.replace(/^\[/u, "").replace(/\]$/u, "");
269
+ return this.normalizeAttribute(inner);
270
+ }
271
+ return token.replace(/^\./u, "");
272
+ }
273
+ /**
274
+ * Does a token/expression seen in a host document (an HTML class token, or an attribute expression)
275
+ * look like a modifier usage of `baseNoDot`? Replaces the old `startsWith("-")` gate.
276
+ */
277
+ looksLikeUsage(token, baseNoDot) {
278
+ if (this.convention.structure === "attribute") return this.attributeMatches(this.normalizeMember(token));
279
+ const name = token.replace(/^\./u, "");
280
+ if (this.isStateClass(name)) return false;
281
+ if (this.convention.structure === "suffix") {
282
+ if (baseNoDot) return this.separators.some((s) => name.startsWith(`${baseNoDot}${s}`));
283
+ return this.separators.some((s) => s !== "" && name.includes(s));
284
+ }
285
+ if (baseNoDot && name === baseNoDot) return false;
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;
299
+ }
300
+ /** Canonicalize an attribute expression (bracket-inner): normalize quotes to double, trim. */
301
+ normalizeAttribute(inner) {
302
+ const trimmed = inner.trim();
303
+ const eq = trimmed.indexOf("=");
304
+ if (eq === -1) return trimmed;
305
+ return `${trimmed.slice(0, eq + 1)}"${unquote$1(trimmed.slice(eq + 1))}"`;
306
+ }
307
+ /** Does an attribute-expression's name carry one of the convention's required prefixes? */
308
+ attributeMatches(name) {
309
+ const eq = name.indexOf("=");
310
+ const attr = (eq === -1 ? name : name.slice(0, eq)).replace(/[~|^$*]$/u, "");
311
+ return attr.length > 0 && this.separators.some((s) => attr.startsWith(s));
312
+ }
313
+ };
314
+ //#endregion
2
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
+ */
3
330
  const TAG_NAME_RE = /^@?[a-zA-Z][a-zA-Z0-9-]*$/u;
4
331
  /** One tag in the vocabulary: its name, kind, and how it may be used. */
5
332
  var CssDocTagDefinition = class {
@@ -47,9 +374,18 @@ var CssDocConfiguration = class CssDocConfiguration {
47
374
  _tagDefinitions = [];
48
375
  _byName = /* @__PURE__ */ new Map();
49
376
  _supported = /* @__PURE__ */ new Set();
377
+ _modifierConvention = DEFAULT_MODIFIER_CONVENTION;
50
378
  constructor() {
51
379
  this.addTagDefinitions(CssDocConfiguration.standardTags(), true);
52
380
  }
381
+ /** The resolved modifier convention this configuration parses with (defaults to BEM). */
382
+ get modifierConvention() {
383
+ return this._modifierConvention;
384
+ }
385
+ /** Set the modifier convention from a preset name or a custom {@link ModifierConvention}. */
386
+ setModifierConvention(input) {
387
+ this._modifierConvention = resolveModifierConvention(input);
388
+ }
53
389
  /** Every registered tag definition, in registration order. */
54
390
  get tagDefinitions() {
55
391
  return this._tagDefinitions;
@@ -99,258 +435,17 @@ var CssDocConfiguration = class CssDocConfiguration {
99
435
  setNoStandardTags() {
100
436
  this.setSupportForTags(CssDocConfiguration.standardTagNames.map((n) => this._byName.get(n)), false);
101
437
  }
102
- /** The names (without `@`) of every standard tag. */
103
- static standardTagNames = [
104
- "component",
105
- "name",
106
- "utility",
107
- "rule",
108
- "declaration",
109
- "class",
110
- "summary",
111
- "remarks",
112
- "privateRemarks",
113
- "deprecated",
114
- "example",
115
- "see",
116
- "since",
117
- "group",
118
- "category",
119
- "defaultValue",
120
- "modifier",
121
- "part",
122
- "csspart",
123
- "cssproperty",
124
- "property",
125
- "cssstate",
126
- "slot",
127
- "function",
128
- "keyframes",
129
- "animation",
130
- "layer",
131
- "container",
132
- "supports",
133
- "media",
134
- "responsive",
135
- "a11y",
136
- "accessibility",
137
- "structure",
138
- "demo",
139
- "alpha",
140
- "beta",
141
- "experimental",
142
- "internal",
143
- "public",
144
- "link",
145
- "inheritDoc",
146
- "label"
147
- ];
148
- /** 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. */
149
441
  static standardTags() {
150
- return [
151
- def({
152
- tagName: "component",
153
- syntaxKind: "record",
154
- recordKind: "component"
155
- }),
156
- def({
157
- tagName: "name",
158
- syntaxKind: "record",
159
- recordKind: "component"
160
- }),
161
- def({
162
- tagName: "utility",
163
- syntaxKind: "record",
164
- recordKind: "utility"
165
- }),
166
- def({
167
- tagName: "rule",
168
- syntaxKind: "record",
169
- recordKind: "rule"
170
- }),
171
- def({
172
- tagName: "declaration",
173
- syntaxKind: "record",
174
- recordKind: "declaration"
175
- }),
176
- def({
177
- tagName: "class",
178
- syntaxKind: "block"
179
- }),
180
- def({
181
- tagName: "summary",
182
- syntaxKind: "block"
183
- }),
184
- def({
185
- tagName: "remarks",
186
- syntaxKind: "block"
187
- }),
188
- def({
189
- tagName: "privateRemarks",
190
- syntaxKind: "block"
191
- }),
192
- def({
193
- tagName: "deprecated",
194
- syntaxKind: "block"
195
- }),
196
- def({
197
- tagName: "example",
198
- syntaxKind: "block",
199
- allowMultiple: true
200
- }),
201
- def({
202
- tagName: "see",
203
- syntaxKind: "block",
204
- allowMultiple: true
205
- }),
206
- def({
207
- tagName: "since",
208
- syntaxKind: "block"
209
- }),
210
- def({
211
- tagName: "group",
212
- syntaxKind: "block"
213
- }),
214
- def({
215
- tagName: "category",
216
- syntaxKind: "block",
217
- aliasFor: "group"
218
- }),
219
- def({
220
- tagName: "defaultValue",
221
- syntaxKind: "block"
222
- }),
223
- def({
224
- tagName: "modifier",
225
- syntaxKind: "block",
226
- allowMultiple: true
227
- }),
228
- def({
229
- tagName: "part",
230
- syntaxKind: "block",
231
- allowMultiple: true
232
- }),
233
- def({
234
- tagName: "csspart",
235
- syntaxKind: "block",
236
- allowMultiple: true,
237
- aliasFor: "part"
238
- }),
239
- def({
240
- tagName: "cssproperty",
241
- syntaxKind: "block",
242
- allowMultiple: true
243
- }),
244
- def({
245
- tagName: "property",
246
- syntaxKind: "block",
247
- allowMultiple: true,
248
- aliasFor: "cssproperty"
249
- }),
250
- def({
251
- tagName: "cssstate",
252
- syntaxKind: "block",
253
- allowMultiple: true
254
- }),
255
- def({
256
- tagName: "slot",
257
- syntaxKind: "block",
258
- allowMultiple: true
259
- }),
260
- def({
261
- tagName: "function",
262
- syntaxKind: "block",
263
- allowMultiple: true
264
- }),
265
- def({
266
- tagName: "keyframes",
267
- syntaxKind: "block",
268
- allowMultiple: true
269
- }),
270
- def({
271
- tagName: "animation",
272
- syntaxKind: "block",
273
- allowMultiple: true,
274
- aliasFor: "keyframes"
275
- }),
276
- def({
277
- tagName: "layer",
278
- syntaxKind: "block",
279
- allowMultiple: true
280
- }),
281
- def({
282
- tagName: "container",
283
- syntaxKind: "block",
284
- allowMultiple: true
285
- }),
286
- def({
287
- tagName: "supports",
288
- syntaxKind: "block",
289
- allowMultiple: true
290
- }),
291
- def({
292
- tagName: "media",
293
- syntaxKind: "block",
294
- allowMultiple: true
295
- }),
296
- def({
297
- tagName: "responsive",
298
- syntaxKind: "block",
299
- allowMultiple: true,
300
- aliasFor: "media"
301
- }),
302
- def({
303
- tagName: "a11y",
304
- syntaxKind: "block",
305
- allowMultiple: true
306
- }),
307
- def({
308
- tagName: "accessibility",
309
- syntaxKind: "block",
310
- allowMultiple: true,
311
- aliasFor: "a11y"
312
- }),
313
- def({
314
- tagName: "structure",
315
- syntaxKind: "block"
316
- }),
317
- def({
318
- tagName: "demo",
319
- syntaxKind: "block"
320
- }),
321
- def({
322
- tagName: "alpha",
323
- syntaxKind: "modifier"
324
- }),
325
- def({
326
- tagName: "beta",
327
- syntaxKind: "modifier"
328
- }),
329
- def({
330
- tagName: "experimental",
331
- syntaxKind: "modifier"
332
- }),
333
- def({
334
- tagName: "internal",
335
- syntaxKind: "modifier"
336
- }),
337
- def({
338
- tagName: "public",
339
- syntaxKind: "modifier"
340
- }),
341
- def({
342
- tagName: "link",
343
- syntaxKind: "inline"
344
- }),
345
- def({
346
- tagName: "inheritDoc",
347
- syntaxKind: "inline"
348
- }),
349
- def({
350
- tagName: "label",
351
- syntaxKind: "inline"
352
- })
353
- ];
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
+ }));
354
449
  }
355
450
  };
356
451
  //#endregion
@@ -362,9 +457,9 @@ var CssDocConfiguration = class CssDocConfiguration {
362
457
  * Manifest names (`@cssproperty`, `@csspart`, `@cssstate`) where they exist, so the vocabulary is
363
458
  * standards-aligned.
364
459
  *
365
- * The grammar is specified formally in `grammar/CssDoc.grammarkdown` (RFC-style, modeling TSDoc's
366
- * `DeclarationReference.grammarkdown`); the functions here are hand-written to conform to those
367
- * 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
368
463
  * custom tags to capture — is governed by a {@link CssDocConfiguration}.
369
464
  *
370
465
  * A block looks like:
@@ -420,12 +515,13 @@ function parseModifierBody(description) {
420
515
  const dep = description?.match(/^@deprecated\b\s*([\s\S]*)$/u);
421
516
  if (dep) {
422
517
  const rawNote = dep[1].trim();
423
- const link = rawNote.match(/\{@link\s+\.?(-[\w-]+)\s*\}/u);
518
+ const link = rawNote.match(/\{@link\s+(\.?[\w-]+|\[[^\]]*\])\s*\}/u);
519
+ const canonical = link?.[1].replace(/^\./u, "").replace(/^\[/u, "").replace(/\]$/u, "");
424
520
  const note = rawNote.replace(/\{@link\s+[^}]*\}/u, "").trim();
425
521
  if (!note && !link) return { deprecatedFlag: true };
426
522
  return {
427
523
  deprecated: note || void 0,
428
- deprecatedCanonical: link?.[1]
524
+ deprecatedCanonical: canonical
429
525
  };
430
526
  }
431
527
  return { description: description ?? "" };
@@ -444,6 +540,7 @@ function parseDocComment(raw, configuration = new CssDocConfiguration()) {
444
540
  const doc = {
445
541
  modifiers: /* @__PURE__ */ new Map(),
446
542
  parts: /* @__PURE__ */ new Map(),
543
+ cssParts: /* @__PURE__ */ new Map(),
447
544
  cssProperties: [],
448
545
  cssStates: /* @__PURE__ */ new Map(),
449
546
  slots: /* @__PURE__ */ new Map(),
@@ -503,9 +600,12 @@ function applyBlockTag(doc, canonical, tagName, rest) {
503
600
  case "a11y":
504
601
  doc.accessibility = rest.trim();
505
602
  break;
506
- case "structure":
507
- doc.structure = parseStructure(rest);
603
+ case "structure": {
604
+ const { description, css } = splitStructureBody(rest);
605
+ doc.structure = parseStructure(css);
606
+ if (description) doc.structureDescription = description;
508
607
  break;
608
+ }
509
609
  case "modifier": {
510
610
  const { head, description } = splitDesc(rest);
511
611
  doc.modifiers.set(head.replace(/^\./u, ""), parseModifierBody(description));
@@ -516,6 +616,11 @@ function applyBlockTag(doc, canonical, tagName, rest) {
516
616
  doc.parts.set(head.replace(/^\./u, ""), description ?? "");
517
617
  break;
518
618
  }
619
+ case "csspart": {
620
+ const { head, description } = splitDesc(rest);
621
+ doc.cssParts.set(head.replace(/^\./u, ""), description ?? "");
622
+ break;
623
+ }
519
624
  case "cssproperty": {
520
625
  const propMatch = rest.match(/^(--[\w-]+)\s*(<[^>]+>)?\s*(?:(?:—|-{1,2})\s*(.*))?$/u);
521
626
  if (propMatch) doc.cssProperties.push({
@@ -601,37 +706,51 @@ function recordNameOf(commentText, configuration) {
601
706
  return commentText.match(new RegExp(`@(?:${tagNames.join("|")})\\s+(\\S+)`, "u"))?.[1];
602
707
  }
603
708
  /**
604
- * Parse a `@structure` body an indentation-nested list of element selectors — into a
605
- * {@link StructureNode} tree. Indentation depth (any consistent width) sets nesting; blank lines are
606
- * 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.
607
735
  *
608
736
  * @example
609
737
  * ```
610
- * .tabs
611
- * .list
612
- * .tab
613
- * .panel
738
+ * .tabs {
739
+ * .list { .tab {} }
740
+ * .panel {}
741
+ * }
614
742
  * ```
615
743
  */
616
744
  function parseStructure(raw) {
617
- const roots = [];
618
- const stack = [];
619
- for (const line of raw.split("\n")) {
620
- if (!line.trim()) continue;
621
- const indent = line.length - line.trimStart().length;
622
- const node = {
623
- selector: line.trim(),
624
- children: []
625
- };
626
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
627
- if (stack.length) stack[stack.length - 1].node.children.push(node);
628
- else roots.push(node);
629
- stack.push({
630
- indent,
631
- node
632
- });
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 [];
633
753
  }
634
- return roots;
635
754
  }
636
755
  //#endregion
637
756
  //#region src/parse.ts
@@ -649,16 +768,6 @@ function parseStructure(raw) {
649
768
  /** Matches a `var(--name` reference; group 1 is the custom-property name. */
650
769
  const VAR_RE = /var\(\s*(--[\w-]+)/gu;
651
770
  const unquote = (value) => value.trim().replace(/^["']|["']$/gu, "");
652
- /** Split a `-<prop>-<value>` (or boolean `-<flag>`) modifier into its prop/value segments. */
653
- function splitModifier(name) {
654
- const body = name.replace(/^-/u, "");
655
- const dash = body.indexOf("-");
656
- if (dash === -1) return { prop: body };
657
- return {
658
- prop: body.slice(0, dash),
659
- value: body.slice(dash + 1)
660
- };
661
- }
662
771
  /** Record a conditional-support block, de-duplicating by type + query. */
663
772
  function addCondition(acc, condition) {
664
773
  if (!acc.conditions.some((c) => c.type === condition.type && c.query === condition.query)) acc.conditions.push(condition);
@@ -682,13 +791,12 @@ function collectFunction(node, acc) {
682
791
  });
683
792
  }
684
793
  /** Extract every fact from one record's nodes into `acc`. */
685
- function collect(nodes, acc, baseEsc, prefixNoDot, inScope) {
686
- const modRe = new RegExp(`(?:${baseEsc}|:scope)((?:\\.-[\\w-]+)+)`, "gu");
794
+ function collect(nodes, acc, matcher, baseNoDot, prefixNoDot, inScope) {
687
795
  let pendingCanonical;
688
796
  for (const node of nodes) {
689
797
  if (node.type === "comment") {
690
- const dep = node.text.match(/@deprecated.*?use\s+\.(-[\w-]+)/u);
691
- if (dep) pendingCanonical = dep[1];
798
+ const dep = node.text.match(/@deprecated.*?use\s+(\.[\w-]+|\[[^\]]*\])/u);
799
+ if (dep) pendingCanonical = matcher.normalizeMember(dep[1]);
692
800
  continue;
693
801
  }
694
802
  if (node.type === "decl") {
@@ -743,27 +851,51 @@ function collect(nodes, acc, baseEsc, prefixNoDot, inScope) {
743
851
  type: "media",
744
852
  query: node.params.trim()
745
853
  });
746
- if (node.nodes) collect(node.nodes, acc, baseEsc, prefixNoDot, inScope || node.name === "scope");
854
+ if (node.nodes) collect(node.nodes, acc, matcher, baseNoDot, prefixNoDot, inScope || node.name === "scope");
747
855
  continue;
748
856
  }
749
857
  if (node.type === "rule") {
750
858
  for (const selector of node.selector.split(",")) {
751
- 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] });
752
868
  const bare = selector.replace(/::?[\w-]+(\([^)]*\))?/gu, "");
753
- for (const m of bare.matchAll(modRe)) for (const mod of m[1].matchAll(/\.(-[\w-]+)/gu)) {
754
- const modName = mod[1];
755
- const existing = acc.modifiers.get(modName);
756
- const { prop, value } = splitModifier(modName);
757
- const entry = existing ?? {
758
- name: modName,
759
- prop,
760
- value
869
+ const mods = matcher.modifiersIn(bare, baseNoDot);
870
+ const modNames = new Set(mods.map((mod) => mod.name));
871
+ for (const mod of mods) {
872
+ const entry = acc.modifiers.get(mod.name) ?? {
873
+ name: mod.name,
874
+ prop: mod.prop,
875
+ value: mod.value
761
876
  };
762
877
  if (pendingCanonical) entry.deprecated = { canonical: pendingCanonical };
763
- acc.modifiers.set(modName, entry);
878
+ acc.modifiers.set(mod.name, entry);
764
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
+ });
765
896
  if (inScope) for (const m of bare.matchAll(/\.([a-z][\w-]*)/gu)) {
766
897
  const part = m[1];
898
+ if (modNames.has(part)) continue;
767
899
  if (prefixNoDot && part.startsWith(prefixNoDot)) continue;
768
900
  if (!acc.parts.has(part)) acc.parts.set(part, { name: part });
769
901
  }
@@ -775,7 +907,7 @@ function collect(nodes, acc, baseEsc, prefixNoDot, inScope) {
775
907
  }
776
908
  const byName = (a, b) => a.name.localeCompare(b.name);
777
909
  /** Build one entry from its record name, doc comment, and nodes. */
778
- function buildEntry(name, doc, nodes) {
910
+ function buildEntry(name, doc, nodes, matcher) {
779
911
  let className = doc.className ?? "";
780
912
  if (!className) {
781
913
  const bare = nodes.filter((n) => n.type === "rule").map((n) => n.selector.trim()).filter((sel) => /^\.[a-z][\w-]*$/u.test(sel));
@@ -788,6 +920,7 @@ function buildEntry(name, doc, nodes) {
788
920
  className,
789
921
  modifiers: /* @__PURE__ */ new Map(),
790
922
  parts: /* @__PURE__ */ new Map(),
923
+ shadowParts: /* @__PURE__ */ new Map(),
791
924
  states: /* @__PURE__ */ new Map(),
792
925
  consumed: /* @__PURE__ */ new Set(),
793
926
  declared: /* @__PURE__ */ new Map(),
@@ -796,7 +929,7 @@ function buildEntry(name, doc, nodes) {
796
929
  layers: /* @__PURE__ */ new Map(),
797
930
  conditions: []
798
931
  };
799
- collect(nodes, acc, className.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), className.endsWith(name) ? className.slice(1, className.length - name.length) : "", false);
932
+ collect(nodes, acc, matcher, className.replace(/^\./u, ""), className.endsWith(name) ? className.slice(1, className.length - name.length) : "", false);
800
933
  for (const [modName, mdoc] of doc.modifiers) {
801
934
  const existing = acc.modifiers.get(modName);
802
935
  const dep = mdoc.deprecated || mdoc.deprecatedCanonical || mdoc.deprecatedFlag ? {
@@ -810,7 +943,7 @@ function buildEntry(name, doc, nodes) {
810
943
  ...dep
811
944
  };
812
945
  } else {
813
- const { prop, value } = splitModifier(modName);
946
+ const { prop, value } = matcher.analyze(modName);
814
947
  acc.modifiers.set(modName, {
815
948
  name: modName,
816
949
  prop,
@@ -828,11 +961,22 @@ function buildEntry(name, doc, nodes) {
828
961
  description
829
962
  });
830
963
  }
831
- 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;
832
975
  const existing = acc.states.get(state);
833
976
  if (existing) existing.description = description || existing.description;
834
977
  else acc.states.set(state, {
835
978
  name: state,
979
+ kind: isPseudo ? "pseudo-class" : "custom",
836
980
  description: description || void 0
837
981
  });
838
982
  }
@@ -893,7 +1037,11 @@ function buildEntry(name, doc, nodes) {
893
1037
  group: doc.group,
894
1038
  accessibility: doc.accessibility,
895
1039
  modifiers,
896
- 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),
897
1045
  states: [...acc.states.values()].sort(byName),
898
1046
  slots: [...doc.slots].map(([slotName, description]) => ({
899
1047
  name: slotName,
@@ -907,6 +1055,7 @@ function buildEntry(name, doc, nodes) {
907
1055
  conditions: acc.conditions,
908
1056
  examples: doc.examples,
909
1057
  structure: doc.structure,
1058
+ structureDescription: doc.structureDescription,
910
1059
  demo: doc.demo,
911
1060
  deprecated: doc.deprecated,
912
1061
  see: doc.see,
@@ -932,8 +1081,9 @@ function buildEntry(name, doc, nodes) {
932
1081
  */
933
1082
  function parseCssDocs(css, options = {}) {
934
1083
  const configuration = options.configuration ?? new CssDocConfiguration();
1084
+ const matcher = new ModifierMatcher(resolveModifierConvention(options.modifierConvention ?? configuration.modifierConvention));
935
1085
  const boundary = options.isRecordBoundary ?? ((text) => recordNameOf(text, configuration));
936
- const root = postcss.parse(css);
1086
+ const root = (options.parse ?? postcss.parse)(css);
937
1087
  const records = [];
938
1088
  let current = null;
939
1089
  for (const node of root.nodes) {
@@ -951,7 +1101,7 @@ function parseCssDocs(css, options = {}) {
951
1101
  }
952
1102
  if (current) current.nodes.push(node);
953
1103
  }
954
- return records.map((r) => buildEntry(r.name, r.doc, r.nodes));
1104
+ return records.map((r) => buildEntry(r.name, r.doc, r.nodes, matcher));
955
1105
  }
956
1106
  //#endregion
957
1107
  //#region src/mermaid.ts
@@ -1001,4 +1151,4 @@ function toJson(model) {
1001
1151
  return `${JSON.stringify(model, null, 2)}\n`;
1002
1152
  }
1003
1153
  //#endregion
1004
- export { CssDocConfiguration, CssDocTagDefinition, RECORD_TAGS, parseCssDocs, parseDocComment, parseStructure, recordNameOf, 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 };