create-zudo-doc 0.2.21 → 0.2.22

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.
Files changed (71) hide show
  1. package/dist/features/image-enlarge.d.ts +10 -2
  2. package/dist/features/image-enlarge.js +10 -2
  3. package/dist/scaffold.js +20 -22
  4. package/dist/zfb-config-gen.d.ts +16 -10
  5. package/dist/zfb-config-gen.js +34 -239
  6. package/package.json +1 -1
  7. package/templates/base/pages/lib/_body-end-islands.tsx +6 -13
  8. package/templates/base/pages/lib/_category-nav.tsx +30 -115
  9. package/templates/base/pages/lib/_category-tree-nav.tsx +38 -65
  10. package/templates/base/pages/lib/_compose-meta-title.ts +2 -6
  11. package/templates/base/pages/lib/_doc-body-end.tsx +3 -35
  12. package/templates/base/pages/lib/_doc-content-header.tsx +10 -111
  13. package/templates/base/pages/lib/_doc-history-area.tsx +19 -211
  14. package/templates/base/pages/lib/_doc-metainfo-area.tsx +10 -113
  15. package/templates/base/pages/lib/_doc-page-renderer.tsx +22 -183
  16. package/templates/base/pages/lib/_doc-page-shell.tsx +17 -251
  17. package/templates/base/pages/lib/_doc-pager.tsx +4 -74
  18. package/templates/base/pages/lib/_doc-route-entries.ts +43 -177
  19. package/templates/base/pages/lib/_doc-route-paths.ts +16 -101
  20. package/templates/base/pages/lib/_doc-tags-area.tsx +14 -77
  21. package/templates/base/pages/lib/_footer-with-defaults.tsx +37 -225
  22. package/templates/base/pages/lib/_frontmatter-preview-data.ts +5 -31
  23. package/templates/base/pages/lib/_head-with-defaults.tsx +13 -138
  24. package/templates/base/pages/lib/_header-with-defaults.tsx +24 -324
  25. package/templates/base/pages/lib/_inline-version-switcher.tsx +16 -78
  26. package/templates/base/pages/lib/_nav-data-prep.ts +32 -51
  27. package/templates/base/pages/lib/_nav-source-cache.ts +12 -57
  28. package/templates/base/pages/lib/_nav-source-docs.ts +33 -233
  29. package/templates/base/pages/lib/_search-widget-script.ts +2 -470
  30. package/templates/base/pages/lib/_search-widget.tsx +9 -195
  31. package/templates/base/pages/lib/_sidebar-prepaint.tsx +6 -59
  32. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +16 -118
  33. package/templates/base/pages/lib/_site-tree-nav.tsx +29 -80
  34. package/templates/base/pages/lib/doc-page-props.ts +26 -44
  35. package/templates/base/pages/lib/locale-merge.ts +32 -145
  36. package/templates/base/pages/lib/route-enumerators.ts +52 -286
  37. package/templates/base/pages/robots.txt.tsx +2 -26
  38. package/templates/base/src/components/ai-chat-modal.tsx +9 -8
  39. package/templates/base/src/components/doc-history.tsx +9 -8
  40. package/templates/base/src/components/image-enlarge.tsx +11 -8
  41. package/templates/base/src/components/sidebar-toggle.tsx +6 -170
  42. package/templates/base/src/components/sidebar-tree.tsx +6 -548
  43. package/templates/base/src/components/site-tree-nav.tsx +6 -220
  44. package/templates/base/src/config/color-scheme-utils.ts +34 -158
  45. package/templates/base/src/config/i18n.ts +9 -0
  46. package/templates/base/src/config/z-index-tokens.ts +5 -4
  47. package/templates/base/src/styles/global.css +5 -579
  48. package/templates/base/src/utils/base.ts +1 -1
  49. package/templates/base/src/utils/docs.ts +47 -16
  50. package/templates/base/src/utils/github.ts +12 -9
  51. package/templates/base/src/utils/nav-scope.ts +13 -42
  52. package/templates/base/src/utils/sidebar.ts +18 -86
  53. package/templates/base/src/utils/slug.ts +10 -53
  54. package/templates/base/src/utils/smart-break.tsx +12 -120
  55. package/templates/base/src/utils/tags.ts +25 -68
  56. package/templates/features/bodyFootUtil/files/src/utils/github.ts +12 -9
  57. package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +13 -39
  58. package/templates/features/docHistory/files/src/components/doc-history.tsx +8 -636
  59. package/templates/features/docHistory/files/src/types/doc-history.ts +7 -23
  60. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +35 -201
  61. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +8 -269
  62. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +6 -99
  63. package/templates/features/tagGovernance/files/scripts/tags-audit.ts +67 -515
  64. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +21 -73
  65. package/templates/base/plugins/connect-adapter.mjs +0 -169
  66. package/templates/base/plugins/search-index-plugin.mjs +0 -66
  67. package/templates/base/scripts/gen-z-index.mjs +0 -157
  68. package/templates/base/src/components/mermaid-enlarge.tsx +0 -490
  69. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +0 -47
  70. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +0 -111
  71. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +0 -93
