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,428 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * tags:suggest — optional local-LLM tag suggester.
4
+ *
5
+ * Reads one or more doc files, asks a local Ollama instance for up to 3 tag
6
+ * ids from the project vocabulary, and either opens an interactive approval
7
+ * prompt (default) or appends suggestions to `.tag-suggestions.jsonl`
8
+ * (`--batch`, also used automatically when stdout is not a TTY).
9
+ *
10
+ * Entirely developer-opt-in. Never runs in CI; never wired into b4push.
11
+ */
12
+ import { appendFile, readFile, writeFile } from "node:fs/promises";
13
+ import { existsSync } from "node:fs";
14
+ import { isAbsolute, relative, resolve } from "node:path";
15
+ import { parseArgs } from "node:util";
16
+ import matter from "gray-matter";
17
+
18
+ import { tagVocabulary } from "../src/config/tag-vocabulary";
19
+ import type { TagVocabularyEntry } from "../src/config/tag-vocabulary-types";
20
+
21
+ const DEFAULT_HOST = "http://localhost:11434";
22
+ const DEFAULT_MODEL = "qwen2.5:7b";
23
+ const BODY_CHAR_LIMIT = 1500;
24
+ const REQUEST_TIMEOUT_MS = 60_000;
25
+ const BATCH_FILE = ".tag-suggestions.jsonl";
26
+
27
+ interface Args {
28
+ files: string[];
29
+ host: string;
30
+ model: string;
31
+ batch: boolean;
32
+ help: boolean;
33
+ }
34
+
35
+ interface Suggestion {
36
+ file: string;
37
+ current: string[];
38
+ suggested: string[];
39
+ }
40
+
41
+ function printHelp(): void {
42
+ process.stdout.write(`Usage: pnpm tags:suggest [options] <file...>
43
+
44
+ Ask a local Ollama LLM to suggest up to 3 tag ids from the project
45
+ vocabulary for each doc file. Opt-in developer tool — never runs in CI.
46
+
47
+ Options:
48
+ --host <url> Ollama endpoint (default: ${DEFAULT_HOST})
49
+ --model <id> Ollama model id (default: ${DEFAULT_MODEL})
50
+ --batch Append suggestions to ${BATCH_FILE} instead of prompting.
51
+ Auto-enabled when stdout is not a TTY.
52
+ --help Show this help.
53
+
54
+ Ollama setup:
55
+ 1. Install from https://ollama.com/
56
+ 2. Pull a model: \`ollama pull ${DEFAULT_MODEL}\`
57
+ 3. Ensure the daemon is running at ${DEFAULT_HOST}
58
+
59
+ Model trade-offs:
60
+ - \`qwen2.5:7b\` (default) — balanced quality/speed; ~5 GB download.
61
+ - \`llama3.1:8b\` — similar size, stronger English reasoning.
62
+ - \`qwen2.5:3b\` / \`llama3.2:3b\` — smaller/faster, noisier output.
63
+ Pass \`--model\` to override.
64
+
65
+ Exit codes:
66
+ 0 success
67
+ 1 usage error
68
+ 2 Ollama unreachable or returned unusable output
69
+ `);
70
+ }
71
+
72
+ function parseCliArgs(argv: string[]): Args {
73
+ let parsed;
74
+ try {
75
+ parsed = parseArgs({
76
+ args: argv,
77
+ allowPositionals: true,
78
+ options: {
79
+ host: { type: "string", default: DEFAULT_HOST },
80
+ model: { type: "string", default: DEFAULT_MODEL },
81
+ batch: { type: "boolean", default: false },
82
+ help: { type: "boolean", default: false },
83
+ },
84
+ });
85
+ } catch (err) {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ process.stderr.write(`tags:suggest: ${msg}\n`);
88
+ process.exit(1);
89
+ }
90
+ return {
91
+ files: parsed.positionals,
92
+ host: String(parsed.values.host ?? DEFAULT_HOST),
93
+ model: String(parsed.values.model ?? DEFAULT_MODEL),
94
+ batch: Boolean(parsed.values.batch),
95
+ help: Boolean(parsed.values.help),
96
+ };
97
+ }
98
+
99
+ function activeVocabulary(): TagVocabularyEntry[] {
100
+ return tagVocabulary.filter((entry) => {
101
+ const d = entry.deprecated;
102
+ // Exclude fully-retired tags. Redirect-style deprecation still points
103
+ // at a live canonical id, which the model may surface if relevant.
104
+ if (d === true) return false;
105
+ if (typeof d === "object" && d !== null && !("redirect" in d)) return false;
106
+ return true;
107
+ });
108
+ }
109
+
110
+ function buildPrompt(
111
+ entries: TagVocabularyEntry[],
112
+ title: string,
113
+ body: string,
114
+ ): string {
115
+ const vocabLines = entries
116
+ .map((e) => {
117
+ const label = e.label ?? e.id;
118
+ const desc = e.description ?? "";
119
+ const group = e.group ? ` [${e.group}]` : "";
120
+ return `- ${e.id}${group} — ${label}: ${desc}`.trim();
121
+ })
122
+ .join("\n");
123
+ const snippet = body.slice(0, BODY_CHAR_LIMIT);
124
+ return `You are tagging a documentation page.
125
+
126
+ Vocabulary (pick ONLY from these ids):
127
+ ${vocabLines}
128
+
129
+ Document title: ${title}
130
+
131
+ Document excerpt:
132
+ """
133
+ ${snippet}
134
+ """
135
+
136
+ Return a JSON array of AT MOST 3 tag ids that best fit this page. The
137
+ array must contain only strings that exactly match ids from the
138
+ vocabulary above. Respond with the JSON array and nothing else.`;
139
+ }
140
+
141
+ interface OllamaGenerateResponse {
142
+ response?: string;
143
+ error?: string;
144
+ }
145
+
146
+ async function callOllama(
147
+ host: string,
148
+ model: string,
149
+ prompt: string,
150
+ ): Promise<string> {
151
+ const url = `${host.replace(/\/$/, "")}/api/generate`;
152
+ const controller = new AbortController();
153
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
154
+
155
+ let res: Response;
156
+ try {
157
+ res = await fetch(url, {
158
+ method: "POST",
159
+ headers: { "content-type": "application/json" },
160
+ body: JSON.stringify({
161
+ model,
162
+ prompt,
163
+ stream: false,
164
+ format: "json",
165
+ }),
166
+ signal: controller.signal,
167
+ });
168
+ } catch (err) {
169
+ clearTimeout(timer);
170
+ if ((err as Error)?.name === "AbortError") {
171
+ throw new OllamaError(
172
+ `Ollama request timed out after ${REQUEST_TIMEOUT_MS / 1000}s at ${host}. Is \`ollama serve\` running?`,
173
+ );
174
+ }
175
+ // Any other fetch rejection (ECONNREFUSED, UND_ERR_CONNECT_TIMEOUT,
176
+ // DNS failure, bad port, …) means we could not talk to Ollama. Collapse
177
+ // them into one actionable message — the full stack isn't useful to
178
+ // the doc author running this CLI.
179
+ throw new OllamaUnreachableError(host, model);
180
+ }
181
+ clearTimeout(timer);
182
+
183
+ if (!res.ok) {
184
+ let detail = "";
185
+ try {
186
+ const body = (await res.json()) as OllamaGenerateResponse;
187
+ if (body?.error) detail = ` — ${body.error}`;
188
+ } catch {
189
+ // ignore; keep status line only
190
+ }
191
+ if (res.status === 404) {
192
+ throw new OllamaError(
193
+ `Ollama responded 404 at ${host}. Pull the model first: \`ollama pull ${model}\`.${detail}`,
194
+ );
195
+ }
196
+ throw new OllamaError(
197
+ `Ollama responded HTTP ${res.status} at ${host}.${detail}`,
198
+ );
199
+ }
200
+
201
+ let json: OllamaGenerateResponse;
202
+ try {
203
+ json = (await res.json()) as OllamaGenerateResponse;
204
+ } catch {
205
+ throw new OllamaError(`Ollama returned non-JSON response at ${host}.`);
206
+ }
207
+ if (typeof json.response !== "string") {
208
+ throw new OllamaError(`Ollama response missing 'response' field.`);
209
+ }
210
+ return json.response;
211
+ }
212
+
213
+ class OllamaError extends Error {
214
+ readonly friendly = true;
215
+ }
216
+ class OllamaUnreachableError extends OllamaError {
217
+ constructor(host: string, model: string) {
218
+ super(
219
+ `Ollama not reachable at ${host}. Install from https://ollama.com/ and run \`ollama pull ${model}\`.`,
220
+ );
221
+ }
222
+ }
223
+
224
+ function parseSuggestions(
225
+ raw: string,
226
+ allowedIds: Set<string>,
227
+ ): string[] {
228
+ const trimmed = raw.trim();
229
+ // Accept either a bare JSON array or a JSON object that wraps one.
230
+ // Some models return `{"tags": [...]}` even when asked for an array.
231
+ let parsed: unknown;
232
+ try {
233
+ parsed = JSON.parse(trimmed);
234
+ } catch {
235
+ // Last-ditch: try to extract the first JSON array substring.
236
+ const match = trimmed.match(/\[[\s\S]*\]/);
237
+ if (!match) {
238
+ throw new OllamaError(
239
+ `Model output was not valid JSON. Raw: ${trimmed.slice(0, 200)}`,
240
+ );
241
+ }
242
+ parsed = JSON.parse(match[0]);
243
+ }
244
+ let arr: unknown[];
245
+ if (Array.isArray(parsed)) {
246
+ arr = parsed;
247
+ } else if (
248
+ parsed &&
249
+ typeof parsed === "object" &&
250
+ Array.isArray((parsed as { tags?: unknown }).tags)
251
+ ) {
252
+ arr = (parsed as { tags: unknown[] }).tags;
253
+ } else {
254
+ throw new OllamaError(`Model output is not a JSON array.`);
255
+ }
256
+ const ids = arr
257
+ .filter((v): v is string => typeof v === "string")
258
+ .map((v) => v.trim())
259
+ .filter((v) => v.length > 0 && allowedIds.has(v));
260
+ // Dedupe, cap at 3.
261
+ return Array.from(new Set(ids)).slice(0, 3);
262
+ }
263
+
264
+ function asStringArray(v: unknown): string[] {
265
+ if (!Array.isArray(v)) return [];
266
+ return v.filter((x): x is string => typeof x === "string");
267
+ }
268
+
269
+ async function writeFrontmatterTags(
270
+ filePath: string,
271
+ parsed: matter.GrayMatterFile<string>,
272
+ nextTags: string[],
273
+ ): Promise<void> {
274
+ const data = { ...parsed.data, tags: nextTags };
275
+ const rebuilt = matter.stringify(parsed.content, data);
276
+ await writeFile(filePath, rebuilt, "utf-8");
277
+ }
278
+
279
+ async function appendBatch(
280
+ repoRoot: string,
281
+ entry: Suggestion,
282
+ ): Promise<void> {
283
+ const outPath = resolve(repoRoot, BATCH_FILE);
284
+ await appendFile(outPath, JSON.stringify(entry) + "\n", "utf-8");
285
+ }
286
+
287
+ class UsageError extends Error {}
288
+
289
+ function resolveFiles(repoRoot: string, inputs: string[]): string[] {
290
+ const out: string[] = [];
291
+ for (const raw of inputs) {
292
+ const abs = isAbsolute(raw) ? raw : resolve(repoRoot, raw);
293
+ if (!existsSync(abs)) {
294
+ throw new UsageError(`file not found: ${raw}`);
295
+ }
296
+ out.push(abs);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ async function main(): Promise<void> {
302
+ const args = parseCliArgs(process.argv.slice(2));
303
+ if (args.help) {
304
+ printHelp();
305
+ return;
306
+ }
307
+ if (args.files.length === 0) {
308
+ process.stderr.write(
309
+ "tags:suggest: no input files. Pass one or more `.mdx` paths, or run with --help.\n",
310
+ );
311
+ process.exit(1);
312
+ }
313
+
314
+ const repoRoot = process.cwd();
315
+ const files = resolveFiles(repoRoot, args.files);
316
+ const vocab = activeVocabulary();
317
+ const allowedIds = new Set(vocab.map((e) => e.id));
318
+
319
+ const isTty = Boolean(process.stdout.isTTY);
320
+ const batchMode = args.batch || !isTty;
321
+
322
+ // Dynamic import so the script's `--help` and arg parsing work even if
323
+ // `@inquirer/prompts` is momentarily unavailable in an odd environment.
324
+ type CheckboxFn = <V extends string>(config: {
325
+ message: string;
326
+ choices: { name: string; value: V; checked?: boolean }[];
327
+ loop?: boolean;
328
+ }) => Promise<V[]>;
329
+ let checkbox: CheckboxFn | null = null;
330
+ if (!batchMode) {
331
+ const mod = (await import("@inquirer/prompts")) as {
332
+ checkbox: CheckboxFn;
333
+ };
334
+ checkbox = mod.checkbox;
335
+ }
336
+
337
+ for (const filePath of files) {
338
+ const rel = relative(repoRoot, filePath);
339
+ const source = await readFile(filePath, "utf-8");
340
+ const parsed = matter(source);
341
+ const title =
342
+ typeof parsed.data.title === "string" ? parsed.data.title : rel;
343
+ const current = asStringArray(parsed.data.tags);
344
+
345
+ let suggested: string[];
346
+ try {
347
+ const prompt = buildPrompt(vocab, title, parsed.content);
348
+ const raw = await callOllama(args.host, args.model, prompt);
349
+ suggested = parseSuggestions(raw, allowedIds);
350
+ } catch (err) {
351
+ if (err instanceof OllamaError) {
352
+ process.stderr.write(`tags:suggest: ${err.message}\n`);
353
+ process.exit(2);
354
+ }
355
+ throw err;
356
+ }
357
+
358
+ if (suggested.length === 0) {
359
+ process.stdout.write(`${rel}: no vocabulary-matching suggestions\n`);
360
+ continue;
361
+ }
362
+
363
+ if (batchMode) {
364
+ await appendBatch(repoRoot, {
365
+ file: rel,
366
+ current,
367
+ suggested,
368
+ });
369
+ process.stdout.write(
370
+ `${rel}: recorded ${suggested.length} suggestion(s) to ${BATCH_FILE}\n`,
371
+ );
372
+ continue;
373
+ }
374
+
375
+ // Interactive approval: pre-check suggestions, show current tags for context.
376
+ const combined = Array.from(new Set([...current, ...suggested]));
377
+ const choices = combined.map((id) => {
378
+ const inSuggestion = suggested.includes(id);
379
+ const inCurrent = current.includes(id);
380
+ const label =
381
+ inSuggestion && inCurrent
382
+ ? `${id} (current, also suggested)`
383
+ : inSuggestion
384
+ ? `${id} (suggested)`
385
+ : `${id} (current)`;
386
+ return {
387
+ name: label,
388
+ value: id,
389
+ checked: inSuggestion || inCurrent,
390
+ };
391
+ });
392
+ if (!checkbox) {
393
+ // Should not happen — we set it above when !batchMode.
394
+ throw new Error("interactive prompt loader missing");
395
+ }
396
+ const picked = await checkbox<string>({
397
+ message: `${rel} — pick tags to write:`,
398
+ choices,
399
+ loop: false,
400
+ });
401
+ const nextTags = picked.slice();
402
+ if (
403
+ nextTags.length === current.length &&
404
+ nextTags.every((t, i) => t === current[i])
405
+ ) {
406
+ process.stdout.write(`${rel}: unchanged\n`);
407
+ continue;
408
+ }
409
+ await writeFrontmatterTags(filePath, parsed, nextTags);
410
+ process.stdout.write(
411
+ `${rel}: wrote tags [${nextTags.join(", ")}]\n`,
412
+ );
413
+ }
414
+ }
415
+
416
+ main().catch((err) => {
417
+ if (err instanceof OllamaError) {
418
+ process.stderr.write(`tags:suggest: ${err.message}\n`);
419
+ process.exit(2);
420
+ }
421
+ if (err instanceof UsageError) {
422
+ process.stderr.write(`tags:suggest: ${err.message}\n`);
423
+ process.exit(1);
424
+ }
425
+ const msg = err instanceof Error ? err.message : String(err);
426
+ process.stderr.write(`tags:suggest: ${msg}\n`);
427
+ process.exit(1);
428
+ });
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect, useCallback } from "preact/hooks";
4
+ import type { FindResult, FindInPage } from "@/utils/find-in-page";
5
+
6
+ interface FindBarProps {
7
+ visible: boolean;
8
+ onClose: () => void;
9
+ findInPage: FindInPage;
10
+ containerSelector: string;
11
+ }
12
+
13
+ function toMatchInfo(result: FindResult): FindResult | null {
14
+ return result.matches > 0 ? result : null;
15
+ }
16
+
17
+ export function FindBar({ visible, onClose, findInPage, containerSelector }: FindBarProps) {
18
+ const [query, setQuery] = useState("");
19
+ const [matchInfo, setMatchInfo] = useState<FindResult | null>(null);
20
+ const inputRef = useRef<HTMLInputElement>(null);
21
+ // Pin `findInPage` via ref so the visibility-clear effect doesn't
22
+ // re-run when a new `find-in-page` instance is passed; the latest
23
+ // value is read at effect-fire time without entering the deps.
24
+ const findInPageRef = useRef(findInPage);
25
+ useEffect(() => {
26
+ findInPageRef.current = findInPage;
27
+ }, [findInPage]);
28
+
29
+ useEffect(() => {
30
+ if (visible) {
31
+ inputRef.current?.focus();
32
+ inputRef.current?.select();
33
+ }
34
+ }, [visible]);
35
+
36
+ useEffect(() => {
37
+ if (!visible) {
38
+ setQuery("");
39
+ setMatchInfo(null);
40
+ findInPageRef.current.stop();
41
+ }
42
+ }, [visible]);
43
+
44
+ const handleFind = useCallback(
45
+ (text: string) => {
46
+ const container = document.querySelector(containerSelector);
47
+ if (!text || !(container instanceof HTMLElement)) {
48
+ setMatchInfo(null);
49
+ findInPage.stop();
50
+ return;
51
+ }
52
+ const result = findInPage.find(container, text);
53
+ setMatchInfo(toMatchInfo(result));
54
+ },
55
+ [findInPage, containerSelector],
56
+ );
57
+
58
+ const handleKeyDown = useCallback(
59
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
60
+ if (e.key === "Escape") {
61
+ onClose();
62
+ } else if (e.key === "Enter") {
63
+ const result = e.shiftKey ? findInPage.prev() : findInPage.next();
64
+ setMatchInfo(toMatchInfo(result));
65
+ }
66
+ },
67
+ [onClose, findInPage],
68
+ );
69
+
70
+ if (!visible) return null;
71
+
72
+ return (
73
+ <div className="fixed top-[3.5rem] right-0 z-50 flex items-center gap-hsp-sm py-hsp-xs px-hsp-md bg-surface border-b border-l border-muted rounded-bl-lg shadow-md">
74
+ <input
75
+ ref={inputRef}
76
+ className="w-48 py-[4px] px-hsp-sm rounded text-small bg-bg border border-muted text-fg outline-none focus:border-accent"
77
+ type="text"
78
+ value={query}
79
+ placeholder="Find in page..."
80
+ aria-label="Find in page"
81
+ onChange={(e) => {
82
+ setQuery(e.currentTarget.value);
83
+ handleFind(e.currentTarget.value);
84
+ }}
85
+ onKeyDown={handleKeyDown}
86
+ />
87
+ <span className="text-caption whitespace-nowrap min-w-[3rem] text-center text-fg/60">
88
+ {matchInfo ? `${matchInfo.activeMatchOrdinal}/${matchInfo.matches}` : ""}
89
+ </span>
90
+ <button
91
+ type="button"
92
+ className="py-hsp-2xs px-hsp-sm rounded text-caption bg-bg border border-muted text-fg hover:bg-surface"
93
+ onClick={() => {
94
+ const result = findInPage.prev();
95
+ setMatchInfo(toMatchInfo(result));
96
+ }}
97
+ title="Previous (Shift+Enter)"
98
+ >
99
+ Prev
100
+ </button>
101
+ <button
102
+ type="button"
103
+ className="py-hsp-2xs px-hsp-sm rounded text-caption bg-bg border border-muted text-fg hover:bg-surface"
104
+ onClick={() => {
105
+ const result = findInPage.next();
106
+ setMatchInfo(toMatchInfo(result));
107
+ }}
108
+ title="Next (Enter)"
109
+ >
110
+ Next
111
+ </button>
112
+ <button
113
+ type="button"
114
+ className="py-hsp-2xs px-hsp-sm rounded text-caption bg-bg border border-muted text-fg hover:bg-surface"
115
+ onClick={onClose}
116
+ title="Close (Esc)"
117
+ >
118
+ Close
119
+ </button>
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,53 @@
1
+ import { useState, useEffect, useRef } from "preact/compat";
2
+ import { FindBar } from "./find-bar";
3
+ import { createFindInPage } from "@/utils/find-in-page";
4
+
5
+ const CONTENT_SELECTOR = "article.zd-content";
6
+
7
+ export default function FindInPageInit() {
8
+ const [isTauri, setIsTauri] = useState(false);
9
+ const [visible, setVisible] = useState(false);
10
+ const findInPageRef = useRef(createFindInPage());
11
+
12
+ // Detect Tauri environment
13
+ useEffect(() => {
14
+ if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
15
+ setIsTauri(true);
16
+ }
17
+ }, []);
18
+
19
+ // Intercept Cmd/Ctrl+F only in Tauri
20
+ useEffect(() => {
21
+ if (!isTauri) return;
22
+
23
+ const handler = (e: KeyboardEvent) => {
24
+ if ((e.metaKey || e.ctrlKey) && e.key === "f") {
25
+ e.preventDefault();
26
+ setVisible((prev) => !prev);
27
+ }
28
+ };
29
+ document.addEventListener("keydown", handler);
30
+ return () => document.removeEventListener("keydown", handler);
31
+ }, [isTauri]);
32
+
33
+ // Clear search on Astro page navigation
34
+ useEffect(() => {
35
+ const handler = () => {
36
+ findInPageRef.current.stop();
37
+ setVisible(false);
38
+ };
39
+ document.addEventListener("pagehide", handler);
40
+ return () => document.removeEventListener("pagehide", handler);
41
+ }, []);
42
+
43
+ if (!isTauri) return null;
44
+
45
+ return (
46
+ <FindBar
47
+ visible={visible}
48
+ onClose={() => setVisible(false)}
49
+ findInPage={findInPageRef.current}
50
+ containerSelector={CONTENT_SELECTOR}
51
+ />
52
+ );
53
+ }