@cssdoc/core 0.4.1 → 0.5.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.d.mts +2 -730
- package/dist/index.mjs +7 -826
- package/dist/lite-BJ1h869i.d.mts +749 -0
- package/dist/lite-CNHsXy8P.mjs +880 -0
- package/dist/lite.d.mts +2 -0
- package/dist/lite.mjs +2 -0
- package/package.json +4 -2
|
@@ -0,0 +1,880 @@
|
|
|
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
|
+
const CARDINALITY_LABEL = {
|
|
823
|
+
optional: " (optional)",
|
|
824
|
+
many: " (0..n)",
|
|
825
|
+
"one-or-more": " (1..n)"
|
|
826
|
+
};
|
|
827
|
+
const SLOT_NODE = /^slot(?:\[\s*name\s*=\s*["']?([\w-]+)["']?\s*\])?$/u;
|
|
828
|
+
/**
|
|
829
|
+
* A node's label: its selector (a `slot` node shown as ‹content›) plus a cardinality suffix, quoted for a
|
|
830
|
+
* Mermaid node body (quoting allows dots, brackets, and spaces).
|
|
831
|
+
*/
|
|
832
|
+
const label = (node) => {
|
|
833
|
+
const slot = node.selector.match(SLOT_NODE);
|
|
834
|
+
return `"${`${slot ? slot[1] ? `‹content: ${slot[1]}›` : "‹content›" : node.selector}${node.cardinality ? CARDINALITY_LABEL[node.cardinality] : ""}`.replace(/"/gu, """)}"`;
|
|
835
|
+
};
|
|
836
|
+
/**
|
|
837
|
+
* Convert a structure tree to a Mermaid `flowchart` definition. Each node gets a stable id (`n0`, `n1`,
|
|
838
|
+
* …) in depth-first order; edges connect a parent to each child.
|
|
839
|
+
*
|
|
840
|
+
* @param roots - Top-level {@link StructureNode}s (an authored `@structure`).
|
|
841
|
+
* @param options - `direction` sets the flowchart orientation (default `TD`, top-down).
|
|
842
|
+
* @returns Mermaid source, or an empty string when there are no nodes.
|
|
843
|
+
*
|
|
844
|
+
* @example
|
|
845
|
+
* ```ts
|
|
846
|
+
* toMermaid([{ selector: ".tabs", children: [{ selector: ".panel", children: [] }] }]);
|
|
847
|
+
* // flowchart TD
|
|
848
|
+
* // n0[".tabs"]
|
|
849
|
+
* // n1[".panel"]
|
|
850
|
+
* // n0 --> n1
|
|
851
|
+
* ```
|
|
852
|
+
*/
|
|
853
|
+
function toMermaid(roots, options = {}) {
|
|
854
|
+
if (!roots.length) return "";
|
|
855
|
+
const lines = [`flowchart ${options.direction ?? "TD"}`];
|
|
856
|
+
const edges = [];
|
|
857
|
+
let counter = 0;
|
|
858
|
+
const walk = (node) => {
|
|
859
|
+
const id = `n${counter++}`;
|
|
860
|
+
lines.push(` ${id}[${label(node)}]`);
|
|
861
|
+
for (const child of node.children) edges.push(` ${id} --> ${walk(child)}`);
|
|
862
|
+
return id;
|
|
863
|
+
};
|
|
864
|
+
for (const root of roots) walk(root);
|
|
865
|
+
return [...lines, ...edges].join("\n");
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/lite.ts
|
|
869
|
+
/**
|
|
870
|
+
* Serialize a documentation model to pretty JSON (the raw, emitter-agnostic artifact — like TypeDoc's
|
|
871
|
+
* `--json`).
|
|
872
|
+
*
|
|
873
|
+
* @param model - The entries from `parseCssDocs`.
|
|
874
|
+
* @returns A JSON string.
|
|
875
|
+
*/
|
|
876
|
+
function toJson(model) {
|
|
877
|
+
return `${JSON.stringify(model, null, 2)}\n`;
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
880
|
+
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 };
|