@@ -1,534 +1,93 @@
1
1
  #!/usr/bin/env -S tsx
2
2
  /**
3
- * tags-audit.ts — vocabulary-aware audit of frontmatter tags.
3
+ * scripts/tags-audit.ts
4
4
  *
5
- * Walks content directories, parses frontmatter, and reports:
6
- * - unknown tags (not in vocabulary; not an alias)
7
- * - deprecated tags (vocabulary entry has `deprecated`)
8
- * - near-duplicates (string-similarity or shared singular form)
9
- * - orphan vocab (vocab id referenced by nothing)
5
+ * Thin re-export shim the audit logic lives in @takazudo/zudo-doc/tags-audit
6
+ * (S9b #2334). This file exists for backward-compat with any project-side
7
+ * imports of these functions (e.g. custom scripts that import helpers from here).
10
8
  *
11
- * Flags:
12
- * --fix rewrite alias tags to their canonical id, byte-stable for the
13
- * rest of the file. Never touches unknown tags.
14
- * --ci force non-zero exit on any hard issue regardless of governance
15
- * --json emit the report as JSON instead of colorized text
9
+ * The `tags:audit` package.json script does not invoke this file directly;
10
+ * it uses the `tags-audit` package bin from @takazudo/zudo-doc instead.
11
+ *
12
+ * CLI usage (still works via tsx for debugging):
13
+ * tsx scripts/tags-audit.ts [--fix] [--ci] [--json]
16
14
  */
17
15
 
18
- import { readFile, readdir, writeFile } from "node:fs/promises";
19
- import { existsSync } from "node:fs";
20
- import { dirname, join, relative, resolve } from "node:path";
16
+ import { join, resolve } from "node:path";
21
17
  import { fileURLToPath } from "node:url";
22
18
 
23
- import matter from "gray-matter";
24
19
  import pc from "picocolors";
25
20
  import pluralize from "pluralize";
26
21
  import stringSimilarity from "string-similarity";
27
22
 
28
23
  import { settings } from "../src/config/settings";
29
24
  import { tagVocabulary } from "../src/config/tag-vocabulary";
