create-zudo-doc 0.1.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.
Files changed (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/create-zudo-doc.js +2 -0
  4. package/dist/api.d.ts +20 -0
  5. package/dist/api.js +13 -0
  6. package/dist/claude-md-gen.d.ts +2 -0
  7. package/dist/claude-md-gen.js +113 -0
  8. package/dist/cli.d.ts +39 -0
  9. package/dist/cli.js +157 -0
  10. package/dist/compose.d.ts +95 -0
  11. package/dist/compose.js +206 -0
  12. package/dist/constants.d.ts +20 -0
  13. package/dist/constants.js +224 -0
  14. package/dist/features/body-foot-util.d.ts +10 -0
  15. package/dist/features/body-foot-util.js +12 -0
  16. package/dist/features/claude-resources.d.ts +2 -0
  17. package/dist/features/claude-resources.js +6 -0
  18. package/dist/features/design-token-panel.d.ts +14 -0
  19. package/dist/features/design-token-panel.js +27 -0
  20. package/dist/features/doc-history.d.ts +9 -0
  21. package/dist/features/doc-history.js +11 -0
  22. package/dist/features/doc-tags.d.ts +19 -0
  23. package/dist/features/doc-tags.js +33 -0
  24. package/dist/features/footer-taglist.d.ts +14 -0
  25. package/dist/features/footer-taglist.js +17 -0
  26. package/dist/features/footer.d.ts +8 -0
  27. package/dist/features/footer.js +10 -0
  28. package/dist/features/i18n.d.ts +22 -0
  29. package/dist/features/i18n.js +41 -0
  30. package/dist/features/image-enlarge.d.ts +11 -0
  31. package/dist/features/image-enlarge.js +13 -0
  32. package/dist/features/index.d.ts +15 -0
  33. package/dist/features/index.js +53 -0
  34. package/dist/features/llms-txt.d.ts +11 -0
  35. package/dist/features/llms-txt.js +13 -0
  36. package/dist/features/search.d.ts +9 -0
  37. package/dist/features/search.js +11 -0
  38. package/dist/features/sidebar-resizer.d.ts +14 -0
  39. package/dist/features/sidebar-resizer.js +16 -0
  40. package/dist/features/sidebar-toggle.d.ts +13 -0
  41. package/dist/features/sidebar-toggle.js +15 -0
  42. package/dist/features/tag-governance.d.ts +14 -0
  43. package/dist/features/tag-governance.js +16 -0
  44. package/dist/features/tauri-dev.d.ts +2 -0
  45. package/dist/features/tauri-dev.js +25 -0
  46. package/dist/features/tauri.d.ts +11 -0
  47. package/dist/features/tauri.js +52 -0
  48. package/dist/features/versioning.d.ts +27 -0
  49. package/dist/features/versioning.js +43 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +150 -0
  52. package/dist/preset.d.ts +37 -0
  53. package/dist/preset.js +156 -0
  54. package/dist/prompts.d.ts +32 -0
  55. package/dist/prompts.js +248 -0
  56. package/dist/scaffold.d.ts +4 -0
  57. package/dist/scaffold.js +344 -0
  58. package/dist/settings-gen.d.ts +2 -0
  59. package/dist/settings-gen.js +237 -0
  60. package/dist/utils.d.ts +8 -0
  61. package/dist/utils.js +34 -0
  62. package/dist/zfb-config-gen.d.ts +19 -0
  63. package/dist/zfb-config-gen.js +222 -0
  64. package/package.json +65 -0
  65. package/templates/base/.htmlvalidate.json +5 -0
  66. package/templates/base/.zfb/doc-history-meta.json +1 -0
  67. package/templates/base/pages/404.tsx +55 -0
  68. package/templates/base/pages/_data.ts +179 -0
  69. package/templates/base/pages/_mdx-components.ts +249 -0
  70. package/templates/base/pages/docs/[...slug].tsx +448 -0
  71. package/templates/base/pages/index.tsx +158 -0
  72. package/templates/base/pages/lib/_body-end-islands.tsx +201 -0
  73. package/templates/base/pages/lib/_category-nav.tsx +148 -0
  74. package/templates/base/pages/lib/_category-tree-nav.tsx +104 -0
  75. package/templates/base/pages/lib/_compose-meta-title.ts +29 -0
  76. package/templates/base/pages/lib/_details.tsx +30 -0
  77. package/templates/base/pages/lib/_doc-history-area.tsx +178 -0
  78. package/templates/base/pages/lib/_doc-metainfo-area.tsx +100 -0
  79. package/templates/base/pages/lib/_doc-tags-area.tsx +89 -0
  80. package/templates/base/pages/lib/_extract-headings.ts +81 -0
  81. package/templates/base/pages/lib/_footer-with-defaults.tsx +234 -0
  82. package/templates/base/pages/lib/_frontmatter-preview-data.ts +53 -0
  83. package/templates/base/pages/lib/_head-with-defaults.tsx +113 -0
  84. package/templates/base/pages/lib/_header-with-defaults.tsx +386 -0
  85. package/templates/base/pages/lib/_inline-version-switcher.tsx +84 -0
  86. package/templates/base/pages/lib/_math-block.tsx +63 -0
  87. package/templates/base/pages/lib/_nav-source-docs.ts +68 -0
  88. package/templates/base/pages/lib/_preset-generator.tsx +81 -0
  89. package/templates/base/pages/lib/_search-widget-script.ts +388 -0
  90. package/templates/base/pages/lib/_search-widget.tsx +196 -0
  91. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +176 -0
  92. package/templates/base/pages/lib/_site-tree-nav.tsx +128 -0
  93. package/templates/base/pages/lib/locale-merge.ts +58 -0
  94. package/templates/base/pages/lib/route-enumerators.ts +302 -0
  95. package/templates/base/pages/sitemap.xml.tsx +51 -0
  96. package/templates/base/plugins/connect-adapter.mjs +144 -0
  97. package/templates/base/plugins/copy-public-plugin.mjs +50 -0
  98. package/templates/base/plugins/search-index-plugin.mjs +54 -0
  99. package/templates/base/scripts/run-b4push.sh +102 -0
  100. package/templates/base/src/components/ai-chat-modal.tsx +15 -0
  101. package/templates/base/src/components/client-router-bootstrap.tsx +14 -0
  102. package/templates/base/src/components/content/component-map.ts +25 -0
  103. package/templates/base/src/components/content/content-blockquote.tsx +16 -0
  104. package/templates/base/src/components/content/content-code.tsx +117 -0
  105. package/templates/base/src/components/content/content-link.tsx +83 -0
  106. package/templates/base/src/components/content/content-ol.tsx +19 -0
  107. package/templates/base/src/components/content/content-paragraph.tsx +10 -0
  108. package/templates/base/src/components/content/content-strong.tsx +16 -0
  109. package/templates/base/src/components/content/content-table.tsx +18 -0
  110. package/templates/base/src/components/content/content-ul.tsx +18 -0
  111. package/templates/base/src/components/content/heading-h2.tsx +26 -0
  112. package/templates/base/src/components/content/heading-h3.tsx +26 -0
  113. package/templates/base/src/components/content/heading-h4.tsx +26 -0
  114. package/templates/base/src/components/design-token-panel-bootstrap.tsx +15 -0
  115. package/templates/base/src/components/desktop-sidebar-toggle.tsx +15 -0
  116. package/templates/base/src/components/doc-history.tsx +18 -0
  117. package/templates/base/src/components/html-preview/highlighted-code.tsx +74 -0
  118. package/templates/base/src/components/html-preview/html-preview.tsx +108 -0
  119. package/templates/base/src/components/html-preview/preflight.ts +112 -0
  120. package/templates/base/src/components/html-preview/preview-base.tsx +159 -0
  121. package/templates/base/src/components/image-enlarge.tsx +19 -0
  122. package/templates/base/src/components/mobile-toc.tsx +94 -0
  123. package/templates/base/src/components/preset-generator.tsx +14 -0
  124. package/templates/base/src/components/sidebar-toggle.tsx +98 -0
  125. package/templates/base/src/components/sidebar-tree.tsx +543 -0
  126. package/templates/base/src/components/site-tree-nav.tsx +233 -0
  127. package/templates/base/src/components/theme-toggle.tsx +93 -0
  128. package/templates/base/src/components/toc.tsx +63 -0
  129. package/templates/base/src/components/tree-nav-shared.tsx +71 -0
  130. package/templates/base/src/config/color-scheme-utils.ts +182 -0
  131. package/templates/base/src/config/color-schemes.ts +128 -0
  132. package/templates/base/src/config/frontmatter-preview-defaults.ts +24 -0
  133. package/templates/base/src/config/frontmatter-preview-renderers.tsx +46 -0
  134. package/templates/base/src/config/i18n.ts +225 -0
  135. package/templates/base/src/config/settings-types.ts +162 -0
  136. package/templates/base/src/config/sidebars.ts +66 -0
  137. package/templates/base/src/config/tag-vocabulary-types.ts +39 -0
  138. package/templates/base/src/config/tag-vocabulary.ts +20 -0
  139. package/templates/base/src/hooks/use-active-heading.ts +133 -0
  140. package/templates/base/src/plugins/docs-source-map.ts +103 -0
  141. package/templates/base/src/plugins/hast-utils.ts +10 -0
  142. package/templates/base/src/plugins/rehype-code-title.ts +50 -0
  143. package/templates/base/src/plugins/rehype-heading-links.ts +53 -0
  144. package/templates/base/src/plugins/rehype-image-enlarge.ts +113 -0
  145. package/templates/base/src/plugins/rehype-mermaid.ts +41 -0
  146. package/templates/base/src/plugins/rehype-strip-md-extension.ts +58 -0
  147. package/templates/base/src/plugins/remark-admonitions.ts +99 -0
  148. package/templates/base/src/plugins/remark-resolve-markdown-links.ts +127 -0
  149. package/templates/base/src/plugins/url-utils.ts +4 -0
  150. package/templates/base/src/styles/global.css +1066 -0
  151. package/templates/base/src/types/docs-entry.ts +39 -0
  152. package/templates/base/src/types/heading.ts +5 -0
  153. package/templates/base/src/types/locale.ts +10 -0
  154. package/templates/base/src/utils/base.ts +139 -0
  155. package/templates/base/src/utils/content-files.ts +106 -0
  156. package/templates/base/src/utils/dedent.ts +24 -0
  157. package/templates/base/src/utils/docs.ts +335 -0
  158. package/templates/base/src/utils/git-info.ts +70 -0
  159. package/templates/base/src/utils/github.ts +19 -0
  160. package/templates/base/src/utils/header-right-items.ts +38 -0
  161. package/templates/base/src/utils/nav-scope.ts +63 -0
  162. package/templates/base/src/utils/sidebar.ts +104 -0
  163. package/templates/base/src/utils/slug.ts +10 -0
  164. package/templates/base/src/utils/smart-break.tsx +126 -0
  165. package/templates/base/src/utils/tags.ts +126 -0
  166. package/templates/base/tsconfig.json +36 -0
  167. package/templates/features/bodyFootUtil/files/src/utils/github.ts +19 -0
  168. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +137 -0
  169. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/escape-for-mdx.test.ts +34 -0
  170. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +376 -0
  171. package/templates/features/claudeResources/files/src/integrations/claude-resources/escape-for-mdx.ts +93 -0
  172. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +586 -0
  173. package/templates/features/designTokenPanel/files/src/components/design-token-panel-bootstrap.tsx +15 -0
  174. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +99 -0
  175. package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +177 -0
  176. package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +50 -0
  177. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +99 -0
  178. package/templates/features/docHistory/files/src/components/doc-history.tsx +598 -0
  179. package/templates/features/docHistory/files/src/types/doc-history.ts +23 -0
  180. package/templates/features/docHistory/files/src/utils/doc-history.ts +180 -0
  181. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +116 -0
  182. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +99 -0
  183. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +101 -0
  184. package/templates/features/docTags/files/pages/docs/tags/index.tsx +86 -0
  185. package/templates/features/i18n/files/pages/[locale]/docs/[...slug].tsx +467 -0
  186. package/templates/features/i18n/files/pages/[locale]/index.tsx +213 -0
  187. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +248 -0
  188. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +74 -0
  189. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +185 -0
  190. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +126 -0
  191. package/templates/features/tagGovernance/files/scripts/tags-audit.ts +576 -0
  192. package/templates/features/tagGovernance/files/scripts/tags-suggest.ts +428 -0
  193. package/templates/features/tauri/files/src/components/find-bar.tsx +122 -0
  194. package/templates/features/tauri/files/src/components/find-in-page-init.tsx +53 -0
  195. package/templates/features/tauri/files/src/utils/find-in-page.ts +175 -0
  196. package/templates/features/tauri/files/src-tauri/Cargo.toml +14 -0
  197. package/templates/features/tauri/files/src-tauri/build.rs +3 -0
  198. package/templates/features/tauri/files/src-tauri/capabilities/default.json +11 -0
  199. package/templates/features/tauri/files/src-tauri/src/main.rs +250 -0
  200. package/templates/features/tauri/files/src-tauri/tauri.conf.json +25 -0
  201. package/templates/features/tauriDev/files/src-tauri-dev/Cargo.toml +15 -0
  202. package/templates/features/tauriDev/files/src-tauri-dev/build.rs +3 -0
  203. package/templates/features/tauriDev/files/src-tauri-dev/capabilities/default.json +7 -0
  204. package/templates/features/tauriDev/files/src-tauri-dev/frontend/index.html +187 -0
  205. package/templates/features/tauriDev/files/src-tauri-dev/icons/icon.png +0 -0
  206. package/templates/features/tauriDev/files/src-tauri-dev/src/main.rs +995 -0
  207. package/templates/features/tauriDev/files/src-tauri-dev/tauri.conf.json +22 -0
  208. package/templates/features/tauriDev/files/src-tauri-dev/test-launch.sh +65 -0
  209. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +100 -0
  210. package/templates/features/versioning/files/pages/docs/versions.tsx +78 -0
  211. package/templates/features/versioning/files/pages/v/[version]/docs/[...slug].tsx +451 -0
  212. package/templates/features/versioning/files/pages/v/[version]/ja/docs/[...slug].tsx +490 -0
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env -S tsx
2
+ /**
3
+ * tags-audit.ts — vocabulary-aware audit of frontmatter tags.
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)
10
+ *
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
16
+ */
17
+
18
+ import { readFile, readdir, writeFile } from "node:fs/promises";
19
+ import { existsSync } from "node:fs";
20
+ import { dirname, join, relative, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ import matter from "gray-matter";
24
+ import pc from "picocolors";
25
+ import pluralize from "pluralize";
26
+ import stringSimilarity from "string-similarity";
27
+
28
+ import { settings } from "../src/config/settings";
29
+ 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
+
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;
271
+ }
272
+
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, open, fmBody, close] = fmMatch;
291
+
292
+ let changed = false;
293
+ let newBody = fmBody;
294
+
295
+ // Flow-style: tags: [a, b, "c"]
296
+ newBody = newBody.replace(
297
+ /^(tags[ \t]*:[ \t]*\[)([^\]\n]*)(\])/m,
298
+ (_full, pre, inner: string, post) => {
299
+ const rewritten = inner.replace(
300
+ /(^|,)([ \t]*)(?:(")([^"]*)(")|(')([^']*)(')|([^,\s"'][^,]*?))([ \t]*)(?=,|$)/g,
301
+ (
302
+ _m,
303
+ lead,
304
+ leadWs,
305
+ dq1,
306
+ dqVal,
307
+ dq2,
308
+ sq1,
309
+ sqVal,
310
+ sq2,
311
+ bareVal,
312
+ trailWs,
313
+ ) => {
314
+ const val: string = dqVal ?? sqVal ?? bareVal ?? "";
315
+ const target = rewrites.get(val.trim());
316
+ if (!target || target === val) return _m;
317
+ changed = true;
318
+ if (dq1) return `${lead}${leadWs}"${target}"${trailWs}`;
319
+ if (sq1) return `${lead}${leadWs}'${target}'${trailWs}`;
320
+ return `${lead}${leadWs}${target}${trailWs}`;
321
+ },
322
+ );
323
+ return pre + rewritten + post;
324
+ },
325
+ );
326
+
327
+ // Block-style: tags:\n - a\n - b
328
+ newBody = newBody.replace(
329
+ /^(tags[ \t]*:[ \t]*\r?\n)((?:[ \t]+-[^\n]*\r?\n?)+)/m,
330
+ (_full, pre, items: string) => {
331
+ const rewritten = items.replace(
332
+ /^([ \t]+-[ \t]+)(?:(")([^"]*)(")|(')([^']*)('))?([^\n]*)$/gm,
333
+ (line, dash, dq1, dqVal, dq2, sq1, sqVal, sq2, bare) => {
334
+ if (dq1) {
335
+ const target = rewrites.get(dqVal);
336
+ if (target && target !== dqVal) {
337
+ changed = true;
338
+ return `${dash}"${target}"${bare}`;
339
+ }
340
+ return line;
341
+ }
342
+ if (sq1) {
343
+ const target = rewrites.get(sqVal);
344
+ if (target && target !== sqVal) {
345
+ changed = true;
346
+ return `${dash}'${target}'${bare}`;
347
+ }
348
+ return line;
349
+ }
350
+ // Trailing whitespace includes \r for CRLF line endings — strip it
351
+ // before the lookup, preserve it in the emitted line.
352
+ const trimmed = bare.replace(/[ \t\r]+$/, "");
353
+ const trailing = bare.slice(trimmed.length);
354
+ const target = rewrites.get(trimmed);
355
+ if (target && target !== trimmed) {
356
+ changed = true;
357
+ return `${dash}${target}${trailing}`;
358
+ }
359
+ return line;
360
+ },
361
+ );
362
+ return pre + rewritten;
363
+ },
364
+ );
365
+
366
+ if (!changed) return { content, changed: false };
367
+ return {
368
+ content: open + newBody + close + content.slice(whole.length),
369
+ changed: true,
370
+ };
371
+ }
372
+
373
+ // ── Runner + reporting ─────────────────────────────────────────────────────
374
+
375
+ interface CliFlags {
376
+ fix: boolean;
377
+ ci: boolean;
378
+ json: boolean;
379
+ }
380
+
381
+ function parseArgs(argv: string[]): CliFlags {
382
+ return {
383
+ fix: argv.includes("--fix"),
384
+ ci: argv.includes("--ci"),
385
+ json: argv.includes("--json"),
386
+ };
387
+ }
388
+
389
+ function formatTextReport(
390
+ report: AuditReport,
391
+ governance: AuditOptions["governance"],
392
+ vocabularyActive: boolean,
393
+ ): string {
394
+ const lines: string[] = [];
395
+ lines.push(pc.bold(pc.cyan(`tags:audit — scanned ${report.filesScanned} file(s)`)));
396
+ lines.push(
397
+ pc.dim(
398
+ `vocabulary: ${vocabularyActive ? "active" : "inactive"}, governance: ${governance}`,
399
+ ),
400
+ );
401
+ lines.push("");
402
+
403
+ if (report.unknowns.length > 0) {
404
+ lines.push(pc.bold(pc.red(`✗ Unknown tags (${report.unknowns.length})`)));
405
+ for (const { file, raw } of report.unknowns) {
406
+ lines.push(` ${pc.red("•")} ${file} ${pc.yellow(raw)}`);
407
+ }
408
+ lines.push("");
409
+ }
410
+
411
+ if (report.deprecated.length > 0) {
412
+ lines.push(pc.bold(pc.yellow(`⚠ Deprecated tags (${report.deprecated.length})`)));
413
+ for (const { file, raw, redirect } of report.deprecated) {
414
+ const suffix = redirect ? pc.dim(` → ${redirect}`) : pc.dim(" (dropped)");
415
+ lines.push(` ${pc.yellow("•")} ${file} ${raw}${suffix}`);
416
+ }
417
+ lines.push("");
418
+ }
419
+
420
+ if (report.aliases.length > 0) {
421
+ lines.push(pc.bold(pc.blue(`ℹ Alias usage (${report.aliases.length})`)));
422
+ for (const { file, raw, canonical } of report.aliases) {
423
+ lines.push(` ${pc.blue("•")} ${file} ${raw} ${pc.dim(`→ ${canonical}`)}`);
424
+ }
425
+ lines.push(pc.dim(` (run with --fix to rewrite these in place)`));
426
+ lines.push("");
427
+ }
428
+
429
+ if (report.nearDuplicates.length > 0) {
430
+ lines.push(pc.bold(pc.yellow(`⚠ Near-duplicate tags (${report.nearDuplicates.length})`)));
431
+ for (const pair of report.nearDuplicates) {
432
+ const hint =
433
+ pair.reason === "plural"
434
+ ? "same singular"
435
+ : `similarity ${(pair.score ?? 0).toFixed(2)}`;
436
+ lines.push(` ${pc.yellow("•")} ${pair.a} ↔ ${pair.b} ${pc.dim(`(${hint})`)}`);
437
+ }
438
+ lines.push("");
439
+ }
440
+
441
+ if (report.orphans.length > 0) {
442
+ lines.push(pc.bold(pc.dim(`… Orphan vocabulary entries (${report.orphans.length})`)));
443
+ for (const id of report.orphans) {
444
+ lines.push(` ${pc.dim("•")} ${id}`);
445
+ }
446
+ lines.push("");
447
+ }
448
+
449
+ if (
450
+ report.unknowns.length === 0 &&
451
+ report.deprecated.length === 0 &&
452
+ report.aliases.length === 0 &&
453
+ report.nearDuplicates.length === 0 &&
454
+ report.orphans.length === 0
455
+ ) {
456
+ lines.push(pc.green("✓ No tag issues found"));
457
+ }
458
+
459
+ return lines.join("\n");
460
+ }
461
+
462
+ export function hasHardIssues(report: AuditReport): boolean {
463
+ return report.unknowns.length > 0 || report.deprecated.length > 0;
464
+ }
465
+
466
+ export function computeRewrites(
467
+ vocabulary: readonly TagVocabularyEntry[],
468
+ ): Map<string, string> {
469
+ const rewrites = new Map<string, string>();
470
+ const { byId } = buildIndex(vocabulary);
471
+ for (const entry of vocabulary) {
472
+ for (const alias of entry.aliases ?? []) {
473
+ if (alias === entry.id) continue;
474
+ const dep = entry.deprecated;
475
+ if (dep && typeof dep === "object" && dep.redirect) {
476
+ const target = byId.get(dep.redirect);
477
+ rewrites.set(alias, target ? target.id : entry.id);
478
+ } else {
479
+ rewrites.set(alias, entry.id);
480
+ }
481
+ }
482
+ // A deprecated entry with a redirect: rewrite the id itself.
483
+ const dep = entry.deprecated;
484
+ if (dep && typeof dep === "object" && dep.redirect) {
485
+ const target = byId.get(dep.redirect);
486
+ if (target) rewrites.set(entry.id, target.id);
487
+ }
488
+ }
489
+ return rewrites;
490
+ }
491
+
492
+ async function applyFixes(
493
+ contentDirs: string[],
494
+ rewrites: Map<string, string>,
495
+ rootDir: string,
496
+ ): Promise<string[]> {
497
+ const touched: string[] = [];
498
+ for (const dir of contentDirs) {
499
+ if (!existsSync(dir)) continue;
500
+ const files = await collectMdxFiles(dir);
501
+ for (const file of files) {
502
+ const original = await readFile(file, "utf-8");
503
+ const { content, changed } = rewriteAliasesByteStable(original, rewrites);
504
+ if (changed) {
505
+ await writeFile(file, content, "utf-8");
506
+ touched.push(relative(rootDir, file));
507
+ }
508
+ }
509
+ }
510
+ return touched;
511
+ }
512
+
513
+ async function main() {
514
+ const flags = parseArgs(process.argv.slice(2));
515
+ const rootDir = ROOT_DIR;
516
+
517
+ const docsDir = join(rootDir, settings.docsDir);
518
+ const localeDirs = Object.values(settings.locales ?? {}).map((l) =>
519
+ join(rootDir, l.dir),
520
+ );
521
+ const contentDirs = [docsDir, ...localeDirs];
522
+
523
+ const vocabularyActive =
524
+ Boolean(settings.tagVocabulary) && settings.tagGovernance !== "off";
525
+
526
+ if (flags.fix) {
527
+ const rewrites = computeRewrites(tagVocabulary);
528
+ const touched = await applyFixes(contentDirs, rewrites, rootDir);
529
+ if (flags.json) {
530
+ process.stdout.write(JSON.stringify({ fixed: touched }, null, 2) + "\n");
531
+ } else if (touched.length === 0) {
532
+ console.log(pc.green("✓ No alias rewrites needed"));
533
+ } else {
534
+ console.log(pc.green(`✓ Rewrote aliases in ${touched.length} file(s):`));
535
+ for (const f of touched) console.log(` ${f}`);
536
+ }
537
+ return;
538
+ }
539
+
540
+ const report = await audit({
541
+ rootDir,
542
+ contentDirs,
543
+ vocabulary: tagVocabulary,
544
+ governance: settings.tagGovernance,
545
+ vocabularyActive,
546
+ });
547
+
548
+ if (flags.json) {
549
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
550
+ } else {
551
+ console.log(formatTextReport(report, settings.tagGovernance, vocabularyActive));
552
+ }
553
+
554
+ const hardIssues = hasHardIssues(report);
555
+ if (hardIssues && (flags.ci || settings.tagGovernance === "strict")) {
556
+ process.exit(1);
557
+ }
558
+ if (hardIssues) {
559
+ // Soft mode: surface to stderr so CI users see it in logs while exit 0.
560
+ console.error(
561
+ pc.yellow(
562
+ "Note: tag issues found but running in non-strict mode (exit 0). Use --ci to fail.",
563
+ ),
564
+ );
565
+ }
566
+ }
567
+
568
+ const isMain =
569
+ process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(__filename);
570
+
571
+ if (isMain) {
572
+ main().catch((err) => {
573
+ console.error(err);
574
+ process.exit(1);
575
+ });
576
+ }