@takazudo/zudo-doc 5.1.0 → 5.2.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.
@@ -6,59 +6,294 @@
6
6
  // src/config/z-index-tokens.ts.
7
7
  //
8
8
  // Reads from the project root (process.cwd()). Conventional paths:
9
- // - Tokens: src/config/z-index-tokens.ts
10
- // - CSS: src/styles/global.css
9
+ // - Tokens: src/config/z-index-tokens.ts (override with --tokens <path>)
10
+ // - CSS: src/styles/global.css (override with --css <path>)
11
11
  //
12
12
  // Usage (after pnpm install, via scripts in package.json):
13
- // gen-z-index # rewrite the @theme block in global.css
14
- // gen-z-index --check # verify committed block is up to date (exit 1 on drift)
13
+ // gen-z-index # rewrite the @theme block (conventional paths)
14
+ // gen-z-index --check # verify committed block is up to date (exit 1 on drift)
15
+ // gen-z-index --tokens <path> --css <path> # use non-conventional source/destination paths
16
+ // gen-z-index --no-theme-wrapper # emit bare --z-index-<name> declarations, no @theme wrapper
17
+ // gen-z-index --md-table <path> # also generate/verify a Z_INDEX_TABLE region in a
18
+ // # markdown/MDX file (opt-in, no conventional default path)
15
19
  //
16
- // MUST be run with the project root as cwd (it resolves source paths against
17
- // process.cwd(), NOT against this file's location). The package.json scripts and
18
- // b4push satisfy this; CI's no-install z-index job invokes the file by path
19
- // from the repo root: `node packages/zudo-doc/bin/gen-z-index.mjs --check`.
20
+ // MUST be run with the project root as cwd it resolves --tokens/--css/
21
+ // --md-table (or their conventional defaults) against process.cwd(), NOT
22
+ // against this file's location. A consuming project's own package.json
23
+ // scripts (e.g. gen:z-index / check:z-index) are responsible for invoking it
24
+ // from the project root. This generator is opt-in per project — a project
25
+ // only needs it when overriding one of the default tiers shipped
26
+ // unconditionally by @takazudo/zudo-doc/theme.css — and it is not wired into
27
+ // this repo's own b4push or CI; that integration was retired in
28
+ // zudolab/zudo-doc#2661.
20
29
  //
21
30
  // The block is a Tailwind v4 `@theme { --z-index-<name>: <value>; }` for every
22
31
  // tier, so Tailwind generates `z-<name>` utilities and raw CSS can reference
23
- // `z-index: var(--z-index-<name>)`.
32
+ // `z-index: var(--z-index-<name>)`. `--no-theme-wrapper` drops the `@theme`
33
+ // wrapper and emits bare `--z-index-<name>: <value>;` declarations instead,
34
+ // for projects that want to compose the block into their own `@theme` block.
24
35
  //
25
- // Pure Node (fs only NO npm deps). Idempotent: running twice produces no diff.
36
+ // `--md-table <path>` additionally generates/verifies a second, independent
37
+ // `GENERATED:Z_INDEX_TABLE` region — a `| Token | Kind | Role |` table, one
38
+ // row per tier — inside a markdown/MDX file at <path>. It uses the MDX-safe
39
+ // brace-comment marker form `{/* GENERATED:Z_INDEX_TABLE_BEGIN/END */}`
40
+ // rather than an HTML `<!-- -->` comment, because an HTML comment is a parse
41
+ // error in MDX. `--check` verifies BOTH regions when --md-table is given;
42
+ // drift in either exits 1. Like the CSS block, the md-table region must be
43
+ // seeded once by hand (just the marker pair) before the generator can find
44
+ // it to replace. On this surface ONLY, marker lines inside a CommonMark
45
+ // fenced code block are ignored, so a doc page may reproduce the marker
46
+ // verbatim while explaining the generator (see `scanMarkerLines`).
26
47
  //
27
- // MAINTENANCE: edit src/config/z-index-tokens.ts (the source of truth), then run
28
- // `pnpm gen:z-index` and commit the regenerated global.css. Never hand-edit the
29
- // block between the BEGIN/END markers.
48
+ // Pure Node (fs only NO npm deps, no minimist). Idempotent: running twice
49
+ // produces no diff.
50
+ //
51
+ // MAINTENANCE: edit src/config/z-index-tokens.ts (the source of truth), then
52
+ // run `pnpm gen:z-index` (or the equivalent --tokens/--css/--md-table
53
+ // invocation) and commit the regenerated CSS (and md table, if used). Never
54
+ // hand-edit either block between its BEGIN/END markers.
30
55
 
31
- import { readFileSync, writeFileSync } from "node:fs";
56
+ import { readFileSync, writeFileSync, realpathSync } from "node:fs";
32
57
  import { resolve } from "node:path";
33
-
34
- // Project root is always the current working directory — this is a project bin,
35
- // not a monorepo script.
36
- const ROOT = process.cwd();
37
-
38
- const TOKENS_PATH = resolve(ROOT, "src/config/z-index-tokens.ts");
39
- const CSS_PATH = resolve(ROOT, "src/styles/global.css");
58
+ import { fileURLToPath } from "node:url";
40
59
 
41
60
  const BEGIN_MARKER = "GENERATED:Z_INDEX_BEGIN";
42
61
  const END_MARKER = "GENERATED:Z_INDEX_END";
43
62
 