30
- import type { TagVocabularyEntry } from "../src/config/tag-vocabulary-types";
31
-
32
- const __filename = fileURLToPath(import.meta.url);
33
- const __dirname = fileURLToPath(new URL(".", import.meta.url));
34
- const ROOT_DIR = resolve(__dirname, "..");
35
-
36
- /** Similarity threshold above which two distinct tags are flagged as near-duplicates. */
37
- const NEAR_DUP_THRESHOLD = 0.82;
38
-
39
- // ── Types ──────────────────────────────────────────────────────────────────
40
-
41
- interface VocabularyIndex {
42
- byId: Map<string, TagVocabularyEntry>;
43
- byAlias: Map<string, TagVocabularyEntry>;
44
- aliasToId: Map<string, string>;
45
- }
46
-
47
- interface FileTagRef {
48
- /** File path relative to repo root. */
49
- file: string;
50
- /** Raw tag as written in frontmatter. */
51
- raw: string;
52
- }
53
-
54
- interface UnknownIssue {
55
- file: string;
56
- raw: string;
57
- }
58
-
59
- interface DeprecatedIssue {
60
- file: string;
61
- raw: string;
62
- canonical: string;
63
- redirect?: string;
64
- }
65
-
66
- interface AliasIssue {
67
- file: string;
68
- raw: string;
69
- canonical: string;
70
- }
71
25
 
