@cssdoc/core 0.4.2 → 0.5.1

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.
@@ -0,0 +1,933 @@
1
+ import { CSSDOC_TAGS, CSSDOC_TAG_NAMES } from "@cssdoc/spec";
2
+ //#region src/modifier.ts
3
+ /**
4
+ * The pseudo-classes cssdoc treats as component states by default — form and UI states a component
5
+ * meaningfully declares. Ubiquitous interaction pseudos (`:hover`, `:focus`, `:active`) are omitted so
6
+ * incidental rules don't become documented states; add them via `statePseudoClasses` if you want them.
7
+ */
8
+ const DEFAULT_STATE_PSEUDO_CLASSES = [
9
+ "checked",
10
+ "disabled",
11
+ "enabled",
12
+ "indeterminate",
13
+ "default",
14
+ "open",
15
+ "placeholder-shown",
16
+ "read-only",
17
+ "read-write",
18
+ "required",
19
+ "optional",
20
+ "valid",
21
+ "invalid",
22
+ "in-range",
23
+ "out-of-range"
24
+ ];
25
+ /** The built-in convention presets. Other schemes use the custom object (see the docs). */
26
+ const MODIFIER_PRESETS = {
27
+ /** BEM / SUIT — `.button--primary`, with `.button__element` sub-elements. The default. */
28
+ bem: {
29
+ structure: "suffix",
30
+ separator: "--",
31
+ elementSeparator: "__",
32
+ propValue: false
33
+ },
34
+ /** rscss — `.button.-color-secondary`, split into `prop`/`value`. */
35
+ rscss: {
36
+ structure: "chained",
37
+ separator: "-",
38
+ propValue: true,
39
+ propValueSeparator: "-"
40
+ },
41
+ /** OOCSS / bare chained classes — `.button.primary` (any class chained to the base). */
42
+ bare: {
43
+ structure: "chained",
44
+ separator: "",
45
+ propValue: false
46
+ }
47
+ };
48
+ /** The default convention: BEM. */
49
+ const DEFAULT_MODIFIER_CONVENTION = MODIFIER_PRESETS.bem;
50
+ /**
51
+ * Resolve a preset name or custom object into a fully-populated {@link ModifierConvention} (defaults
52
+ * filled in). No argument resolves to the {@link DEFAULT_MODIFIER_CONVENTION} (BEM).
53
+ *
54
+ * @throws If given an unknown preset name.
55
+ */
56
+ function resolveModifierConvention(input) {
57
+ let base;
58
+ if (input === void 0) base = DEFAULT_MODIFIER_CONVENTION;
59
+ else if (typeof input === "string") base = MODIFIER_PRESETS[input];
60
+ else base = input;
61
+ if (!base) throw new Error(`Unknown modifier convention preset: ${JSON.stringify(input)}`);
62
+ return {
63
+ structure: base.structure,
64
+ separator: base.separator,
65
+ ...base.elementSeparator !== void 0 ? { elementSeparator: base.elementSeparator } : {},
66
+ ...base.statePrefixes !== void 0 ? { statePrefixes: base.statePrefixes } : {},
67
+ statePseudoClasses: base.statePseudoClasses ?? [...DEFAULT_STATE_PSEUDO_CLASSES],
68
+ propValue: base.propValue ?? false,
69
+ propValueSeparator: base.propValueSeparator ?? "-"
70
+ };
71
+ }
72
+ /** Escape a string for literal use inside a `RegExp`. */
73
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
74
+ /** Strip one layer of matching quotes from an attribute value. */
75
+ const unquote = (v) => v.trim().replace(/^(["'])([\s\S]*)\1$/u, "$2");
76
+ /**
77
+ * The single owner of modifier recognition for one convention: finds modifiers on selectors, derives
78
+ * `prop`/`value`, renders a modifier name back to a selector fragment, and answers "does this host-doc
79
+ * token look like a modifier usage?". Constructed once per parse from the resolved convention.
80
+ */
81
+ var ModifierMatcher = class {
82
+ convention;
83
+ /** The separator(s), longest-first so overlapping prefixes (e.g. `--` before `-`) match greedily. */
84
+ separators;
85
+ /** The separators as a non-capturing regex alternation, e.g. `(?:is-|has-)` (or `(?:)` when empty). */
86
+ sepAlt;
87
+ /** BEM-style element separators (longest-first), or empty when the convention has none. */
88
+ elementSeparators;
89
+ /** The element separators as a non-capturing alternation, or `""` when there are none. */
90
+ elementSepAlt;
91
+ /** State-class prefixes (longest-first), or empty when the convention has none. */
92
+ statePrefixes;
93
+ /** Native pseudo-classes (no `:`) recognized as states. */
94
+ statePseudoClasses;
95
+ constructor(convention) {
96
+ this.convention = convention;
97
+ const longestFirst = (value) => (Array.isArray(value) ? value : [value]).slice().sort((a, b) => b.length - a.length);
98
+ this.separators = longestFirst(convention.separator);
99
+ this.sepAlt = `(?:${this.separators.map(escapeRe).join("|")})`;
100
+ this.elementSeparators = convention.elementSeparator ? longestFirst(convention.elementSeparator).filter((s) => s !== "") : [];
101
+ this.elementSepAlt = this.elementSeparators.length ? `(?:${this.elementSeparators.map(escapeRe).join("|")})` : "";
102
+ this.statePrefixes = (convention.statePrefixes ?? []).filter((p) => p !== "").slice().sort((a, b) => b.length - a.length);
103
+ this.statePseudoClasses = new Set(convention.statePseudoClasses ?? []);
104
+ }
105
+ /** Does a class name (no leading dot) start with one of the convention's state prefixes? */
106
+ isStateClass(name) {
107
+ return this.statePrefixes.some((p) => name.startsWith(p));
108
+ }
109
+ /** Strip a leading chained-class separator from `name` (the longest that matches), else return it. */
110
+ stripPrefix(name) {
111
+ for (const s of this.separators) if (name.startsWith(s)) return name.slice(s.length);
112
+ return name;
113
+ }
114
+ /** Return the suffix body — the part after the first (longest) non-empty separator occurrence. */
115
+ stripToBody(name) {
116
+ for (const s of this.separators) {
117
+ if (s === "") continue;
118
+ const at = name.indexOf(s);
119
+ if (at !== -1) return name.slice(at + s.length);
120
+ }
121
+ return name;
122
+ }
123
+ /**
124
+ * Every modifier attached to `baseNoDot` within one selector. `selector` should have its pseudos
125
+ * already dropped (as `parse.ts`/`index.ts` do); the base is given without its leading dot.
126
+ */
127
+ modifiersIn(selector, baseNoDot) {
128
+ const baseEsc = escapeRe(baseNoDot);
129
+ const hits = [];
130
+ const seen = /* @__PURE__ */ new Set();
131
+ const push = (name) => {
132
+ if (seen.has(name)) return;
133
+ seen.add(name);
134
+ hits.push({
135
+ name,
136
+ ...this.analyze(name),
137
+ ...name.includes("*") ? { pattern: true } : {}
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
+ for (const name of this.classAttrFamilies(selector, baseEsc)) push(name);
144
+ return hits;
145
+ }
146
+ if (this.convention.structure === "attribute") {
147
+ const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:\\[[^\\]]*\\])+)`, "gu");
148
+ for (const m of selector.matchAll(chain)) for (const a of m[1].matchAll(/\[([^\]]*)\]/gu)) {
149
+ const name = this.normalizeAttribute(a[1]);
150
+ if (name && this.attributeMatches(name)) push(name);
151
+ }
152
+ return hits;
153
+ }
154
+ const cls = `\\.${this.sepAlt}[\\w-]+`;
155
+ const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:${cls})+)`, "gu");
156
+ const inner = new RegExp(`\\.(${this.sepAlt}[\\w-]+)`, "gu");
157
+ for (const m of selector.matchAll(chain)) for (const c of m[1].matchAll(inner)) if (!this.isStateClass(c[1])) push(c[1]);
158
+ for (const name of this.classAttrFamilies(selector, baseEsc)) push(name);
159
+ return hits;
160
+ }
161
+ /**
162
+ * Family modifiers declared by a `class` attribute selector on the base — `.base[class*="-icon-"]`
163
+ * yields the `*` family `-icon-*`. The operator maps to the wildcard position: `*=`/`|=` and (rare)
164
+ * exact `~=`/`=` derive a prefix family or a concrete name, `$=` a suffix family; `^=` is skipped
165
+ * (it anchors to the start of the whole class attribute — the base class — not a chained modifier).
166
+ * The value must begin with a convention separator to count (so `[class*="-icon-"]` does, `[dir]` and
167
+ * `[class*="grid"]` don't), and carry a literal core beyond the separator.
168
+ */
169
+ classAttrFamilies(selector, baseEsc) {
170
+ const out = [];
171
+ const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:\\[[^\\]]*\\])+)`, "gu");
172
+ const attr = /\[\s*class\s*([~^$*|]?)=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]*))\s*\]/gu;
173
+ for (const m of selector.matchAll(chain)) for (const a of m[1].matchAll(attr)) {
174
+ const value = (a[2] ?? a[3] ?? a[4] ?? "").trim();
175
+ const name = this.classAttrFamily(a[1], value);
176
+ if (name) out.push(name);
177
+ }
178
+ return out;
179
+ }
180
+ /** Derive a modifier name from a `[class OP value]` selector, or `undefined` when it isn't one. */
181
+ classAttrFamily(op, value) {
182
+ const sep = this.separators.find((s) => s !== "" && value.startsWith(s));
183
+ if (sep === void 0 && this.separators.every((s) => s !== "")) return void 0;
184
+ if (value.length <= (sep?.length ?? 0)) return void 0;
185
+ if (op === "^") return void 0;
186
+ if (op === "$") return `*${value}`;
187
+ if (op === "" || op === "~") return value;
188
+ return `${value}*`;
189
+ }
190
+ /**
191
+ * Every BEM-style element attached to `baseNoDot` within one selector (`.base<elementSep><name>`),
192
+ * as parts, each with any element-scoped modifiers (`.base__element--mod` → element `base__element`
193
+ * with modifier `mod`). Only meaningful for `suffix` conventions with an `elementSeparator`.
194
+ */
195
+ elementsIn(selector, baseNoDot) {
196
+ if (this.convention.structure !== "suffix" || this.elementSepAlt === "") return [];
197
+ const baseEsc = escapeRe(baseNoDot);
198
+ const re = new RegExp(`\\.(${baseEsc}${this.elementSepAlt}[\\w-]+)`, "gu");
199
+ const byName = /* @__PURE__ */ new Map();
200
+ for (const m of selector.matchAll(re)) {
201
+ const { element, modifier } = this.splitElementModifier(m[1], baseNoDot);
202
+ const mods = byName.get(element) ?? [];
203
+ if (modifier && !mods.some((x) => x.name === modifier.name)) mods.push(modifier);
204
+ byName.set(element, mods);
205
+ }
206
+ return [...byName].map(([name, modifiers]) => ({
207
+ name,
208
+ modifiers
209
+ }));
210
+ }
211
+ /**
212
+ * Split `.base__element--mod`-style tokens into the element class (`base__element`) and, if a
213
+ * modifier separator follows the element name, the element-scoped modifier.
214
+ */
215
+ splitElementModifier(token, baseNoDot) {
216
+ const elsep = this.elementSeparators.find((s) => token.startsWith(baseNoDot + s));
217
+ if (!elsep) return { element: token };
218
+ const afterElement = baseNoDot.length + elsep.length;
219
+ let at = -1;
220
+ for (const sep of this.separators) {
221
+ if (sep === "") continue;
222
+ const i = token.indexOf(sep, afterElement);
223
+ if (i !== -1 && (at === -1 || i < at)) at = i;
224
+ }
225
+ if (at === -1) return { element: token };
226
+ return {
227
+ element: token.slice(0, at),
228
+ modifier: {
229
+ name: token,
230
+ ...this.analyze(token)
231
+ }
232
+ };
233
+ }
234
+ /**
235
+ * Every state class chained to `baseNoDot` within one selector — a class whose name starts with one
236
+ * of the convention's {@link ModifierConvention.statePrefixes} (e.g. `.tabs.is-open` → `is-open`).
237
+ * Empty when the convention sets no state prefixes.
238
+ */
239
+ statesIn(selector, baseNoDot) {
240
+ if (this.statePrefixes.length === 0) return [];
241
+ const baseEsc = escapeRe(baseNoDot);
242
+ const prefixAlt = `(?:${this.statePrefixes.map(escapeRe).join("|")})`;
243
+ const chain = new RegExp(`(?:\\.${baseEsc}|:scope)((?:\\.[\\w-]+)+)`, "gu");
244
+ const inner = new RegExp(`\\.(${prefixAlt}[\\w-]+)`, "gu");
245
+ const seen = /* @__PURE__ */ new Set();
246
+ const out = [];
247
+ for (const m of selector.matchAll(chain)) for (const c of m[1].matchAll(inner)) if (!seen.has(c[1])) {
248
+ seen.add(c[1]);
249
+ out.push({ name: c[1] });
250
+ }
251
+ return out;
252
+ }
253
+ /**
254
+ * Every native pseudo-class on the selector recognized as a state — a `:name` whose `name` is in the
255
+ * convention's {@link ModifierConvention.statePseudoClasses} (e.g. `.tab:disabled` → `disabled`).
256
+ * Pseudo-elements (`::part`) and pseudos not in the set are ignored.
257
+ */
258
+ pseudoStatesIn(selector) {
259
+ if (this.statePseudoClasses.size === 0) return [];
260
+ const seen = /* @__PURE__ */ new Set();
261
+ const out = [];
262
+ for (const m of selector.matchAll(/(?<!:):([\w-]+)/gu)) {
263
+ const name = m[1];
264
+ if (this.statePseudoClasses.has(name) && !seen.has(name)) {
265
+ seen.add(name);
266
+ out.push({ name });
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+ /** Derive `prop`/`value` for a modifier `name` (as returned by {@link modifiersIn} or authored). */
272
+ analyze(name) {
273
+ if (name.includes("*")) {
274
+ const body = this.convention.structure === "suffix" ? this.stripToBody(name) : this.stripPrefix(name);
275
+ return { prop: body.replace(/\*/gu, "").replace(/-+$/u, "") || body };
276
+ }
277
+ if (this.convention.structure === "attribute") {
278
+ const canonical = this.normalizeAttribute(name);
279
+ const eq = canonical.indexOf("=");
280
+ const attr = eq === -1 ? canonical : canonical.slice(0, eq);
281
+ return {
282
+ prop: this.stripPrefix(attr),
283
+ value: eq === -1 ? void 0 : unquote(canonical.slice(eq + 1))
284
+ };
285
+ }
286
+ const body = this.convention.structure === "suffix" ? this.stripToBody(name) : this.stripPrefix(name);
287
+ if (!this.convention.propValue) return { prop: body };
288
+ const sep = this.convention.propValueSeparator ?? "-";
289
+ const at = body.indexOf(sep);
290
+ if (at === -1) return { prop: body };
291
+ return {
292
+ prop: body.slice(0, at),
293
+ value: body.slice(at + sep.length)
294
+ };
295
+ }
296
+ /** Render a modifier `name` as the selector fragment it denotes (`.name` or `[name]`). */
297
+ selectorFor(name) {
298
+ return this.convention.structure === "attribute" ? `[${name}]` : `.${name}`;
299
+ }
300
+ /**
301
+ * Whether a used class `used` is an instance of the documented modifier `name` — an exact match, or,
302
+ * when `name` is a `*` family (e.g. `-icon-*`), a glob match (`*` → any `[\w-]` run). Used consumer-side
303
+ * so `-icon-arrow` resolves to the documented `-icon-*` family.
304
+ */
305
+ matchesModifier(name, used) {
306
+ if (name === used) return true;
307
+ if (!name.includes("*")) return false;
308
+ return new RegExp(`^${name.split("*").map(escapeRe).join("[\\w-]*")}$`, "u").test(used);
309
+ }
310
+ /** Normalize a member token/expression to its canonical `name` key (the inverse of authoring noise). */
311
+ normalizeMember(token) {
312
+ if (this.convention.structure === "attribute") {
313
+ const inner = token.replace(/^\[/u, "").replace(/\]$/u, "");
314
+ return this.normalizeAttribute(inner);
315
+ }
316
+ return token.replace(/^\./u, "");
317
+ }
318
+ /**
319
+ * Does a token/expression seen in a host document (an HTML class token, or an attribute expression)
320
+ * look like a modifier usage of `baseNoDot`? Replaces the old `startsWith("-")` gate.
321
+ */
322
+ looksLikeUsage(token, baseNoDot) {
323
+ if (this.convention.structure === "attribute") return this.attributeMatches(this.normalizeMember(token));
324
+ const name = token.replace(/^\./u, "");
325
+ if (this.isStateClass(name)) return false;
326
+ if (this.convention.structure === "suffix") {
327
+ if (baseNoDot) return this.separators.some((s) => name.startsWith(`${baseNoDot}${s}`));
328
+ return this.separators.some((s) => s !== "" && name.includes(s));
329
+ }
330
+ if (baseNoDot && name === baseNoDot) return false;
331
+ return this.separators.some((s) => name.startsWith(s));
332
+ }
333
+ /**
334
+ * Classify a host-document class token relative to `baseNoDot`: a `modifier` usage, a `state` class
335
+ * (a `statePrefixes` prefix), a BEM `element` class (`base<elementSep>…`), or `undefined` if it's
336
+ * none of those. Consumer-side linting routes each kind to the right "unknown-…" check.
337
+ */
338
+ usageKind(token, baseNoDot) {
339
+ if (this.convention.structure === "attribute") return this.looksLikeUsage(token, baseNoDot) ? "modifier" : void 0;
340
+ const name = token.replace(/^\./u, "");
341
+ if (this.isStateClass(name)) return "state";
342
+ if (baseNoDot && this.convention.structure === "suffix" && this.elementSeparators.some((s) => name.startsWith(`${baseNoDot}${s}`))) return "element";
343
+ return this.looksLikeUsage(token, baseNoDot) ? "modifier" : void 0;
344
+ }
345
+ /** Canonicalize an attribute expression (bracket-inner): normalize quotes to double, trim. */
346
+ normalizeAttribute(inner) {
347
+ const trimmed = inner.trim();
348
+ const eq = trimmed.indexOf("=");
349
+ if (eq === -1) return trimmed;
350
+ return `${trimmed.slice(0, eq + 1)}"${unquote(trimmed.slice(eq + 1))}"`;
351
+ }
352
+ /** Does an attribute-expression's name carry one of the convention's required prefixes? */
353
+ attributeMatches(name) {
354
+ const eq = name.indexOf("=");
355
+ const attr = (eq === -1 ? name : name.slice(0, eq)).replace(/[~|^$*]$/u, "");
356
+ return attr.length > 0 && this.separators.some((s) => attr.startsWith(s));
357
+ }
358
+ };
359
+ //#endregion
360
+ //#region src/configuration.ts
361
+ /**
362
+ * The tag-definition registry — cssdoc's analog to `@microsoft/tsdoc`'s `TSDocConfiguration` /
363
+ * `TSDocTagDefinition`. It names the vocabulary a parse understands: the built-in **standard** tags
364
+ * (seeded automatically) plus any **custom** tags registered on top (typically loaded from a
365
+ * `cssdoc.json` by `@cssdoc/config`). The parser consults the configuration to decide which record
366
+ * tags open a boundary, which standard tags are active, and which unknown tags to capture as custom
367
+ * blocks rather than ignore.
368
+ *
369
+ * The vocabulary is expansive and modeled on TSDoc's kind taxonomy — `record` / `block` / `modifier` /
370
+ * `inline` — and covers the modern CSSOM surface. See `@cssdoc/spec`'s `grammar/CssDoc.grammarkdown`
371
+ * for the formal shape of each tag.
372
+ *
373
+ * @module
374
+ */
375
+ const TAG_NAME_RE = /^@?[a-zA-Z][a-zA-Z0-9-]*$/u;
376
+ /** One tag in the vocabulary: its name, kind, and how it may be used. */
377
+ var CssDocTagDefinition = class {
378
+ /** The normalized tag name, always with a leading `@` (e.g. `@modifier`). */
379
+ tagName;
380
+ /** The tag name without its leading `@` (e.g. `modifier`). */
381
+ tagNameWithoutAt;
382
+ syntaxKind;
383
+ allowMultiple;
384
+ recordKind;
385
+ /** The canonical tag name (without `@`) this aliases, if any; otherwise its own name. */
386
+ aliasFor;
387
+ constructor(options) {
388
+ const raw = options.tagName.trim();
389
+ if (!TAG_NAME_RE.test(raw)) throw new Error(`Invalid CSS-doc tag name: ${JSON.stringify(options.tagName)}`);
390
+ this.tagNameWithoutAt = raw.replace(/^@/u, "");
391
+ this.tagName = `@${this.tagNameWithoutAt}`;
392
+ this.syntaxKind = options.syntaxKind;
393
+ this.allowMultiple = options.allowMultiple ?? false;
394
+ this.recordKind = options.recordKind;
395
+ this.aliasFor = options.aliasFor;
396
+ }
397
+ /** The tag this definition resolves to when handled — its alias target, or itself. */
398
+ get canonicalName() {
399
+ return this.aliasFor ?? this.tagNameWithoutAt;
400
+ }
401
+ };
402
+ function def(options) {
403
+ return new CssDocTagDefinition(options);
404
+ }
405
+ /**
406
+ * A parse configuration: the set of tag definitions plus which are supported. Construct one to get the
407
+ * full standard vocabulary, then add custom tags or disable standard ones.
408
+ *
409
+ * @example
410
+ * ```ts
411
+ * import { CssDocConfiguration, CssDocTagDefinition, parseCssDocs } from "@cssdoc/core";
412
+ *
413
+ * const config = new CssDocConfiguration();
414
+ * config.addTagDefinition(new CssDocTagDefinition({ tagName: "@token", syntaxKind: "block" }), true);
415
+ * const model = parseCssDocs(css, { configuration: config });
416
+ * ```
417
+ */
418
+ var CssDocConfiguration = class CssDocConfiguration {
419
+ _tagDefinitions = [];
420
+ _byName = /* @__PURE__ */ new Map();
421
+ _supported = /* @__PURE__ */ new Set();
422
+ _modifierConvention = DEFAULT_MODIFIER_CONVENTION;
423
+ constructor() {
424
+ this.addTagDefinitions(CssDocConfiguration.standardTags(), true);
425
+ }
426
+ /** The resolved modifier convention this configuration parses with (defaults to BEM). */
427
+ get modifierConvention() {
428
+ return this._modifierConvention;
429
+ }
430
+ /** Set the modifier convention from a preset name or a custom {@link ModifierConvention}. */
431
+ setModifierConvention(input) {
432
+ this._modifierConvention = resolveModifierConvention(input);
433
+ }
434
+ /** Every registered tag definition, in registration order. */
435
+ get tagDefinitions() {
436
+ return this._tagDefinitions;
437
+ }
438
+ /** Only the tag definitions that are currently supported. */
439
+ get supportedTagDefinitions() {
440
+ return this._tagDefinitions.filter((d) => this._supported.has(d));
441
+ }
442
+ /**
443
+ * Register a tag definition. Re-registering a tag name replaces the earlier definition.
444
+ *
445
+ * @param definition - The tag to add.
446
+ * @param supported - Whether it is supported (defaults to `true`).
447
+ */
448
+ addTagDefinition(definition, supported = true) {
449
+ const existing = this._byName.get(definition.tagNameWithoutAt);
450
+ if (existing) {
451
+ this._tagDefinitions.splice(this._tagDefinitions.indexOf(existing), 1);
452
+ this._supported.delete(existing);
453
+ }
454
+ this._tagDefinitions.push(definition);
455
+ this._byName.set(definition.tagNameWithoutAt, definition);
456
+ if (supported) this._supported.add(definition);
457
+ }
458
+ /** Register several tag definitions. */
459
+ addTagDefinitions(definitions, supported = true) {
460
+ for (const definition of definitions) this.addTagDefinition(definition, supported);
461
+ }
462
+ /** Look up a tag definition by name (with or without a leading `@`). */
463
+ tryGetTagDefinition(tagName) {
464
+ return this._byName.get(tagName.replace(/^@/u, ""));
465
+ }
466
+ /** Whether a tag definition is supported. */
467
+ isTagSupported(definition) {
468
+ return this._supported.has(definition);
469
+ }
470
+ /** Enable or disable support for a tag. */
471
+ setSupportForTag(definition, supported) {
472
+ if (supported) this._supported.add(definition);
473
+ else this._supported.delete(definition);
474
+ }
475
+ /** Enable or disable support for several tags. */
476
+ setSupportForTags(definitions, supported) {
477
+ for (const definition of definitions) this.setSupportForTag(definition, supported);
478
+ }
479
+ /** Disable support for every standard tag (custom tags added later remain supported). */
480
+ setNoStandardTags() {
481
+ this.setSupportForTags(CssDocConfiguration.standardTagNames.map((n) => this._byName.get(n)), false);
482
+ }
483
+ /** The names (without `@`) of every standard tag — the canonical vocabulary from `@cssdoc/spec`. */
484
+ static standardTagNames = CSSDOC_TAG_NAMES;
485
+ /** A fresh set of the standard tag definitions (new instances on each call), built from the spec. */
486
+ static standardTags() {
487
+ return CSSDOC_TAGS.map((tag) => def({
488
+ tagName: tag.name,
489
+ syntaxKind: tag.kind,
490
+ ...tag.recordKind ? { recordKind: tag.recordKind } : {},
491
+ ...tag.aliasFor ? { aliasFor: tag.aliasFor } : {},
492
+ ...tag.allowMultiple ? { allowMultiple: true } : {}
493
+ }));
494
+ }
495
+ };
496
+ //#endregion
497
+ //#region src/grammar.ts
498
+ /**
499
+ * The record-opening tags and the {@link CssRecordKind} each selects, as the default boundary map.
500
+ * A doc comment carrying one of these opens a new record; `@name` is an alias for `@component`. A
501
+ * {@link CssDocConfiguration} may add more record tags.
502
+ */
503
+ const RECORD_TAGS = {
504
+ component: "component",
505
+ name: "component",
506
+ utility: "utility",
507
+ rule: "rule",
508
+ declaration: "declaration"
509
+ };
510
+ /** Split a tag's argument into `head` (a selector/name/token) and `description` on ` — ` or ` - `. */
511
+ function splitDesc(rest) {
512
+ const m = rest.match(/^(\S+)\s+(?:—|-{1,2})\s+(.*)$/u);
513
+ if (m) return {
514
+ head: m[1],
515
+ description: m[2].trim()
516
+ };
517
+ return { head: rest.trim() || rest };
518
+ }
519
+ /** Split any argument into `query` (everything before the first ` — `/` - `) and `description`. */
520
+ function splitQuery(rest) {
521
+ const m = rest.match(/^([\s\S]*?)\s+(?:—|-{1,2})\s+([\s\S]*)$/u);
522
+ if (m) return {
523
+ query: m[1].trim(),
524
+ description: m[2].trim()
525
+ };
526
+ return { query: rest.trim() };
527
+ }
528
+ /** Strip the comment framing (`/**`, `*\/`, and leading ` * `) from a raw block-comment body. */
529
+ function stripCommentFraming(raw) {
530
+ return raw.replace(/^\/\*\*?/, "").replace(/\*\/$/, "").split("\n").map((line) => line.replace(/^\s*\*\s?/, "")).join("\n").trim();
531
+ }
532
+ /** Parse the inner body of a `@modifier` line's argument into a {@link DocModifier}. */
533
+ function parseModifierBody(description) {
534
+ const dep = description?.match(/^@deprecated\b\s*([\s\S]*)$/u);
535
+ if (dep) {
536
+ const rawNote = dep[1].trim();
537
+ const link = rawNote.match(/\{@link\s+(\.?[\w-]+|\[[^\]]*\])\s*\}/u);
538
+ const canonical = link?.[1].replace(/^\./u, "").replace(/^\[/u, "").replace(/\]$/u, "");
539
+ const note = rawNote.replace(/\{@link\s+[^}]*\}/u, "").trim();
540
+ if (!note && !link) return { deprecatedFlag: true };
541
+ return {
542
+ deprecated: note || void 0,
543
+ deprecatedCanonical: canonical
544
+ };
545
+ }
546
+ return { description: description ?? "" };
547
+ }
548
+ /**
549
+ * Parse a doc-comment's INNER text (already stripped of `/* *\/` framing, or a raw block — both are
550
+ * handled) into a {@link ParsedDoc}. The `configuration` decides which tags are active and which custom
551
+ * tags to capture; unknown or unsupported tags are ignored, so the grammar degrades gracefully.
552
+ *
553
+ * @param raw - The comment text (with or without `/** … *\/` framing).
554
+ * @param configuration - The active tag configuration (defaults to the full standard vocabulary).
555
+ * @returns The structured tags.
556
+ */
557
+ function parseDocComment(raw, configuration = new CssDocConfiguration(), parse) {
558
+ const body = stripCommentFraming(raw);
559
+ const doc = {
560
+ modifiers: /* @__PURE__ */ new Map(),
561
+ parts: /* @__PURE__ */ new Map(),
562
+ tokens: /* @__PURE__ */ new Map(),
563
+ cssParts: /* @__PURE__ */ new Map(),
564
+ cssProperties: [],
565
+ cssStates: /* @__PURE__ */ new Map(),
566
+ slots: /* @__PURE__ */ new Map(),
567
+ functions: /* @__PURE__ */ new Map(),
568
+ animations: /* @__PURE__ */ new Map(),
569
+ layers: /* @__PURE__ */ new Map(),
570
+ conditions: [],
571
+ examples: [],
572
+ see: [],
573
+ compat: [],
574
+ related: [],
575
+ customBlocks: /* @__PURE__ */ new Map()
576
+ };
577
+ const blocks = [];
578
+ for (const line of body.split("\n")) if (/^\s*@[a-zA-Z]/u.test(line)) blocks.push(line.trim());
579
+ else if (blocks.length) blocks[blocks.length - 1] += `\n${line}`;
580
+ for (const block of blocks) {
581
+ const m = block.match(/^@([a-zA-Z][\w-]*)\s*([\s\S]*)$/u);
582
+ if (!m) continue;
583
+ const tagName = m[1];
584
+ const rest = m[2].trim();
585
+ const definition = configuration.tryGetTagDefinition(tagName);
586
+ if (!definition || !configuration.isTagSupported(definition)) continue;
587
+ if (definition.syntaxKind === "record") {
588
+ doc.component = rest.split(/\s/u)[0];
589
+ doc.kind = definition.recordKind;
590
+ continue;
591
+ }
592
+ if (definition.syntaxKind === "modifier") {
593
+ doc.releaseStage = definition.canonicalName;
594
+ continue;
595
+ }
596
+ if (definition.syntaxKind === "inline") continue;
597
+ applyBlockTag(doc, definition.canonicalName, definition.tagNameWithoutAt, rest, parse);
598
+ }
599
+ return doc;
600
+ }
601
+ /** Apply one supported block tag (resolved to its canonical name) to the accumulating {@link ParsedDoc}. */
602
+ function applyBlockTag(doc, canonical, tagName, rest, parse) {
603
+ switch (canonical) {
604
+ case "class":
605
+ doc.className = rest.split(/\s/u)[0];
606
+ break;
607
+ case "summary":
608
+ doc.summary = rest.replace(/\s+/gu, " ").trim();
609
+ break;
610
+ case "remarks":
611
+ doc.remarks = rest.trim();
612
+ break;
613
+ case "privateRemarks":
614
+ doc.privateRemarks = rest.trim();
615
+ break;
616
+ case "since":
617
+ doc.since = rest.trim();
618
+ break;
619
+ case "group":
620
+ doc.group = rest.trim();
621
+ break;
622
+ case "a11y":
623
+ doc.accessibility = rest.trim();
624
+ break;
625
+ case "structure": {
626
+ const { description, css } = splitStructureBody(rest);
627
+ doc.structure = parseStructure(css, parse);
628
+ if (description) doc.structureDescription = description;
629
+ break;
630
+ }
631
+ case "modifier": {
632
+ const { head, description } = splitDesc(rest);
633
+ doc.modifiers.set(head.replace(/^\./u, ""), parseModifierBody(description));
634
+ break;
635
+ }
636
+ case "part": {
637
+ const { head, description } = splitDesc(rest);
638
+ doc.parts.set(head.replace(/^\./u, ""), description ?? "");
639
+ break;
640
+ }
641
+ case "tokens": {
642
+ const { head, description } = splitDesc(rest);
643
+ doc.tokens.set(head, description ?? "");
644
+ break;
645
+ }
646
+ case "csspart": {
647
+ const { head, description } = splitDesc(rest);
648
+ doc.cssParts.set(head.replace(/^\./u, ""), description ?? "");
649
+ break;
650
+ }
651
+ case "cssproperty": {
652
+ const propMatch = rest.match(/^(--[\w-]+)\s*(<[^>]+>)?\s*(?:(?:—|-{1,2})\s*(.*))?$/u);
653
+ if (propMatch) doc.cssProperties.push({
654
+ name: propMatch[1],
655
+ syntax: propMatch[2],
656
+ description: propMatch[3]?.trim() || void 0
657
+ });
658
+ break;
659
+ }
660
+ case "defaultValue": {
661
+ const last = doc.cssProperties.at(-1);
662
+ if (last) last.defaultValue = rest.trim();
663
+ break;
664
+ }
665
+ case "cssstate": {
666
+ const { head, description } = splitDesc(rest);
667
+ doc.cssStates.set(head, description ?? "");
668
+ break;
669
+ }
670
+ case "slot": {
671
+ const { head, description } = splitDesc(rest);
672
+ doc.slots.set(head.replace(/^\./u, ""), description ?? "");
673
+ break;
674
+ }
675
+ case "function": {
676
+ const nameMatch = rest.match(/^(--[\w-]+)/u);
677
+ const { description } = splitDesc(rest);
678
+ if (nameMatch) doc.functions.set(nameMatch[1], description ?? "");
679
+ break;
680
+ }
681
+ case "keyframes": {
682
+ const { head, description } = splitDesc(rest);
683
+ doc.animations.set(head, description ?? "");
684
+ break;
685
+ }
686
+ case "layer": {
687
+ const { head, description } = splitDesc(rest);
688
+ doc.layers.set(head, description ?? "");
689
+ break;
690
+ }
691
+ case "container":
692
+ case "supports":
693
+ case "media": {
694
+ const { query, description } = splitQuery(rest);
695
+ doc.conditions.push({
696
+ type: canonical,
697
+ query,
698
+ description
699
+ });
700
+ break;
701
+ }
702
+ case "example":
703
+ doc.examples.push(rest);
704
+ break;
705
+ case "deprecated":
706
+ doc.deprecated = rest;
707
+ break;
708
+ case "demo":
709
+ doc.demo = rest.split(/\s/u)[0];
710
+ break;
711
+ case "see":
712
+ doc.see.push(rest);
713
+ break;
714
+ case "usage":
715
+ doc.usage = rest.trim();
716
+ break;
717
+ case "compat":
718
+ doc.compat.push(rest.trim());
719
+ break;
720
+ case "related": {
721
+ const { head, description } = splitDesc(rest);
722
+ doc.related.push({
723
+ name: head.replace(/^\./u, ""),
724
+ description
725
+ });
726
+ break;
727
+ }
728
+ default: {
729
+ const list = doc.customBlocks.get(tagName) ?? [];
730
+ list.push(rest);
731
+ doc.customBlocks.set(tagName, list);
732
+ break;
733
+ }
734
+ }
735
+ }
736
+ /**
737
+ * Whether a comment's text opens a record — i.e. carries a record tag. Uses the `configuration`'s
738
+ * record tags when given, else the default {@link RECORD_TAGS}.
739
+ *
740
+ * @param commentText - The comment body.
741
+ * @param configuration - The active configuration (optional).
742
+ * @returns The record name, or `undefined`.
743
+ */
744
+ function recordNameOf(commentText, configuration) {
745
+ const tagNames = configuration ? configuration.supportedTagDefinitions.filter((d) => d.syntaxKind === "record").map((d) => d.tagNameWithoutAt) : Object.keys(RECORD_TAGS);
746
+ if (tagNames.length === 0) return void 0;
747
+ return commentText.match(new RegExp(`@(?:${tagNames.join("|")})\\s+(\\S+)`, "u"))?.[1];
748
+ }
749
+ /**
750
+ * Peel an optional leading prose description off a `@structure` body. The nested CSS begins at the
751
+ * first line that opens a rule (contains `{`); any prose before it is the description. If the body
752
+ * begins with a selector — including a multi-line selector like `.a,\n.b {` — there is no description
753
+ * and the whole body is CSS.
754
+ *
755
+ * @param raw - The `@structure` body (description and/or nested CSS).
756
+ * @returns The split `description` (when present) and the `css` to parse.
757
+ */
758
+ function splitStructureBody(raw) {
759
+ const lines = raw.split("\n");
760
+ const braceLine = lines.findIndex((l) => l.includes("{"));
761
+ if (braceLine <= 0) return { css: raw };
762
+ const lead = lines.slice(0, braceLine);
763
+ if (/^\s*[.#:[*&>+~]/u.test(lead[0])) return { css: raw };
764
+ const description = lead.join("\n").trim();
765
+ return description ? {
766
+ description,
767
+ css: lines.slice(braceLine).join("\n")
768
+ } : { css: raw };
769
+ }
770
+ /**
771
+ * Parse a `@structure` body — nested CSS (brace-delimited rules) — into a {@link StructureNode} tree.
772
+ * Each rule's selector becomes a node; nested rules become its children. Because a node is a real
773
+ * compound selector, `:has()` (contains), `:is()` / selector-lists (one-of), and `:not()` (not) express
774
+ * relationships natively. Leaf nodes are written as empty rules (`.tab {}`). A malformed body parses to
775
+ * an empty tree rather than throwing.
776
+ *
777
+ * @example
778
+ * ```
779
+ * .tabs {
780
+ * .list { .tab {} }
781
+ * .panel {}
782
+ * }
783
+ * ```
784
+ *
785
+ * @param raw - The nested-CSS structure body.
786
+ * @param parse - The CSS parser to build the tree with (the same one `parseCssDocs` uses, or a dialect
787
+ * parser). Injected so this module carries no runtime CSS-parser dependency; without it the tree is empty.
788
+ */
789
+ const CARDINALITY = {
790
+ optional: "optional",
791
+ opt: "optional",
792
+ many: "many",
793
+ "one-or-more": "one-or-more",
794
+ more: "one-or-more"
795
+ };
796
+ const CARD_RE = /:(optional|opt|one-or-more|more|many)\s*$/u;
797
+ function parseStructure(raw, parse) {
798
+ if (!parse) return [];
799
+ const build = (nodes) => {
800
+ const out = [];
801
+ for (const rule of nodes) {
802
+ if (rule.type !== "rule") continue;
803
+ const selector = rule.selector.trim();
804
+ const card = selector.match(CARD_RE);
805
+ const node = {
806
+ selector: card ? selector.slice(0, card.index).trim() : selector,
807
+ children: build(rule.nodes ?? [])
808
+ };
809
+ if (card) node.cardinality = CARDINALITY[card[1]];
810
+ out.push(node);
811
+ }
812
+ return out;
813
+ };
814
+ try {
815
+ return build(parse(raw).nodes);
816
+ } catch {
817
+ return [];
818
+ }
819
+ }
820
+ //#endregion
821
+ //#region src/mermaid.ts
822
+ /** Match a `slot` / `slot[name="x"]` node (a light-DOM content region → the default/named slot). */
823
+ const SLOT_NODE = /^slot(?:\[\s*name\s*=\s*["']?([\w-]+)["']?\s*\])?$/u;
824
+ /** The child edge for each cardinality (dashed + `0..1` for optional; a count label for the ranges). */
825
+ const EDGE = {
826
+ required: "-->",
827
+ optional: "-.->|0..1|",
828
+ many: "-->|0..n|",
829
+ "one-or-more": "-->|1..n|"
830
+ };
831
+ /** Wrap a node's label in the shape mermaid draws for its class. */
832
+ const SHAPE = {
833
+ "cssdoc-root": (id, l) => `${id}["${l}"]`,
834
+ "cssdoc-part": (id, l) => `${id}("${l}")`,
835
+ "cssdoc-slot": (id, l) => `${id}[/"${l}"/]`,
836
+ "cssdoc-component": (id, l) => `${id}(["${l}"])`
837
+ };
838
+ /** The classDef palette — a readable standalone default; hosts restyle by class name. */
839
+ const CLASS_DEFS = [
840
+ "classDef cssdoc-root fill:#eef2ff,stroke:#6366f1,color:#1e1b4b;",
841
+ "classDef cssdoc-part fill:#f8fafc,stroke:#94a3b8,color:#0f172a;",
842
+ "classDef cssdoc-slot fill:#f0fdf4,stroke:#4ade80,color:#14532d;",
843
+ "classDef cssdoc-component fill:#fff7ed,stroke:#fb923c,color:#7c2d12;"
844
+ ];
845
+ /** The leading bare class of a compound selector, e.g. `.item.-selected` → `item`. */
846
+ const firstClass = (selector) => selector.match(/\.([\w-]+)/u)?.[1];
847
+ /** Escape a label for a quoted Mermaid node body. */
848
+ const esc = (text) => text.replace(/"/gu, "&quot;");
849
+ /** Classify one node: slot → root → sibling component → plain part. */
850
+ function classify(node, isRoot, options) {
851
+ const slot = node.selector.match(SLOT_NODE);
852
+ if (slot) return {
853
+ klass: "cssdoc-slot",
854
+ label: slot[1] ? `‹content: ${slot[1]}›` : "‹content›"
855
+ };
856
+ if (isRoot) return {
857
+ klass: "cssdoc-root",
858
+ label: node.selector
859
+ };
860
+ const primary = firstClass(node.selector);
861
+ if (primary && primary !== options.self) {
862
+ const component = options.resolveComponent?.(primary);
863
+ if (component) return {
864
+ klass: "cssdoc-component",
865
+ label: component.name,
866
+ href: component.href
867
+ };
868
+ }
869
+ return {
870
+ klass: "cssdoc-part",
871
+ label: node.selector
872
+ };
873
+ }
874
+ /**
875
+ * Convert a structure tree to a Mermaid `flowchart` definition. Each node gets a stable id (`n0`, `n1`,
876
+ * …) in depth-first order; a parent connects to each child with an edge carrying the child's
877
+ * cardinality. Nodes are shaped + classed by kind ({@link classify}); sibling components with an href
878
+ * get a `click` link.
879
+ *
880
+ * @param roots - Top-level {@link StructureNode}s (an authored `@structure`).
881
+ * @param options - {@link MermaidOptions}.
882
+ * @returns Mermaid source, or an empty string when there are no nodes.
883
+ *
884
+ * @example
885
+ * ```ts
886
+ * toMermaid([{ selector: ".tabs", children: [{ selector: ".panel", cardinality: "many", children: [] }] }]);
887
+ * // flowchart TD
888
+ * // n0[".tabs"]:::cssdoc-root
889
+ * // n1(".panel"):::cssdoc-part
890
+ * // n0 -->|0..n| n1
891
+ * // classDef cssdoc-root …
892
+ * ```
893
+ */
894
+ function toMermaid(roots, options = {}) {
895
+ if (!roots.length) return "";
896
+ const nodes = [];
897
+ const edges = [];
898
+ const links = [];
899
+ let counter = 0;
900
+ const walk = (node, isRoot) => {
901
+ const id = `n${counter++}`;
902
+ const { klass, label, href } = classify(node, isRoot, options);
903
+ nodes.push(` ${SHAPE[klass](id, esc(label))}:::${klass}`);
904
+ if (href) links.push(` click ${id} "${href}"`);
905
+ for (const child of node.children) {
906
+ const childId = walk(child, false);
907
+ edges.push(` ${id} ${EDGE[child.cardinality ?? "required"]} ${childId}`);
908
+ }
909
+ return id;
910
+ };
911
+ for (const root of roots) walk(root, true);
912
+ return [
913
+ `flowchart ${options.direction ?? "TD"}`,
914
+ ...nodes,
915
+ ...edges,
916
+ ...links,
917
+ ...CLASS_DEFS.map((d) => ` ${d}`)
918
+ ].join("\n");
919
+ }
920
+ //#endregion
921
+ //#region src/lite.ts
922
+ /**
923
+ * Serialize a documentation model to pretty JSON (the raw, emitter-agnostic artifact — like TypeDoc's
924
+ * `--json`).
925
+ *
926
+ * @param model - The entries from `parseCssDocs`.
927
+ * @returns A JSON string.
928
+ */
929
+ function toJson(model) {
930
+ return `${JSON.stringify(model, null, 2)}\n`;
931
+ }
932
+ //#endregion
933
+ export { parseStructure as a, CssDocConfiguration as c, DEFAULT_STATE_PSEUDO_CLASSES as d, MODIFIER_PRESETS as f, parseDocComment as i, CssDocTagDefinition as l, resolveModifierConvention as m, toMermaid as n, recordNameOf as o, ModifierMatcher as p, RECORD_TAGS as r, stripCommentFraming as s, toJson as t, DEFAULT_MODIFIER_CONVENTION as u };