63
+ // MDX-safe markers for the optional --md-table region. An HTML `<!-- -->`
64
+ // comment is a parse error in MDX, which is the entire reason this region
65
+ // uses the brace-comment form instead — both markers are the literal,
66
+ // complete text that must appear (on their own line) in the seeded file.
67
+ const MD_TABLE_BEGIN_MARKER = "{/* GENERATED:Z_INDEX_TABLE_BEGIN */}";
68
+ const MD_TABLE_END_MARKER = "{/* GENERATED:Z_INDEX_TABLE_END */}";
69
+
70
+ // Conventional paths, relative to the project root (process.cwd()). Used as
71
+ // the default --tokens/--css values AND as the literal text shown in the
72
+ // generated header / log messages when no override is given — this is what
73
+ // keeps the default invocation's output byte-identical to pre-flag output.
74
+ // --md-table has NO conventional default: it's an opt-in region and there's
75
+ // no natural project-wide path to assume, so it stays undefined unless given.
76
+ const DEFAULT_TOKENS_PATH = "src/config/z-index-tokens.ts";
77
+ const DEFAULT_CSS_PATH = "src/styles/global.css";
78
+
79
+ const TIER_NAME_RE = /^[a-z0-9-]+$/;
80
+
81
+ const FLAG_USAGE =
82
+ "Supported flags: --check, --tokens <path>, --css <path>, --md-table <path>, --no-theme-wrapper.";
83
+
84
+ /**
85
+ * Hand-rolled argv parser (no minimist — this bin stays dependency-free).
86
+ * Supports `--flag value` and `--flag=value` for value flags. Unknown flags,
87
+ * repeated flags, a missing value for a value flag, and a value attached to a
88
+ * boolean flag (`--no-theme-wrapper=x`) are all hard errors — this bin does
89
+ * not silently ignore or guess at malformed invocations.
90
+ *
91
+ * Returns `{ check, tokens, css, mdTable, noThemeWrapper }`; `tokens`/`css`/
92
+ * `mdTable` are `undefined` when not passed (callers apply the conventional
93
+ * defaults — `mdTable` has none, so it stays undefined and the md-table
94
+ * region is skipped entirely).
95
+ *
96
+ * Exported for unit testing.
97
+ */
98
+ export function parseArgs(argv) {
99
+ const result = {
100
+ check: false,
101
+ tokens: undefined,
102
+ css: undefined,
103
+ mdTable: undefined,
104
+ noThemeWrapper: false,
105
+ };
106
+ const seen = new Set();
107
+
108
+ for (let i = 0; i < argv.length; i++) {
109
+ const raw = argv[i];
110
+ const eqIdx = raw.startsWith("--") ? raw.indexOf("=") : -1;
111
+ const flag = eqIdx === -1 ? raw : raw.slice(0, eqIdx);
112
+ const inlineValue = eqIdx === -1 ? undefined : raw.slice(eqIdx + 1);
113
+
114
+ const isBoolean = flag === "--check" || flag === "--no-theme-wrapper";
115
+ const isValueFlag = flag === "--tokens" || flag === "--css" || flag === "--md-table";
116
+
117
+ if (!isBoolean && !isValueFlag) {
118
+ throw new Error(`Unknown flag "${raw}". ${FLAG_USAGE}`);
119
+ }
120
+ if (seen.has(flag)) {
121
+ throw new Error(`Flag "${flag}" was passed more than once.`);
122
+ }
123
+ seen.add(flag);
124
+
125
+ if (isBoolean) {
126
+ if (inlineValue !== undefined) {
127
+ throw new Error(
128
+ `Flag "${flag}" does not take a value (got "${raw}"); it is a boolean switch.`,
129
+ );
130
+ }
131
+ if (flag === "--check") result.check = true;
132
+ if (flag === "--no-theme-wrapper") result.noThemeWrapper = true;
133
+ continue;
134
+ }
135
+
136
+ // Value flag (--tokens / --css / --md-table): accept an inline `=value`,
137
+ // otherwise consume the next argv token. A missing/empty value or a next
138
+ // token that looks like another flag is a hard error, not a silent
139
+ // default.
140
+ let value = inlineValue;
141
+ if (value === undefined) {
142
+ const next = argv[i + 1];
143
+ if (next === undefined || next.startsWith("--")) {
144
+ throw new Error(`Flag "${flag}" requires a value (e.g. "${flag} <path>").`);
145
+ }
146
+ value = next;
147
+ i++;
148
+ }
149
+ if (value === "") {
150
+ throw new Error(`Flag "${flag}" requires a non-empty value.`);
151
+ }
152
+ if (flag === "--tokens") result.tokens = value;
153
+ if (flag === "--css") result.css = value;
154
+ if (flag === "--md-table") result.mdTable = value;
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ /**
161
+ * Structural validations that apply regardless of `kind`/`purpose` usage,
162
+ * plus the opt-in per-kind value-uniqueness check. Called by `parseTiers`
163
+ * after regex-extraction, and exported separately so tests can exercise it
164
+ * directly against hand-built tier arrays.
165
+ *
166
+ * - Rejects an empty tiers array (preserves the pre-refactor behavior).
167
+ * - Rejects a tier name that doesn't match `^[a-z0-9-]+$`.
168
+ * - Rejects duplicate tier names.
169
+ * - If at least one tier carries `kind`, rejects two "global" tiers sharing a
170
+ * value. "local" tiers MAY share a value with each other; kind-less tiers
171
+ * are exempt from this check entirely.
172
+ *
173
+ * Exported for unit testing.
174
+ */
175
+ export function validateTiers(tiers, tokensPath = DEFAULT_TOKENS_PATH) {
176
+ if (tiers.length === 0) {
177
+ throw new Error(`Z_INDEX_TIERS in ${tokensPath} parsed to an empty list`);
178
+ }
179
+
180
+ const seenNames = new Set();
181
+ for (const tier of tiers) {
182
+ if (!TIER_NAME_RE.test(tier.name)) {
183
+ throw new Error(
184
+ `Invalid tier name "${tier.name}" in ${tokensPath}: tier names must match ` +
185
+ `${TIER_NAME_RE} (lowercase letters, digits, hyphens only).`,
186
+ );
187
+ }
188
+ if (seenNames.has(tier.name)) {
189
+ throw new Error(
190
+ `Duplicate tier name "${tier.name}" in ${tokensPath}. Tier names must be unique.`,
191
+ );
192
+ }
193
+ seenNames.add(tier.name);
194
+ }
195
+
196
+ const anyKinded = tiers.some((tier) => tier.kind !== undefined);
197
+ if (anyKinded) {
198
+ const seenGlobalValues = new Map();
199
+ for (const tier of tiers) {
200
+ if (tier.kind !== "global") continue;
201
+ const existing = seenGlobalValues.get(tier.value);
202
+ if (existing !== undefined) {
203
+ throw new Error(
204
+ `Duplicate z-index value ${tier.value} shared by "global" tiers "${existing}" ` +
205
+ `and "${tier.name}" in ${tokensPath}. Global tiers must have unique values ` +
206
+ `(local tiers may share a value).`,
207
+ );
208
+ }
209
+ seenGlobalValues.set(tier.value, tier.name);
210
+ }
211
+ }
212
+
213
+ return tiers;
214
+ }
215
+
216
+ /**
217
+ * Scans the raw Z_INDEX_TIERS array body for `purpose:` fields and rejects
218
+ * any whose quoted value contains a brace or a backslash (which also covers
219
+ * escaped quotes) BEFORE the per-object splitter runs. The per-object
220
+ * splitter below uses a non-greedy `{...}` match to isolate each tier object,
221
+ * which only works when no field value contains a brace — a purpose string
222
+ * with a stray `}` would silently truncate the object split and corrupt
223
+ * parsing instead of failing loudly. Only a flat, plain double-quoted string
224
+ * is supported; a newline between `purpose:` and the opening quote is fine
225
+ * (the field-key regexes below all use `\s*`, which matches newlines).
226
+ */
227
+ function assertSupportedPurposeGrammar(body, tokensPath) {
228
+ const purposeKeyRe = /purpose:\s*/g;
229
+ let m;
230
+ while ((m = purposeKeyRe.exec(body)) !== null) {
231
+ const afterKey = m.index + m[0].length;
232
+ if (body[afterKey] !== '"') {
233
+ // Not immediately followed by a quote — not a value this scan can
234
+ // confirm is a real field; let the per-object parser's generic
235
+ // "malformed tier object" error handle it if it really is one.
236
+ continue;
237
+ }
238
+ let i = afterKey + 1;
239
+ let closed = false;
240
+ for (; i < body.length; i++) {
241
+ const ch = body[i];
242
+ if (ch === "{" || ch === "}" || ch === "\\") {
243
+ throw new Error(
244
+ `Unsupported purpose string grammar in ${tokensPath}: purpose values may not ` +
245
+ `contain braces, backslashes, or escaped quotes — only flat, plain double-quoted ` +
246
+ `strings are supported (the object parser cannot safely handle anything else). ` +
247
+ `Offending text near: ${JSON.stringify(body.slice(afterKey, Math.min(afterKey + 40, body.length)))}`,
248
+ );
249
+ }
250
+ if (ch === '"') {
251
+ closed = true;
252
+ break;
253
+ }
254
+ }
255
+ if (!closed) {
256
+ throw new Error(
257
+ `Unterminated purpose string in ${tokensPath} (no closing double quote found).`,
258
+ );
259
+ }
260
+ purposeKeyRe.lastIndex = i + 1;
261
+ }
262
+ }
263
+
44
264
  /**
45
265
  * Parse the Z_INDEX_TIERS array out of z-index-tokens.ts WITHOUT importing it
46
266
  * (this bin is a dependency-free .mjs and cannot resolve TypeScript). Reads
47
- * each `{ name: "...", value: <n>, ... }` object literal. Throws on a malformed
48
- * source so drift between the parser and the file surfaces loudly.
267
+ * each `{ name: "...", value: <n>, kind?: "global"|"local", purpose?: "..." }`
268
+ * object literal. Throws on a malformed source, an unsupported purpose-string
269
+ * grammar, or an unknown `kind` value so drift between the parser and the
270
+ * file surfaces loudly. Delegates the structural invariants (non-empty,
271
+ * name shape, duplicate names, per-kind value uniqueness) to `validateTiers`.
272
+ *
273
+ * `tokensPath` is used purely for error-message context — pass the same path
274
+ * string (conventional default or an explicit --tokens value) that was used
275
+ * to read `src`, so the thrown error tells the reader exactly which file to
276
+ * fix.
277
+ *
278
+ * Exported for unit testing.
49
279
  */
50
- function parseTiers(src) {
280
+ export function parseTiers(src, tokensPath = DEFAULT_TOKENS_PATH) {
51
281
  const arrayMatch = src.match(
52
282
  /export const Z_INDEX_TIERS[^=]*=\s*\[([\s\S]*?)\];/,
53
283
  );
54
284
  if (!arrayMatch) {
55
285
  throw new Error(
56
- `Could not locate "export const Z_INDEX_TIERS = [ ... ]" in ${TOKENS_PATH}`,
286
+ `Could not locate "export const Z_INDEX_TIERS = [ ... ]" in ${tokensPath}`,
57
287
  );
58
288
  }
59
289
  const body = arrayMatch[1];
290
+
291
+ assertSupportedPurposeGrammar(body, tokensPath);
292
+
60
293
  const tiers = [];
61
- // Each tier is a `{ ... }` object literal; iterate top-level braces.
294
+ // Each tier is a `{ ... }` object literal; iterate top-level braces. Safe
295
+ // because assertSupportedPurposeGrammar above already ruled out braces
296
+ // inside any purpose string.
62
297
  const objectRe = /\{([\s\S]*?)\}/g;
63
298
  let m;
64
299
  while ((m = objectRe.exec(body)) !== null) {
@@ -67,98 +302,557 @@ function parseTiers(src) {
67
302
  const valueMatch = obj.match(/value:\s*(-?\d+)/);
68
303
  if (!nameMatch || !valueMatch) {
69
304
  throw new Error(
70
- `Malformed tier object in Z_INDEX_TIERS (missing name/value): ${obj.trim()}`,
305
+ `Malformed tier object in Z_INDEX_TIERS (missing name/value) in ${tokensPath}: ${obj.trim()}`,
71
306
  );
72
307
  }
73
- tiers.push({ name: nameMatch[1], value: Number(valueMatch[1]) });
74
- }
75
- if (tiers.length === 0) {
76
- throw new Error(`Z_INDEX_TIERS in ${TOKENS_PATH} parsed to an empty list`);
308
+
309
+ const tier = { name: nameMatch[1], value: Number(valueMatch[1]) };
310
+
311
+ const kindMatch = obj.match(/kind:\s*"([^"]*)"/);
312
+ if (kindMatch) {
313
+ const kindValue = kindMatch[1];
314
+ if (kindValue !== "global" && kindValue !== "local") {
315
+ throw new Error(
316
+ `Invalid kind "${kindValue}" for tier "${tier.name}" in ${tokensPath} ` +
317
+ `(expected "global" or "local").`,
318
+ );
319
+ }
320
+ tier.kind = kindValue;
321
+ }
322
+
323
+ const purposeMatch = obj.match(/purpose:\s*"([^"]*)"/);
324
+ if (purposeMatch) {
325
+ tier.purpose = purposeMatch[1];
326
+ }
327
+
328
+ tiers.push(tier);
77
329
  }
78
- return tiers;
330
+
331
+ return validateTiers(tiers, tokensPath);
332
+ }
333
+
334
+ /**
335
+ * Wraps a path in double quotes so it copy-pastes safely into a shell even
336
+ * when it contains a space (an unquoted `--tokens a b.ts` would otherwise
337
+ * split into two argv tokens on rerun).
338
+ */
339
+ function shellQuote(value) {
340
+ return `"${value}"`;
341
+ }
342
+
343
+ /**
344
+ * Builds the rerun-guidance command shown in the generated header comment and
345
+ * in main()'s drift/error messages. Returns the exact legacy "pnpm
346
+ * gen:z-index" text when every option is at its conventional default (the
347
+ * byte-identity case); otherwise builds an explicit, directly-runnable
348
+ * `pnpm exec gen-z-index ...` invocation carrying only the non-default flags
349
+ * (quoted), so the guidance always reruns with the SAME effective options
350
+ * that produced the current output. `pnpm exec` — rather than a bare
351
+ * `gen-z-index` — is used because a project customizing --tokens/--css has
352
+ * no guarantee it also defined a package.json script alias for that exact
353
+ * invocation; `pnpm exec` resolves the package-local bin regardless.
354
+ *
355
+ * `mdTablePath` has no conventional default (undefined = --md-table wasn't
356
+ * given), so its mere presence always disqualifies the byte-identity case.
357
+ */
358
+ function buildRerunCommand({ tokensPath, cssPath, themeWrapper, mdTablePath }) {
359
+ const isDefault =
360
+ tokensPath === DEFAULT_TOKENS_PATH &&
361
+ cssPath === DEFAULT_CSS_PATH &&
362
+ themeWrapper === true &&
363
+ mdTablePath === undefined;
364
+ if (isDefault) return "pnpm gen:z-index";
365
+
366
+ const parts = ["pnpm exec gen-z-index"];
367
+ if (tokensPath !== DEFAULT_TOKENS_PATH) parts.push(`--tokens ${shellQuote(tokensPath)}`);
368
+ if (cssPath !== DEFAULT_CSS_PATH) parts.push(`--css ${shellQuote(cssPath)}`);
369
+ if (!themeWrapper) parts.push("--no-theme-wrapper");
370
+ if (mdTablePath !== undefined) parts.push(`--md-table ${shellQuote(mdTablePath)}`);
371
+ return parts.join(" ");
79
372
  }
80
373
 
81
374
  /**
82
375
  * Build the full generated block (markers included). Two leading spaces of
83
376
  * indentation match the surrounding `@theme` style in global.css.
377
+ *
378
+ * `options.tokensPath`/`options.cssPath` feed the "Source of truth:" and
379
+ * rerun-guidance lines in the header comment — pass the SAME path strings
380
+ * (conventional defaults or explicit --tokens/--css values) used to read/
381
+ * write the actual files, so a reader of the committed CSS can tell exactly
382
+ * where the block came from and how to regenerate it. With every option at
383
+ * its default, the header is byte-identical to the pre-flag generator.
384
+ *
385
+ * `options.themeWrapper` (default `true`) wraps the declarations in an
386
+ * `@theme { ... }` block; `false` (the `--no-theme-wrapper` CLI flag) emits
387
+ * bare `--z-index-<name>: <value>;` declarations at 2-space indent instead,
388
+ * for projects composing the block into their own `@theme` block.
389
+ *
390
+ * Deliberately does NOT accept `mdTablePath`: the CSS block's own content
391
+ * must depend only on the CSS-region flags (--tokens/--css/--no-theme-
392
+ * wrapper), never on whether --md-table happens to be passed in a given
393
+ * invocation — otherwise adding/dropping --md-table would flip the embedded
394
+ * rerun-guidance text and manufacture CSS-region drift unrelated to any
395
+ * actual tier change. `main()`'s own drift/error messages use
396
+ * `buildRerunCommand` directly (with `mdTablePath`) for that guidance instead.
397
+ *
398
+ * Exported for unit testing.
84
399
  */
85
- function buildBlock(tiers) {
400
+ export function buildBlock(tiers, options = {}) {
401
+ const {
402
+ tokensPath = DEFAULT_TOKENS_PATH,
403
+ cssPath = DEFAULT_CSS_PATH,
404
+ themeWrapper = true,
405
+ } = options;
406
+
86
407
  const lines = [];
87
408
  lines.push(` /* ${BEGIN_MARKER}`);
88
409
  lines.push(
89
- ` * GENERATED:Z_INDEX — do not hand-edit; run pnpm gen:z-index.`,
90
- );
91
- lines.push(
92
- ` * Source of truth: src/config/z-index-tokens.ts. Tailwind v4 reads the`,
410
+ ` * GENERATED:Z_INDEX — do not hand-edit; run ${buildRerunCommand({ tokensPath, cssPath, themeWrapper })}.`,
93
411
  );
412
+ lines.push(` * Source of truth: ${tokensPath}. Tailwind v4 reads the`);
94
413
  lines.push(
95
414
  ` * --z-index-<name> theme key and generates a z-<name> utility. */`,
96
415
  );
97
- lines.push(` @theme {`);
98
- for (const tier of tiers) {
99
- lines.push(` --z-index-${tier.name}: ${tier.value};`);
416
+ if (themeWrapper) {
417
+ lines.push(` @theme {`);
418
+ for (const tier of tiers) {
419
+ lines.push(` --z-index-${tier.name}: ${tier.value};`);
420
+ }
421
+ lines.push(` }`);
422
+ } else {
423
+ for (const tier of tiers) {
424
+ lines.push(` --z-index-${tier.name}: ${tier.value};`);
425
+ }
100
426
  }
101
- lines.push(` }`);
102
427
  lines.push(` /* ${END_MARKER} */`);
103
428
  return lines.join("\n");
104
429
  }
105
430
 
431
+ // CommonMark fenced-code-block rules (https://spec.commonmark.org/0.31.2/
432
+ // #fenced-code-blocks), transcribed only as far as `scanMarkerLines` needs
433
+ // them. Each of these is a rule a `trim()` + `startsWith` approximation gets
434
+ // wrong, which is why they are spelled out here rather than eyeballed:
435
+ //
436
+ // - An opening fence carries AT MOST three spaces of indentation. Four
437
+ // spaces makes the line indented code, not a fence.
438
+ // - A fence may open on a list-item line (`- ```mdx`), because the list
439
+ // marker is a container prefix rather than content. Missing this one is
440
+ // not a harmless false negative: the item's closing fence — indented to
441
+ // the item's content column, so carrying NO list marker — would then be
442
+ // read as a fresh opener and swallow every marker below it, silently
443
+ // splicing the generated block into the quoted example. Loud failure is
444
+ // acceptable here; silent corruption is not.
445
+ // - A backtick opening fence's info string may not itself contain a
446
+ // backtick (a tilde fence's info string may contain anything).
447
+ // - A closing fence repeats the SAME character, at least as many times as
448
+ // the opener, followed by whitespace only — so an info-string line such
449
+ // as ```js does not close an already-open fence. It carries no list
450
+ // marker of its own, which is why only OPEN_FENCE_RE accepts one.
451
+ // - A closing fence's indentation allowance is measured from the OPENING
452
+ // fence, not from column zero: inside a container the closer sits at the
453
+ // container's content column and may carry up to three further spaces.
454
+ // So the bound is `opener indent + 3`, where the opener's indent is its
455
+ // leading spaces PLUS any list-marker prefix width. A flat three-space
456
+ // cap is wrong for any marker four or more characters wide (`10) `, or
457
+ // `1.` followed by two spaces): the item's real closer would be rejected,
458
+ // the fence would never close, and every marker below it would stay
459
+ // ineligible. Since the opener's indent is never negative, a closer at
460
+ // 0-3 spaces still closes a fence opened at ANY indent.
461
+ // - Indentation is measured in COLUMNS, with a tab advancing to the next
462
+ // four-column tab stop. The opener accepts a tab as list-marker padding,
463
+ // and `"1.\t".length` (3) undercounts that item's real content column
464
+ // (4) — which would then reject a legal closer sitting at column + 3.
465
+ const OPEN_FENCE_RE = /^( {0,3}(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?)(`{3,}|~{3,})(.*)$/;
466
+ const CLOSE_FENCE_RE = /^( *)(`{3,}|~{3,})[ \t]*$/;
467
+
106
468
  /**
107
- * Replace the existing BEGIN…END block in `css` with `block`. Throws if the
108
- * markers are missing (the block must be seeded once by hand see global.css).
469
+ * Column width of an opening fence's prefix (leading spaces plus any
470
+ * list-marker prefix), expanding tabs to CommonMark's four-column tab stops
471
+ * see the tab-stop rule in the block comment above. `CLOSE_FENCE_RE` matches
472
+ * spaces only, so the closer side needs no equivalent (and accepting a
473
+ * tab-indented closer would widen behaviour the pre-existing `^ {0,3}` cap
474
+ * never had).
109
475
  */
110
- function replaceBlock(css, block) {
111
- const beginIdx = css.indexOf(BEGIN_MARKER);
112
- const endIdx = css.indexOf(END_MARKER);
113
- if (beginIdx === -1 || endIdx === -1) {
476
+ function fenceIndentColumns(prefix) {
477
+ let column = 0;
478
+ for (const ch of prefix) {
479
+ column = ch === "\t" ? column + 4 - (column % 4) : column + 1;
480
+ }
481
+ return column;
482
+ }
483
+
484
+ /**
485
+ * Walks `source` line by line and returns the character offsets (into
486
+ * `source`, in source order) of every LINE-ANCHORED occurrence of `marker` —
487
+ * a line where removing the marker text leaves behind only whitespace and
488
+ * comment/brace delimiter characters. This is the single scanner both
489
+ * `replaceBlock`'s validation (duplicate/missing/inverted) and its splice
490
+ * positions read from, so counting and locating can never diverge: a marker
491
+ * mentioned in prose (e.g. "see GENERATED:Z_INDEX_BEGIN for details") is
492
+ * invisible to this scanner, whether it appears before, between, or after the
493
+ * real structural markers.
494
+ *
495
+ * Predicate: for a line containing `marker`, `line.replace(marker, "")` must
496
+ * match `/^[\s{}/*]*$/`. This covers both real marker forms — the CSS
497
+ * mid-comment-line pair (e.g. ` /* GENERATED:Z_INDEX_BEGIN`) and the MDX
498
+ * whole-line brace-comment pair (e.g. `{/* GENERATED:Z_INDEX_TABLE_BEGIN`,
499
+ * closed by a trailing brace-comment on the same line) — while rejecting a
500
+ * line where the marker is only part of a prose sentence.
501
+ *
502
+ * `options.excludeFencedCode` (default `false`) additionally tracks
503
+ * CommonMark fenced-code state (see the two regexes above) and makes every
504
+ * line from an opening fence through its closing fence — inclusive, and
505
+ * through end-of-source for an unterminated fence — ineligible. It is
506
+ * OFF by default and passed only from the `--md-table` surface: fences are a
507
+ * markdown construct, and the CSS surface must keep byte-identical behaviour.
508
+ *
509
+ * ACCEPTED LIMITATION: a marker line inside a **four-space-indented** code
510
+ * block is still counted. Indented code is not a fence, and detecting it
511
+ * needs far more markdown awareness (list-item continuation indentation,
512
+ * paragraph interruption rules) than a dependency-free bin should carry —
513
+ * zudolab/zudo-doc#3290 names fenced code, not indented code. The failure
514
+ * mode stays loud (a "duplicate markers" error), never silent corruption.
515
+ *
516
+ * Exported for unit testing.
517
+ */
518
+ export function scanMarkerLines(source, marker, options = {}) {
519
+ const { excludeFencedCode = false } = options;
520
+ const RESIDUE_RE = /^[\s{}/*]*$/;
521
+ const offsets = [];
522
+ // `{ char, length, indent }` while inside an open fenced region, else null.
523
+ // `indent` is the opening fence's content COLUMN (leading spaces plus any
524
+ // list-marker prefix, tabs expanded), which bounds how far its closer may be
525
+ // indented.
526
+ let fence = null;
527
+ let lineStart = 0;
528
+ while (lineStart <= source.length) {
529
+ const newlineIdx = source.indexOf("\n", lineStart);
530
+ const lineEnd = newlineIdx === -1 ? source.length : newlineIdx;
531
+ const line = source.slice(lineStart, lineEnd);
532
+
533
+ let eligible = true;
534
+ if (excludeFencedCode) {
535
+ // Classify against the line minus a CRLF carriage return, so the
536
+ // "whitespace only after a closing fence" rule still holds on a CRLF
537
+ // source. Offsets are unaffected — `line` itself is untouched.
538
+ const text = line.endsWith("\r") ? line.slice(0, -1) : line;
539
+ if (fence !== null) {
540
+ const close = CLOSE_FENCE_RE.exec(text);
541
+ if (
542
+ close &&
543
+ close[2][0] === fence.char &&
544
+ close[2].length >= fence.length &&
545
+ close[1].length <= fence.indent + 3
546
+ ) {
547
+ fence = null;
548
+ }
549
+ eligible = false;
550
+ } else {
551
+ const open = OPEN_FENCE_RE.exec(text);
552
+ const backtickInInfoString = open !== null && open[2][0] === "`" && open[3].includes("`");
553
+ if (open !== null && !backtickInInfoString) {
554
+ fence = {
555
+ char: open[2][0],
556
+ length: open[2].length,
557
+ indent: fenceIndentColumns(open[1]),
558
+ };
559
+ eligible = false;
560
+ }
561
+ }
562
+ }
563
+
564
+ if (eligible) {
565
+ const markerIdxInLine = line.indexOf(marker);
566
+ if (markerIdxInLine !== -1 && RESIDUE_RE.test(line.replace(marker, ""))) {
567
+ offsets.push(lineStart + markerIdxInLine);
568
+ }
569
+ }
570
+ if (newlineIdx === -1) break;
571
+ lineStart = newlineIdx + 1;
572
+ }
573
+ return offsets;
574
+ }
575
+
576
+ /**
577
+ * Replace the existing BEGIN…END block in `source` with `block`. Requires
578
+ * EXACTLY one line-anchored BEGIN and one line-anchored END marker (per
579
+ * `scanMarkerLines`), with BEGIN preceding END — throws a clear, distinct
580
+ * error for each failure mode: missing (the block must be seeded once by
581
+ * hand), duplicated, or inverted markers.
582
+ *
583
+ * `beginMarker`/`endMarker` default to the CSS `@theme` block's markers so
584
+ * existing call sites (the CSS region) are unaffected; the `--md-table`
585
+ * region passes `MD_TABLE_BEGIN_MARKER`/`MD_TABLE_END_MARKER` instead — same
586
+ * function, parameterized, mirroring how gen-component-tokens.mjs reuses one
587
+ * `replaceBlock` across its content/chrome surfaces. `filePath` is used
588
+ * purely for error-message context (pass the conventional default or an
589
+ * explicit --css/--md-table value).
590
+ *
591
+ * `options.excludeFencedCode` is forwarded verbatim to `scanMarkerLines` and
592
+ * is gated on the SURFACE, not on the marker strings: only the `--md-table`
593
+ * call site passes it. Defaulting it off keeps every CSS call site — and its
594
+ * output — byte-unchanged.
595
+ *
596
+ * Exported for unit testing.
597
+ */
598
+ export function replaceBlock(
599
+ source,
600
+ block,
601
+ beginMarker = BEGIN_MARKER,
602
+ endMarker = END_MARKER,
603
+ filePath = DEFAULT_CSS_PATH,
604
+ options = {},
605
+ ) {
606
+ const { excludeFencedCode = false } = options;
607
+ const scanOptions = { excludeFencedCode };
608
+ const beginOffsets = scanMarkerLines(source, beginMarker, scanOptions);
609
+ const endOffsets = scanMarkerLines(source, endMarker, scanOptions);
610
+
611
+ if (beginOffsets.length === 0 || endOffsets.length === 0) {
114
612
  throw new Error(
115
- `Could not find ${BEGIN_MARKER} … ${END_MARKER} markers in ${CSS_PATH}.\n` +
613
+ `Could not find ${beginMarker} … ${endMarker} markers in ${filePath}.\n` +
116
614
  `Seed the marker block once by hand, then re-run the generator.`,
117
615
  );
118
616
  }
119
- // Expand to the full comment line that opens the block (" /* GENERATED:...")
120
- // and to the end of the closing "*/ " line so the whole region is replaced.
121
- const lineStart = css.lastIndexOf("\n", beginIdx) + 1;
122
- const afterEnd = css.indexOf("\n", endIdx);
123
- const lineEnd = afterEnd === -1 ? css.length : afterEnd;
124
- return css.slice(0, lineStart) + block + css.slice(lineEnd);
617
+ if (beginOffsets.length > 1 || endOffsets.length > 1) {
618
+ throw new Error(
619
+ `Found duplicate markers in ${filePath} (${beginOffsets.length} BEGIN "${beginMarker}", ` +
620
+ `${endOffsets.length} END "${endMarker}"; expected exactly one of each). Remove the extra ` +
621
+ `marker(s) by hand, then re-run the generator.`,
622
+ );
623
+ }
624
+
625
+ const beginIdx = beginOffsets[0];
626
+ const endIdx = endOffsets[0];
627
+ if (beginIdx > endIdx) {
628
+ throw new Error(
629
+ `Markers in ${filePath} are inverted — the END marker (${endMarker}) appears before ` +
630
+ `the BEGIN marker (${beginMarker}). Fix the marker order by hand, then re-run the ` +
631
+ `generator.`,
632
+ );
633
+ }
634
+
635
+ // Expand to the full line that opens the block and to the end of the line
636
+ // that closes it, so the whole region (CSS comment or MDX brace-comment) is
637
+ // replaced.
638
+ const lineStart = source.lastIndexOf("\n", beginIdx) + 1;
639
+ const afterEnd = source.indexOf("\n", endIdx);
640
+ const lineEnd = afterEnd === -1 ? source.length : afterEnd;
641
+ return source.slice(0, lineStart) + block + source.slice(lineEnd);
125
642
  }
126
643
 
127
- function main() {
128
- const check = process.argv.includes("--check");
644
+ /**
645
+ * Collapses whitespace runs — including an embedded literal newline from a
646
+ * multi-line `purpose:` source string (the source grammar permits a raw
647
+ * newline between the opening/closing quotes; only braces/backslashes/
648
+ * escaped quotes are rejected, see `assertSupportedPurposeGrammar`) — into a
649
+ * single space, and trims the result. A markdown/GFM table cell cannot
650
+ * contain a raw newline without corrupting the row.
651
+ */
652
+ function collapseWhitespace(str) {
653
+ return str.replace(/\s+/g, " ").trim();
654
+ }
129
655
 
130
- const tokensSrc = readFileSync(TOKENS_PATH, "utf8");
131
- const css = readFileSync(CSS_PATH, "utf8");
656
+ /** Escapes a literal `|` so it can't be misread as a table-cell boundary. */
657
+ function escapeTableCell(str) {
658
+ return str.replace(/\|/g, "\\|");
659
+ }
132
660
 
133
- const tiers = parseTiers(tokensSrc);
134
- const block = buildBlock(tiers);
135
- const next = replaceBlock(css, block);
661
+ /**
662
+ * Escapes the characters MDX treats as syntax in text position — `<` (JSX
663
+ * tag open) and `{`/`}` (expression delimiters) — as character references,
664
+ * so a purpose like "search <dialog>" is emitted as literal text instead of
665
+ * failing MDX compilation as an unclosed JSX element. `&` is escaped FIRST
666
+ * so a purpose that already spells out an entity (e.g. "&lt;") stays
667
+ * literal `&lt;` on the rendered page instead of collapsing to `<`, and so
668
+ * the replacements below can never double-escape their own output.
669
+ *
670
+ * Braces cannot reach here via the CLI (`assertSupportedPurposeGrammar`
671
+ * rejects them at parse time), but `buildMdTable` is an exported helper that
672
+ * accepts hand-built tier arrays, so it defends against them itself.
673
+ */
674
+ function escapeMdxText(str) {
675
+ return str
676
+ .replace(/&/g, "&amp;")
677
+ .replace(/</g, "&lt;")
678
+ .replace(/\{/g, "&#123;")
679
+ .replace(/\}/g, "&#125;");
680
+ }
681
+
682
+ /**
683
+ * Build the full `--md-table` region (markers included): a GFM
684
+ * `| Token | Kind | Role |` table, one row per tier, in source order.
685
+ * - Token: the tier name.
686
+ * - Kind: the tier's optional `kind` field, or `-` when absent.
687
+ * - Role: the tier's optional `purpose` field with internal whitespace/
688
+ * newlines collapsed to single spaces, or `-` when absent/empty.
689
+ * `|` in any cell is escaped so a stray pipe in a purpose string can't
690
+ * corrupt the table structure. The Role cell is additionally MDX-escaped
691
+ * (`&` first, then `<`/`{`/`}` — see `escapeMdxText`) so a purpose like
692
+ * "search <dialog>" can't make the emitted .mdx fail to compile as an
693
+ * unclosed JSX element.
694
+ *
695
+ * `options.tokensPath` feeds the "do not hand-edit" note beneath the BEGIN
696
+ * marker (same default/meaning as `buildBlock`'s `tokensPath`).
697
+ * `options.beginMarker`/`options.endMarker` default to the MDX-safe
698
+ * `MD_TABLE_BEGIN_MARKER`/`MD_TABLE_END_MARKER` pair — overridable for tests,
699
+ * same convention as `replaceBlock`.
700
+ *
701
+ * Exported for unit testing.
702
+ */
703
+ export function buildMdTable(tiers, options = {}) {
704
+ const {
705
+ tokensPath = DEFAULT_TOKENS_PATH,
706
+ beginMarker = MD_TABLE_BEGIN_MARKER,
707
+ endMarker = MD_TABLE_END_MARKER,
708
+ } = options;
709
+
710
+ const lines = [];
711
+ lines.push(beginMarker);
712
+ lines.push("");
713
+ lines.push(
714
+ `_Generated by \`gen-z-index --md-table\`; do not hand-edit — edit \`${tokensPath}\` instead._`,
715
+ );
716
+ lines.push("");
717
+ lines.push("| Token | Kind | Role |");
718
+ lines.push("| --- | --- | --- |");
719
+ for (const tier of tiers) {
720
+ const token = escapeTableCell(tier.name);
721
+ const kind = escapeTableCell(tier.kind ?? "-");
722
+ // Collapse first, THEN decide the fallback — a whitespace-only purpose
723
+ // (e.g. " ") is truthy but collapses to "", which must still fall back
724
+ // to "-" rather than emit a blank Role cell.
725
+ const collapsedPurpose = tier.purpose ? collapseWhitespace(tier.purpose) : "";
726
+ // Role is the only free-text cell (Token is ^[a-z0-9-]+$, Kind is
727
+ // global|local|-), so it alone needs MDX escaping on top of the pipe
728
+ // escaping.
729
+ const role = collapsedPurpose
730
+ ? escapeTableCell(escapeMdxText(collapsedPurpose))
731
+ : "-";
732
+ lines.push(`| ${token} | ${kind} | ${role} |`);
733
+ }
734
+ lines.push("");
735
+ lines.push(endMarker);
736
+ return lines.join("\n");
737
+ }
738
+
739
+ /**
740
+ * CLI entrypoint. `argv` defaults to the real process argv (minus the node/
741
+ * script prefix) so `isDirectInvocation()` below can call `main()` with no
742
+ * arguments, while tests can pass a synthetic argv without touching
743
+ * `process.argv`. Resolves --tokens/--css/--md-table against `process.cwd()`
744
+ * (the project root) for actual file I/O, but threads the AS-GIVEN path
745
+ * strings (conventional defaults or explicit flag values) through to every
746
+ * message and into the generated header — never the resolved absolute path —
747
+ * so the committed CSS/md file never embeds a machine-specific path.
748
+ *
749
+ * The CSS `@theme` region is always generated/verified. The `--md-table`
750
+ * region is entirely opt-in: when `--md-table <path>` isn't passed, no md
751
+ * file is read, built, or written, and `--check` only covers the CSS region
752
+ * (unchanged from pre-`--md-table` behavior). When it IS passed, `--check`
753
+ * covers BOTH regions — drift in either exits 1.
754
+ *
755
+ * Exported for unit testing.
756
+ */
757
+ export function main(argv = process.argv.slice(2)) {
758
+ const args = parseArgs(argv);
759
+ const tokensPath = args.tokens ?? DEFAULT_TOKENS_PATH;
760
+ const cssPath = args.css ?? DEFAULT_CSS_PATH;
761
+ const themeWrapper = !args.noThemeWrapper;
762
+ const mdTablePath = args.mdTable;
763
+
764
+ const root = process.cwd();
765
+ const tokensAbsPath = resolve(root, tokensPath);
766
+ const cssAbsPath = resolve(root, cssPath);
136
767
 
137
- if (check) {
138
- if (next !== css) {
768
+ const tokensSrc = readFileSync(tokensAbsPath, "utf8");
769
+ const css = readFileSync(cssAbsPath, "utf8");
770
+
771
+ const tiers = parseTiers(tokensSrc, tokensPath);
772
+ const block = buildBlock(tiers, { tokensPath, cssPath, themeWrapper });
773
+ const nextCss = replaceBlock(css, block, BEGIN_MARKER, END_MARKER, cssPath);
774
+
775
+ let mdAbsPath;
776
+ let mdSrc;
777
+ let nextMd;
778
+ if (mdTablePath !== undefined) {
779
+ mdAbsPath = resolve(root, mdTablePath);
780
+ mdSrc = readFileSync(mdAbsPath, "utf8");
781
+ const mdBlock = buildMdTable(tiers, { tokensPath });
782
+ nextMd = replaceBlock(
783
+ mdSrc,
784
+ mdBlock,
785
+ MD_TABLE_BEGIN_MARKER,
786
+ MD_TABLE_END_MARKER,
787
+ mdTablePath,
788
+ { excludeFencedCode: true },
789
+ );
790
+ }
791
+
792
+ if (args.check) {
793
+ let drift = false;
794
+ if (nextCss !== css) {
795
+ console.error(`z-index codegen drift detected: ${cssPath} is out of date.`);
796
+ drift = true;
797
+ }
798
+ if (mdTablePath !== undefined && nextMd !== mdSrc) {
799
+ console.error(`z-index codegen drift detected: ${mdTablePath} is out of date.`);
800
+ drift = true;
801
+ }
802
+ if (drift) {
139
803
  console.error(
140
- "z-index codegen drift detected: src/styles/global.css is out of date.",
804
+ `Run \`${buildRerunCommand({ tokensPath, cssPath, themeWrapper, mdTablePath })}\` and commit the result.`,
141
805
  );
142
- console.error("Run `pnpm gen:z-index` and commit the result.");
143
806
  return 1;
144
807
  }
145
808
  console.log(
146
- `OK z-index @theme block is up to date (${tiers.length} tiers).`,
809
+ mdTablePath !== undefined
810
+ ? `OK — z-index @theme block and md table are up to date (${tiers.length} tiers).`
811
+ : `OK — z-index @theme block is up to date (${tiers.length} tiers).`,
147
812
  );
148
813
  return 0;
149
814
  }
150
815
 
151
- if (next === css) {
816
+ if (nextCss === css) {
152
817
  console.log(
153
818
  `z-index @theme block already up to date (${tiers.length} tiers); no change.`,
154
819
  );
155
- return 0;
820
+ } else {
821
+ writeFileSync(cssAbsPath, nextCss);
822
+ console.log(`Wrote z-index @theme block to ${cssPath} (${tiers.length} tiers).`);
156
823
  }
157
- writeFileSync(CSS_PATH, next);
158
- console.log(
159
- `Wrote z-index @theme block to src/styles/global.css (${tiers.length} tiers).`,
160
- );
824
+
825
+ if (mdTablePath !== undefined) {
826
+ if (nextMd === mdSrc) {
827
+ console.log(`z-index table already up to date at ${mdTablePath}; no change.`);
828
+ } else {
829
+ writeFileSync(mdAbsPath, nextMd);
830
+ console.log(`Wrote z-index table to ${mdTablePath} (${tiers.length} tiers).`);
831
+ }
832
+ }
833
+
161
834
  return 0;
162
835
  }
163
836
 
164
- process.exit(main());
837
+ // Run the CLI only when executed directly, NOT when imported by tests (an
838
+ // import must not write global.css or exit the process as a side effect — it
839
+ // would break `pnpm test`). Compare REAL paths on both sides: when invoked
840
+ // through the pnpm bin shim, argv[1] is the `node_modules/.bin/…` symlink,
841
+ // NOT the real file, so a raw path-equality check is always false and
842
+ // main() would silently no-op. realpathSync resolves the shim/symlink to the
843
+ // real file on both sides so direct invocation is detected however the bin
844
+ // is launched.
845
+ function isDirectInvocation() {
846
+ if (!process.argv[1]) return false;
847
+ try {
848
+ return (
849
+ realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])
850
+ );
851
+ } catch {
852
+ return false;
853
+ }
854
+ }
855
+
856
+ if (isDirectInvocation()) {
857
+ process.exit(main());
858
+ }