72
- interface NearDuplicatePair {
73
- a: string;
74
- b: string;
75
- reason: "similarity" | "plural";
76
- score?: number;
77
- }
78
-
79
- export interface AuditReport {
80
- unknowns: UnknownIssue[];
81
- deprecated: DeprecatedIssue[];
82
- aliases: AliasIssue[];
83
- nearDuplicates: NearDuplicatePair[];
84
- orphans: string[];
85
- filesScanned: number;
86
- /** Totals keyed by canonical id after alias/deprecation resolution. */
87
- frequency: Record<string, number>;
88
- }
89
-
90
- export interface AuditOptions {
91
- rootDir: string;
92
- contentDirs: string[];
93
- vocabulary: readonly TagVocabularyEntry[];
94
- governance: "off" | "warn" | "strict";
95
- vocabularyActive: boolean;
96
- }
97
-
98
- // ── Vocabulary indexing ────────────────────────────────────────────────────
99
-
100
- function buildIndex(vocab: readonly TagVocabularyEntry[]): VocabularyIndex {
101
- const byId = new Map<string, TagVocabularyEntry>();
102
- const byAlias = new Map<string, TagVocabularyEntry>();
103
- const aliasToId = new Map<string, string>();
104
- for (const entry of vocab) {
105
- byId.set(entry.id, entry);
106
- for (const alias of entry.aliases ?? []) {
107
- byAlias.set(alias, entry);
108
- aliasToId.set(alias, entry.id);
109
- }
110
- }
111
- return { byId, byAlias, aliasToId };
112
- }
113
-
114
- // ── File walking ───────────────────────────────────────────────────────────
115
-
116
- async function collectMdxFiles(dir: string): Promise<string[]> {
117
- const out: string[] = [];
118
- async function walk(current: string) {
119
- let entries;
120
- try {
121
- entries = await readdir(current, { withFileTypes: true });
122
- } catch {
123
- return;
124
- }
125
- for (const entry of entries) {
126
- const full = join(current, entry.name);
127
- if (entry.isDirectory()) {
128
- await walk(full);
129
- } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
130
- out.push(full);
131
- }
132
- }
133
- }
134
- await walk(dir);
135
- return out.sort();
136
- }
137
-
138
- // ── Core audit ─────────────────────────────────────────────────────────────
139
-
140
- function normalizeTags(value: unknown): string[] {
141
- if (!Array.isArray(value)) return [];
142
- return value.filter((v): v is string => typeof v === "string");
143
- }
144
-
145
- export async function audit(opts: AuditOptions): Promise<AuditReport> {
146
- const index = buildIndex(opts.vocabulary);
147
-
148
- const unknowns: UnknownIssue[] = [];
149
- const deprecated: DeprecatedIssue[] = [];
150
- const aliases: AliasIssue[] = [];
151
- const frequency: Record<string, number> = {};
152
- const canonicalsUsed = new Set<string>();
153
- const rawTagsUsed = new Set<string>();
154
- let filesScanned = 0;
155
-
156
- for (const dir of opts.contentDirs) {
157
- if (!existsSync(dir)) continue;
158
- const files = await collectMdxFiles(dir);
159
- for (const file of files) {
160
- filesScanned++;
161
- const raw = await readFile(file, "utf-8");
162
- const parsed = matter(raw);
163
- const tags = normalizeTags(parsed.data.tags);
164
- const rel = relative(opts.rootDir, file);
165
-
166
- for (const tag of tags) {
167
- rawTagsUsed.add(tag);
168
- const idEntry = index.byId.get(tag);
169
- const aliasEntry = index.byAlias.get(tag);
170
-
171
- if (!opts.vocabularyActive) {
172
- frequency[tag] = (frequency[tag] ?? 0) + 1;
173
- canonicalsUsed.add(tag);
174
- continue;
175
- }
176
-
177
- if (!idEntry && !aliasEntry) {
178
- unknowns.push({ file: rel, raw: tag });
179
- frequency[tag] = (frequency[tag] ?? 0) + 1;
180
- canonicalsUsed.add(tag);
181
- continue;
182
- }
183
-
184
- const entry = idEntry ?? aliasEntry!;
185
- let canonical = entry.id;
186
-
187
- const dep = entry.deprecated;
188
- if (dep) {
189
- if (typeof dep === "object" && dep.redirect) {
190
- const target = index.byId.get(dep.redirect);
191
- if (target) {
192
- deprecated.push({ file: rel, raw: tag, canonical: entry.id, redirect: target.id });
193
- canonical = target.id;
194
- } else {
195
- deprecated.push({ file: rel, raw: tag, canonical: entry.id });
196
- }
197
- } else {
198
- deprecated.push({ file: rel, raw: tag, canonical: entry.id });
199
- }
200
- } else if (aliasEntry && tag !== entry.id) {
201
- aliases.push({ file: rel, raw: tag, canonical: entry.id });
202
- }
203
-
204
- frequency[canonical] = (frequency[canonical] ?? 0) + 1;
205
- canonicalsUsed.add(canonical);
206
- }
207
- }
208
- }
209
-
210
- const nearDuplicates = opts.vocabularyActive
211
- ? []
212
- : findNearDuplicates(Array.from(rawTagsUsed));
213
- // When the vocabulary is active, near-duplicate detection runs over the
214
- // canonical set (post-alias) so we don't re-flag resolved aliases.
215
- const nearDupSource = opts.vocabularyActive
216
- ? Array.from(canonicalsUsed)
217
- : Array.from(rawTagsUsed);
218
- if (opts.vocabularyActive) {
219
- nearDuplicates.push(...findNearDuplicates(nearDupSource));
220
- }
221
-
222
- const orphans = opts.vocabularyActive
223
- ? opts.vocabulary
224
- .filter((entry) => {
225
- if (entry.deprecated) return false;
226
- if (canonicalsUsed.has(entry.id)) return false;
227
- for (const alias of entry.aliases ?? []) {
228
- if (rawTagsUsed.has(alias)) return false;
229
- }
230
- return true;
231
- })
232
- .map((entry) => entry.id)
233
- : [];
234
-
235
- return {
236
- unknowns,
237
- deprecated,
238
- aliases,
239
- nearDuplicates,
240
- orphans,
241
- filesScanned,
242
- frequency,
243
- };
244
- }
245
-
246
- export function findNearDuplicates(tags: string[]): NearDuplicatePair[] {
247
- const pairs: NearDuplicatePair[] = [];
248
- const seen = new Set<string>();
249
- const sorted = [...tags].sort();
250
- for (let i = 0; i < sorted.length; i++) {
251
- for (let j = i + 1; j < sorted.length; j++) {
252
- const a = sorted[i]!;
253
- const b = sorted[j]!;
254
- const key = `${a}||${b}`;
255
- if (seen.has(key)) continue;
256
- seen.add(key);
257
-
258
- const singA = pluralize.singular(a);
259
- const singB = pluralize.singular(b);
260
- if (singA === singB && a !== b) {
261
- pairs.push({ a, b, reason: "plural" });
262
- continue;
263
- }
264
- const score = stringSimilarity.compareTwoStrings(a, b);
265
- if (score >= NEAR_DUP_THRESHOLD) {
266
- pairs.push({ a, b, reason: "similarity", score });
267
- }
268
- }
269
- }
270
- return pairs;
26
+ // Re-export everything from the package core so existing callers continue
27
+ // to work unchanged.
28
+ export {
29
+ audit,
30
+ applyFixes,
31
+ buildIndex,
32
+ collectMdxFiles,
33
+ computeRewrites,
34
+ formatTextReport,
35
+ hasHardIssues,
36
+ normalizeTags,
37
+ rewriteAliasesByteStable,
38
+ } from "@takazudo/zudo-doc/tags-audit";
39
+ export type {
40
+ AliasIssue,
41
+ AuditOptions,
42
+ AuditReport,
43
+ DeprecatedIssue,
44
+ NearDuplicatePair,
45
+ NearDupHelpers,
46
+ TagVocabularyEntry,
47
+ UnknownIssue,
48
+ } from "@takazudo/zudo-doc/tags-audit";
49
+
50
+ // Backward-compat: expose findNearDuplicates with project helpers pre-bound.
51
+ import { findNearDuplicates as _findNearDuplicates } from "@takazudo/zudo-doc/tags-audit";
52
+ export function findNearDuplicates(
53
+ tags: string[],
54
+ ): import("@takazudo/zudo-doc/tags-audit").NearDuplicatePair[] {
55
+ return _findNearDuplicates(tags, {
56
+ singular: pluralize.singular,
57
+ compareTwoStrings: stringSimilarity.compareTwoStrings,
58
+ });
271
59
  }
272
60
 
273
- // ── --fix mode: byte-stable alias rewrite ──────────────────────────────────
274
-
275
- /**
276
- * Rewrite alias tags to canonical ids within a single MDX file, preserving
277
- * every other byte of the file verbatim. Returns `{ content, changed }`.
278
- *
279
- * Supports block-style (`tags:\n - foo\n`) and flow-style
280
- * (`tags: [foo, bar]`) YAML sequences. Quoted string values are handled.
281
- */
282
- export function rewriteAliasesByteStable(
283
- content: string,
284
- rewrites: Map<string, string>,
285
- ): { content: string; changed: boolean } {
286
- if (rewrites.size === 0) return { content, changed: false };
287
-
288
- const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/);
289
- if (!fmMatch) return { content, changed: false };
290
- const whole = fmMatch[0] ?? "";
291
- const open = fmMatch[1] ?? "";
292
- const fmBody = fmMatch[2] ?? "";
293
- const close = fmMatch[3] ?? "";
294
-
295
- let changed = false;
296
- let newBody = fmBody;
297
-
298
- // Flow-style: tags: [a, b, "c"]
299
- newBody = newBody.replace(
300
- /^(tags[ \t]*:[ \t]*\[)([^\]\n]*)(\])/m,
301
- (_full, pre, inner: string, post) => {
302
- const rewritten = inner.replace(
303
- /(^|,)([ \t]*)(?:(")([^"]*)(")|(')([^']*)(')|([^,\s"'][^,]*?))([ \t]*)(?=,|$)/g,
304
- (
305
- _m,
306
- lead,
307
- leadWs,
308
- dq1,
309
- dqVal,
310
- dq2,
311
- sq1,
312
- sqVal,
313
- sq2,
314
- bareVal,
315
- trailWs,
316
- ) => {
317
- const val: string = dqVal ?? sqVal ?? bareVal ?? "";
318
- const target = rewrites.get(val.trim());
319
- if (!target || target === val) return _m;
320
- changed = true;
321
- if (dq1) return `${lead}${leadWs}"${target}"${trailWs}`;
322
- if (sq1) return `${lead}${leadWs}'${target}'${trailWs}`;
323
- return `${lead}${leadWs}${target}${trailWs}`;
324
- },
325
- );
326
- return pre + rewritten + post;
327
- },
328
- );
61
+ // ── CLI runner (legacy package bin is preferred) ─────────────────────────
329
62
 
330
- // Block-style: tags:\n - a\n - b
331
- newBody = newBody.replace(
332
- /^(tags[ \t]*:[ \t]*\r?\n)((?:[ \t]+-[^\n]*\r?\n?)+)/m,
333
- (_full, pre, items: string) => {
334
- const rewritten = items.replace(
335
- /^([ \t]+-[ \t]+)(?:(")([^"]*)(")|(')([^']*)('))?([^\n]*)$/gm,
336
- (line, dash, dq1, dqVal, dq2, sq1, sqVal, sq2, bare) => {
337
- if (dq1) {
338
- const target = rewrites.get(dqVal);
339
- if (target && target !== dqVal) {
340
- changed = true;
341
- return `${dash}"${target}"${bare}`;
342
- }
343
- return line;
344
- }
345
- if (sq1) {
346
- const target = rewrites.get(sqVal);
347
- if (target && target !== sqVal) {
348
- changed = true;
349
- return `${dash}'${target}'${bare}`;
350
- }
351
- return line;
352
- }
353
- // Trailing whitespace includes \r for CRLF line endings — strip it
354
- // before the lookup, preserve it in the emitted line.
355
- const trimmed = bare.replace(/[ \t\r]+$/, "");
356
- const trailing = bare.slice(trimmed.length);
357
- const target = rewrites.get(trimmed);
358
- if (target && target !== trimmed) {
359
- changed = true;
360
- return `${dash}${target}${trailing}`;
361
- }
362
- return line;
363
- },
364
- );
365
- return pre + rewritten;
366
- },
367
- );
368
-
369
- if (!changed) return { content, changed: false };
370
- return {
371
- content: open + newBody + close + content.slice(whole.length),
372
- changed: true,
373
- };
374
- }
375
-
376
- // ── Runner + reporting ─────────────────────────────────────────────────────
63
+ const __filename = fileURLToPath(import.meta.url);
64
+ const isMain =
65
+ process.argv[1] !== undefined &&
66
+ resolve(process.argv[1]) === resolve(__filename);
377
67
 
378
- interface CliFlags {
379
- fix: boolean;
380
- ci: boolean;
381
- json: boolean;
382
- }
68
+ if (isMain) {
69
+ const { audit: coreAudit, computeRewrites: coreComputeRewrites, applyFixes: coreApplyFixes, hasHardIssues: coreHasHardIssues, formatTextReport: coreFormatReport } =
70
+ await import("@takazudo/zudo-doc/tags-audit");
383
71
 
384
- function parseArgs(argv: string[]): CliFlags {
385
- return {
72
+ const argv = process.argv.slice(2);
73
+ const flags = {
386
74
  fix: argv.includes("--fix"),
387
75
  ci: argv.includes("--ci"),
388
76
  json: argv.includes("--json"),
389
77
  };
390
- }
391
-
392
- function formatTextReport(
393
- report: AuditReport,
394
- governance: AuditOptions["governance"],
395
- vocabularyActive: boolean,
396
- ): string {
397
- const lines: string[] = [];
398
- lines.push(pc.bold(pc.cyan(`tags:audit — scanned ${report.filesScanned} file(s)`)));
399
- lines.push(
400
- pc.dim(
401
- `vocabulary: ${vocabularyActive ? "active" : "inactive"}, governance: ${governance}`,
402
- ),
403
- );
404
- lines.push("");
405
78
 
406
- if (report.unknowns.length > 0) {
407
- lines.push(pc.bold(pc.red(`✗ Unknown tags (${report.unknowns.length})`)));
408
- for (const { file, raw } of report.unknowns) {
409
- lines.push(` ${pc.red("•")} ${file} ${pc.yellow(raw)}`);
410
- }
411
- lines.push("");
412
- }
413
-
414
- if (report.deprecated.length > 0) {
415
- lines.push(pc.bold(pc.yellow(`⚠ Deprecated tags (${report.deprecated.length})`)));
416
- for (const { file, raw, redirect } of report.deprecated) {
417
- const suffix = redirect ? pc.dim(` → ${redirect}`) : pc.dim(" (dropped)");
418
- lines.push(` ${pc.yellow("•")} ${file} ${raw}${suffix}`);
419
- }
420
- lines.push("");
421
- }
422
-
423
- if (report.aliases.length > 0) {
424
- lines.push(pc.bold(pc.blue(`ℹ Alias usage (${report.aliases.length})`)));
425
- for (const { file, raw, canonical } of report.aliases) {
426
- lines.push(` ${pc.blue("•")} ${file} ${raw} ${pc.dim(`→ ${canonical}`)}`);
427
- }
428
- lines.push(pc.dim(` (run with --fix to rewrite these in place)`));
429
- lines.push("");
430
- }
431
-
432
- if (report.nearDuplicates.length > 0) {
433
- lines.push(pc.bold(pc.yellow(`⚠ Near-duplicate tags (${report.nearDuplicates.length})`)));
434
- for (const pair of report.nearDuplicates) {
435
- const hint =
436
- pair.reason === "plural"
437
- ? "same singular"
438
- : `similarity ${(pair.score ?? 0).toFixed(2)}`;
439
- lines.push(` ${pc.yellow("•")} ${pair.a} ↔ ${pair.b} ${pc.dim(`(${hint})`)}`);
440
- }
441
- lines.push("");
442
- }
443
-
444
- if (report.orphans.length > 0) {
445
- lines.push(pc.bold(pc.dim(`… Orphan vocabulary entries (${report.orphans.length})`)));
446
- for (const id of report.orphans) {
447
- lines.push(` ${pc.dim("•")} ${id}`);
448
- }
449
- lines.push("");
450
- }
451
-
452
- if (
453
- report.unknowns.length === 0 &&
454
- report.deprecated.length === 0 &&
455
- report.aliases.length === 0 &&
456
- report.nearDuplicates.length === 0 &&
457
- report.orphans.length === 0
458
- ) {
459
- lines.push(pc.green("✓ No tag issues found"));
460
- }
461
-
462
- return lines.join("\n");
463
- }
464
-
465
- export function hasHardIssues(report: AuditReport): boolean {
466
- return report.unknowns.length > 0 || report.deprecated.length > 0;
467
- }
468
-
469
- export function computeRewrites(
470
- vocabulary: readonly TagVocabularyEntry[],
471
- ): Map<string, string> {
472
- const rewrites = new Map<string, string>();
473
- const { byId } = buildIndex(vocabulary);
474
- for (const entry of vocabulary) {
475
- for (const alias of entry.aliases ?? []) {
476
- if (alias === entry.id) continue;
477
- const dep = entry.deprecated;
478
- if (dep && typeof dep === "object" && dep.redirect) {
479
- const target = byId.get(dep.redirect);
480
- rewrites.set(alias, target ? target.id : entry.id);
481
- } else {
482
- rewrites.set(alias, entry.id);
483
- }
484
- }
485
- // A deprecated entry with a redirect: rewrite the id itself.
486
- const dep = entry.deprecated;
487
- if (dep && typeof dep === "object" && dep.redirect) {
488
- const target = byId.get(dep.redirect);
489
- if (target) rewrites.set(entry.id, target.id);
490
- }
491
- }
492
- return rewrites;
493
- }
494
-
495
- async function applyFixes(
496
- contentDirs: string[],
497
- rewrites: Map<string, string>,
498
- rootDir: string,
499
- ): Promise<string[]> {
500
- const touched: string[] = [];
501
- for (const dir of contentDirs) {
502
- if (!existsSync(dir)) continue;
503
- const files = await collectMdxFiles(dir);
504
- for (const file of files) {
505
- const original = await readFile(file, "utf-8");
506
- const { content, changed } = rewriteAliasesByteStable(original, rewrites);
507
- if (changed) {
508
- await writeFile(file, content, "utf-8");
509
- touched.push(relative(rootDir, file));
510
- }
511
- }
512
- }
513
- return touched;
514
- }
515
-
516
- async function main() {
517
- const flags = parseArgs(process.argv.slice(2));
518
- const rootDir = ROOT_DIR;
519
-
520
- const docsDir = join(rootDir, settings.docsDir);
79
+ const ROOT_DIR = resolve(__filename, "..", "..");
80
+ const docsDir = join(ROOT_DIR, settings.docsDir);
521
81
  const localeDirs = Object.values(settings.locales ?? {}).map((l) =>
522
- join(rootDir, l.dir),
82
+ join(ROOT_DIR, l.dir),
523
83
  );
524
84
  const contentDirs = [docsDir, ...localeDirs];
525
-
526
85
  const vocabularyActive =
527
86
  Boolean(settings.tagVocabulary) && settings.tagGovernance !== "off";
528
87
 
529
88
  if (flags.fix) {
530
- const rewrites = computeRewrites(tagVocabulary);
531
- const touched = await applyFixes(contentDirs, rewrites, rootDir);
89
+ const rewrites = coreComputeRewrites(tagVocabulary);
90
+ const touched = await coreApplyFixes(contentDirs, rewrites, ROOT_DIR);
532
91
  if (flags.json) {
533
92
  process.stdout.write(JSON.stringify({ fixed: touched }, null, 2) + "\n");
534
93
  } else if (touched.length === 0) {
@@ -537,29 +96,32 @@ async function main() {
537
96
  console.log(pc.green(`✓ Rewrote aliases in ${touched.length} file(s):`));
538
97
  for (const f of touched) console.log(` ${f}`);
539
98
  }
540
- return;
99
+ process.exit(0);
541
100
  }
542
101
 
543
- const report = await audit({
544
- rootDir,
102
+ const report = await coreAudit({
103
+ rootDir: ROOT_DIR,
545
104
  contentDirs,
546
105
  vocabulary: tagVocabulary,
547
106
  governance: settings.tagGovernance,
548
107
  vocabularyActive,
108
+ nearDupHelpers: {
109
+ singular: pluralize.singular,
110
+ compareTwoStrings: stringSimilarity.compareTwoStrings,
111
+ },
549
112
  });
550
113
 
551
114
  if (flags.json) {
552
115
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
553
116
  } else {
554
- console.log(formatTextReport(report, settings.tagGovernance, vocabularyActive));
117
+ console.log(coreFormatReport(report, settings.tagGovernance, vocabularyActive, pc));
555
118
  }
556
119
 
557
- const hardIssues = hasHardIssues(report);
120
+ const hardIssues = coreHasHardIssues(report);
558
121
  if (hardIssues && (flags.ci || settings.tagGovernance === "strict")) {
559
122
  process.exit(1);
560
123
  }
561
124
  if (hardIssues) {
562
- // Soft mode: surface to stderr so CI users see it in logs while exit 0.
563
125
  console.error(
564
126
  pc.yellow(
565
127
  "Note: tag issues found but running in non-strict mode (exit 0). Use --ci to fail.",
@@ -567,13 +129,3 @@ async function main() {
567
129
  );
568
130
  }
569
131
  }
570
-
571
- const isMain =
572
- process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(__filename);
573
-
574
- if (isMain) {
575
- main().catch((err) => {
576
- console.error(err);
577
- process.exit(1);
578
- });
579
- }