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,39 @@
1
+ /**
2
+ * Tag governance enforcement level.
3
+ *
4
+ * - `"off"` — no vocabulary-aware enforcement. The `tags` schema stays a
5
+ * free-form `string[]`. Identical to pre-vocabulary behaviour.
6
+ * - `"warn"` — `tags` schema stays free-form so builds pass, but the tag
7
+ * audit script (see Sub 2) reports unknown tags as warnings and
8
+ * exits non-zero under `--ci`.
9
+ * - `"strict"` — `tags` schema is tightened to `z.enum([...allowedIds])`, so
10
+ * unknown tags fail `pnpm check` / `pnpm build`.
11
+ *
12
+ * Orthogonal to `tagVocabulary` (the on/off switch for consulting the
13
+ * vocabulary file at runtime). See `settings-types.ts` for details.
14
+ */
15
+ export type TagGovernanceMode = "off" | "warn" | "strict";
16
+
17
+ /**
18
+ * A single entry in the tag vocabulary.
19
+ *
20
+ * - `id` — canonical tag id. What content files should ideally use.
21
+ * - `label` — optional human-readable label (falls back to `id`).
22
+ * - `description`— optional short description for tooling / tag index pages.
23
+ * - `group` — optional grouping key used by the grouped tag footer
24
+ * (e.g. `"type"`, `"level"`, `"topic"`).
25
+ * - `aliases` — alternate strings that content files may use. Alias
26
+ * resolution rewrites these to `id` before aggregation.
27
+ * - `deprecated` — `true` marks the tag as deprecated with no redirect: the
28
+ * canonical id is dropped from aggregation. Pass
29
+ * `{ redirect: "<other-id>" }` to rewrite this tag to another
30
+ * canonical id when it appears in content.
31
+ */
32
+ export interface TagVocabularyEntry {
33
+ id: string;
34
+ label?: string;
35
+ description?: string;
36
+ group?: string;
37
+ aliases?: readonly string[];
38
+ deprecated?: boolean | { redirect?: string };
39
+ }
@@ -0,0 +1,20 @@
1
+ import type { TagVocabularyEntry } from "./tag-vocabulary-types";
2
+
3
+ /**
4
+ * Canonical tag vocabulary for this documentation base.
5
+ *
6
+ * Populate this array with the tags you use in `src/content/docs/**`.
7
+ * Then enable `tagGovernance: "warn"` (or `"strict"`) and
8
+ * `tagVocabulary: true` in `settings.ts` to activate alias resolution,
9
+ * deprecation filtering, and grouped tag rendering.
10
+ *
11
+ * Example entry:
12
+ *
13
+ * {
14
+ * id: "type:guide",
15
+ * label: "Guide",
16
+ * group: "type",
17
+ * aliases: ["guide", "guides"],
18
+ * }
19
+ */
20
+ export const tagVocabulary: readonly TagVocabularyEntry[] = [];
@@ -0,0 +1,133 @@
1
+ import { useCallback, useEffect, useRef, useState } from "preact/compat";
2
+ import type { Heading } from "@/types/heading";
3
+
4
+ const SCROLL_MARGIN_TOP = 80;
5
+ const DEBOUNCE_MS = 200;
6
+
7
+ function getActiveHeadingId(
8
+ headingIds: string[],
9
+ elementMap: Map<string, HTMLElement>,
10
+ ): string | null {
11
+ if (headingIds.length === 0) return null;
12
+
13
+ const viewportHeight = window.innerHeight;
14
+
15
+ let firstVisibleIndex = -1;
16
+ for (let i = 0; i < headingIds.length; i++) {
17
+ const el = elementMap.get(headingIds[i]);
18
+ if (!el) continue;
19
+ const { top } = el.getBoundingClientRect();
20
+ if (top >= SCROLL_MARGIN_TOP) {
21
+ firstVisibleIndex = i;
22
+ break;
23
+ }
24
+ }
25
+
26
+ if (firstVisibleIndex === -1) {
27
+ return headingIds[headingIds.length - 1];
28
+ }
29
+
30
+ if (firstVisibleIndex === 0) {
31
+ const el = elementMap.get(headingIds[0]);
32
+ if (el) {
33
+ const { top } = el.getBoundingClientRect();
34
+ if (top < viewportHeight / 2) {
35
+ return headingIds[0];
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ const el = elementMap.get(headingIds[firstVisibleIndex]);
42
+ if (el) {
43
+ const { top } = el.getBoundingClientRect();
44
+ if (top < viewportHeight / 2) {
45
+ return headingIds[firstVisibleIndex];
46
+ }
47
+ }
48
+
49
+ return headingIds[firstVisibleIndex - 1];
50
+ }
51
+
52
+ interface UseActiveHeadingResult {
53
+ activeId: string | null;
54
+ activate: (id: string) => void;
55
+ }
56
+
57
+ export function useActiveHeading(
58
+ headings: Heading[],
59
+ ): UseActiveHeadingResult {
60
+ const [activeId, setActiveId] = useState<string | null>(null);
61
+ const headingIdsRef = useRef<string[]>([]);
62
+ const elementMapRef = useRef<Map<string, HTMLElement>>(new Map());
63
+ const suppressedRef = useRef(false);
64
+
65
+ const activate = useCallback((id: string) => {
66
+ setActiveId(id);
67
+ suppressedRef.current = true;
68
+ // Safety timeout: unsuppress if no scroll event fires (target already in view)
69
+ setTimeout(() => {
70
+ suppressedRef.current = false;
71
+ }, 2000);
72
+ }, []);
73
+
74
+ useEffect(() => {
75
+ const ids = headings.map((h) => h.slug);
76
+ headingIdsRef.current = ids;
77
+
78
+ const map = new Map<string, HTMLElement>();
79
+ for (const id of ids) {
80
+ const el = document.getElementById(id);
81
+ if (el) map.set(id, el);
82
+ }
83
+ elementMapRef.current = map;
84
+
85
+ let timerId: ReturnType<typeof setTimeout> | null = null;
86
+
87
+ function update() {
88
+ timerId = null;
89
+ setActiveId(
90
+ getActiveHeadingId(headingIdsRef.current, elementMapRef.current),
91
+ );
92
+ }
93
+
94
+ let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
95
+
96
+ function onScroll() {
97
+ if (suppressedRef.current) {
98
+ // Fallback for browsers without scrollend (Safari < 18)
99
+ if (fallbackTimerId !== null) clearTimeout(fallbackTimerId);
100
+ fallbackTimerId = setTimeout(onScrollEnd, 1500);
101
+ return;
102
+ }
103
+ if (timerId !== null) clearTimeout(timerId);
104
+ timerId = setTimeout(update, DEBOUNCE_MS);
105
+ }
106
+
107
+ function onScrollEnd() {
108
+ suppressedRef.current = false;
109
+ if (fallbackTimerId !== null) {
110
+ clearTimeout(fallbackTimerId);
111
+ fallbackTimerId = null;
112
+ }
113
+ if (timerId !== null) clearTimeout(timerId);
114
+ update();
115
+ }
116
+
117
+ update();
118
+
119
+ window.addEventListener("scroll", onScroll, { passive: true });
120
+ window.addEventListener("resize", onScroll, { passive: true });
121
+ window.addEventListener("scrollend", onScrollEnd, { passive: true });
122
+
123
+ return () => {
124
+ window.removeEventListener("scroll", onScroll);
125
+ window.removeEventListener("resize", onScroll);
126
+ window.removeEventListener("scrollend", onScrollEnd);
127
+ if (timerId !== null) clearTimeout(timerId);
128
+ if (fallbackTimerId !== null) clearTimeout(fallbackTimerId);
129
+ };
130
+ }, [headings]);
131
+
132
+ return { activeId, activate };
133
+ }
@@ -0,0 +1,103 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ export interface DocsSourceMapOptions {
5
+ /** Absolute root directory of the project */
6
+ rootDir: string;
7
+ /** Main docs directory relative to rootDir (e.g., "src/content/docs") */
8
+ docsDir: string;
9
+ /** Locale configurations: { ja: { dir: "src/content/docs-ja" } } */
10
+ locales: Record<string, { dir: string }>;
11
+ /** Version configurations */
12
+ versions: Array<{ slug: string; docsDir: string }> | false;
13
+ /** Base URL path (e.g., "/" or "/pj/my-docs/") */
14
+ base: string;
15
+ /** Whether to append trailing slash to URLs */
16
+ trailingSlash: boolean;
17
+ }
18
+
19
+ /**
20
+ * Build a map of absolute file paths to URL paths.
21
+ * Scans all configured docs directories for .md/.mdx files.
22
+ */
23
+ export function buildDocsSourceMap(
24
+ options: DocsSourceMapOptions,
25
+ ): Map<string, string> {
26
+ const map = new Map<string, string>();
27
+ const { rootDir, docsDir, locales, versions, base, trailingSlash } = options;
28
+
29
+ const normalizedBase = base.replace(/\/+$/, "");
30
+
31
+ function applyTS(url: string): string {
32
+ if (trailingSlash) {
33
+ if (url.endsWith("/")) return url;
34
+ return url + "/";
35
+ }
36
+ // Strip trailing slashes when trailingSlash is false (except root "/")
37
+ if (url !== "/" && url.endsWith("/")) {
38
+ return url.replace(/\/+$/, "");
39
+ }
40
+ return url;
41
+ }
42
+
43
+ function withBase(path: string): string {
44
+ const raw =
45
+ normalizedBase === ""
46
+ ? path
47
+ : `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`;
48
+ return applyTS(raw);
49
+ }
50
+
51
+ function scanDir(dir: string, urlPrefix: string): void {
52
+ const absDir = resolve(rootDir, dir);
53
+ let files: string[];
54
+ try {
55
+ files = readdirSync(absDir, { recursive: true })
56
+ .map((f) => String(f))
57
+ .filter((f) => /\.(md|mdx)$/.test(f));
58
+ } catch {
59
+ // Directory doesn't exist — skip silently
60
+ return;
61
+ }
62
+ for (const file of files) {
63
+ const absFile = resolve(absDir, file);
64
+ // Convert file path to slug: strip extension, strip /index suffix
65
+ const slug = file
66
+ .replace(/\.(md|mdx)$/, "")
67
+ .replace(/(^|\/)index$/, "$1") // Strip index (root or nested)
68
+ .replace(/(^|\\)index$/, "$1") // Windows
69
+ .replace(/\\/g, "/") // Windows path sep
70
+ .replace(/\/$/, ""); // Trailing slash from index strip
71
+ const url = withBase(`${urlPrefix}/${slug}`);
72
+ map.set(absFile, url);
73
+
74
+ // Also register with alternative extension for cross-referencing
75
+ // e.g., if file is foo.mdx, also register foo.md → same URL
76
+ // Only set if not already registered (avoid shadowing a real file)
77
+ const altExt = file.endsWith(".mdx")
78
+ ? file.replace(/\.mdx$/, ".md")
79
+ : file.replace(/\.md$/, ".mdx");
80
+ const altFile = resolve(absDir, altExt);
81
+ if (!map.has(altFile)) {
82
+ map.set(altFile, url);
83
+ }
84
+ }
85
+ }
86
+
87
+ // Main docs (default locale)
88
+ scanDir(docsDir, "/docs");
89
+
90
+ // Locale docs
91
+ for (const [code, config] of Object.entries(locales)) {
92
+ scanDir(config.dir, `/${code}/docs`);
93
+ }
94
+
95
+ // Versioned docs
96
+ if (versions) {
97
+ for (const version of versions) {
98
+ scanDir(version.docsDir, `/v/${version.slug}/docs`);
99
+ }
100
+ }
101
+
102
+ return map;
103
+ }
@@ -0,0 +1,10 @@
1
+ import type { Element, ElementContent, Text } from "hast";
2
+
3
+ /** Recursively extract plain text from a HAST node tree. */
4
+ export function extractText(node: Element | ElementContent | Text): string {
5
+ if (node.type === "text") return node.value;
6
+ if (node.type === "element") {
7
+ return node.children.map((c) => extractText(c)).join("");
8
+ }
9
+ return "";
10
+ }
@@ -0,0 +1,50 @@
1
+ import type { Root, Element } from "hast";
2
+ import { visit, SKIP } from "unist-util-visit";
3
+
4
+ /**
5
+ * Rehype plugin that extracts title="..." from code block meta strings
6
+ * and wraps the <pre> in a container with a title header.
7
+ *
8
+ * Usage in MDX:
9
+ * ```js title="config.js"
10
+ * const x = 1;
11
+ * ```
12
+ */
13
+ export function rehypeCodeTitle() {
14
+ return (tree: Root) => {
15
+ visit(tree, "element", (node: Element, index, parent) => {
16
+ if (node.tagName !== "pre" || !parent || index === undefined) return;
17
+
18
+ const codeEl = node.children.find(
19
+ (child): child is Element =>
20
+ child.type === "element" && child.tagName === "code",
21
+ );
22
+ if (!codeEl) return;
23
+
24
+ const meta =
25
+ String(codeEl.properties?.meta ?? "") ||
26
+ String((codeEl.data as Record<string, unknown> | undefined)?.meta ?? "");
27
+ const titleMatch = meta.match(/title="([^"]+)"/);
28
+ if (!titleMatch) return;
29
+
30
+ const title = titleMatch[1];
31
+
32
+ const titleEl: Element = {
33
+ type: "element",
34
+ tagName: "div",
35
+ properties: { className: ["code-block-title"] },
36
+ children: [{ type: "text", value: title }],
37
+ };
38
+
39
+ const wrapper: Element = {
40
+ type: "element",
41
+ tagName: "div",
42
+ properties: { className: ["code-block-container"] },
43
+ children: [titleEl, { ...node }],
44
+ };
45
+
46
+ (parent as Element).children[index] = wrapper;
47
+ return SKIP;
48
+ });
49
+ };
50
+ }
@@ -0,0 +1,53 @@
1
+ import type { Root, Element } from "hast";
2
+ import GithubSlugger from "github-slugger";
3
+ import { visit } from "unist-util-visit";
4
+ import { extractText } from "./hast-utils";
5
+
6
+ /**
7
+ * Rehype plugin that adds Docusaurus-style anchor links to headings (h2-h6).
8
+ *
9
+ * Generates heading IDs (via github-slugger) and appends:
10
+ * <a href="#id" class="hash-link" aria-label="Direct link to ..."></a>
11
+ *
12
+ * The "#" symbol is rendered via CSS ::after to avoid polluting
13
+ * Astro's heading text extraction (used for TOC).
14
+ *
15
+ * Runs before Astro's built-in heading ID plugin, so it sets IDs itself.
16
+ * Astro's plugin will skip headings that already have an ID.
17
+ */
18
+
19
+ const headingTags = new Set(["h2", "h3", "h4", "h5", "h6"]);
20
+
21
+ export function rehypeHeadingLinks() {
22
+ return (tree: Root) => {
23
+ const slugger = new GithubSlugger();
24
+
25
+ visit(tree, "element", (node: Element) => {
26
+ if (!headingTags.has(node.tagName)) return;
27
+
28
+ const text = node.children
29
+ .map((c) => extractText(c))
30
+ .join("");
31
+
32
+ const id =
33
+ (node.properties?.id as string | undefined) || slugger.slug(text);
34
+
35
+ // Set the id if not already present
36
+ if (!node.properties) node.properties = {};
37
+ if (!node.properties.id) node.properties.id = id;
38
+
39
+ const link: Element = {
40
+ type: "element",
41
+ tagName: "a",
42
+ properties: {
43
+ href: `#${id}`,
44
+ className: ["hash-link"],
45
+ "aria-label": `Direct link to ${text}`,
46
+ },
47
+ children: [],
48
+ };
49
+
50
+ node.children.push(link);
51
+ });
52
+ };
53
+ }
@@ -0,0 +1,113 @@
1
+ import type { Root, Element, ElementContent } from "hast";
2
+ import { visit, SKIP } from "unist-util-visit";
3
+
4
+ function isWhitespaceText(node: ElementContent): boolean {
5
+ return node.type === "text" && node.value.trim() === "";
6
+ }
7
+
8
+ function isEnlargeableFigure(node: ElementContent): boolean {
9
+ if (node.type !== "element" || node.tagName !== "figure") return false;
10
+ const cls = node.properties?.className;
11
+ return Array.isArray(cls) && cls.includes("zd-enlargeable");
12
+ }
13
+
14
+ function makeEnlargeButton(): Element {
15
+ return {
16
+ type: "element",
17
+ tagName: "button",
18
+ properties: {
19
+ type: "button",
20
+ className: ["zd-enlarge-btn"],
21
+ ariaLabel: "Enlarge image",
22
+ hidden: true,
23
+ },
24
+ children: [
25
+ {
26
+ type: "element",
27
+ tagName: "svg",
28
+ properties: {
29
+ viewBox: "0 0 38.99 38.99",
30
+ fill: "currentColor",
31
+ focusable: "false",
32
+ ariaHidden: true,
33
+ },
34
+ children: [
35
+ {
36
+ type: "element",
37
+ tagName: "polygon",
38
+ properties: {
39
+ points:
40
+ "16.2 13.74 5.92 3.47 11.2 3.47 11.2 0 3.47 0 0 0 0 3.47 0 11.2 3.47 11.2 3.47 5.92 13.74 16.2 16.2 13.74",
41
+ },
42
+ children: [],
43
+ },
44
+ {
45
+ type: "element",
46
+ tagName: "polygon",
47
+ properties: {
48
+ points:
49
+ "25.24 16.2 35.52 5.92 35.52 11.2 38.99 11.2 38.99 3.47 38.99 0 35.52 0 27.79 0 27.79 3.47 33.07 3.47 22.79 13.74 25.24 16.2",
50
+ },
51
+ children: [],
52
+ },
53
+ {
54
+ type: "element",
55
+ tagName: "polygon",
56
+ properties: {
57
+ points:
58
+ "22.79 25.24 33.07 35.52 27.79 35.52 27.79 38.99 35.52 38.99 38.99 38.99 38.99 35.52 38.99 27.79 35.52 27.79 35.52 33.07 25.24 22.79 22.79 25.24",
59
+ },
60
+ children: [],
61
+ },
62
+ {
63
+ type: "element",
64
+ tagName: "polygon",
65
+ properties: {
66
+ points:
67
+ "13.74 22.79 3.47 33.07 3.47 27.79 0 27.79 0 35.52 0 38.99 3.47 38.99 11.2 38.99 11.2 35.52 5.92 35.52 16.2 25.24 13.74 22.79",
68
+ },
69
+ children: [],
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ };
75
+ }
76
+
77
+ export function rehypeImageEnlarge() {
78
+ return (tree: Root) => {
79
+ visit(tree, "element", (node: Element, index, parent) => {
80
+ if (node.tagName !== "p" || !parent || index === undefined) return;
81
+
82
+ const nonWs = node.children.filter((c) => !isWhitespaceText(c));
83
+
84
+ // Idempotency: <p> already contains a wrapped figure
85
+ if (nonWs.length === 1 && isEnlargeableFigure(nonWs[0])) return SKIP;
86
+
87
+ // Case A requires exactly one non-whitespace child that is <img>
88
+ if (nonWs.length !== 1) return;
89
+ const child = nonWs[0];
90
+ if (child.type !== "element" || child.tagName !== "img") return;
91
+
92
+ const imgEl = child;
93
+
94
+ // Opt-out: exact title="no-enlarge" — skip wrap but remove title attr
95
+ if (imgEl.properties?.title === "no-enlarge") {
96
+ const newProps = { ...imgEl.properties };
97
+ delete newProps.title;
98
+ imgEl.properties = newProps;
99
+ return SKIP;
100
+ }
101
+
102
+ const figureEl: Element = {
103
+ type: "element",
104
+ tagName: "figure",
105
+ properties: { className: ["zd-enlargeable"] },
106
+ children: [imgEl, makeEnlargeButton()],
107
+ };
108
+
109
+ (parent as Element).children[index] = figureEl;
110
+ return SKIP;
111
+ });
112
+ };
113
+ }
@@ -0,0 +1,41 @@
1
+ import type { Root, Element } from "hast";
2
+ import { visit } from "unist-util-visit";
3
+ import { extractText } from "./hast-utils";
4
+
5
+ /**
6
+ * Rehype plugin that transforms mermaid code blocks into renderable containers.
7
+ *
8
+ * After Shiki processes code blocks, mermaid blocks become:
9
+ * <pre data-language="mermaid"><code><span>...</span></code></pre>
10
+ *
11
+ * This plugin converts them to:
12
+ * <div class="mermaid" data-mermaid>graph LR; A-->B</div>
13
+ */
14
+
15
+ export function rehypeMermaid() {
16
+ return (tree: Root) => {
17
+ visit(tree, "element", (node: Element, index, parent) => {
18
+ if (
19
+ node.tagName !== "pre" ||
20
+ !parent ||
21
+ index === undefined
22
+ ) return;
23
+
24
+ // Match Shiki-processed mermaid blocks (data-language="mermaid")
25
+ if (node.properties?.dataLanguage !== "mermaid") return;
26
+
27
+ // Extract all text content recursively from the code/span tree
28
+ const text = node.children
29
+ .map((c) => extractText(c))
30
+ .join("");
31
+
32
+ // Replace the <pre> with a <div class="mermaid">
33
+ (parent as Element).children[index] = {
34
+ type: "element",
35
+ tagName: "div",
36
+ properties: { className: ["mermaid"], "data-mermaid": true },
37
+ children: [{ type: "text", value: text }],
38
+ };
39
+ });
40
+ };
41
+ }
@@ -0,0 +1,58 @@
1
+ import type { Root, Element } from 'hast';
2
+ import { visit } from 'unist-util-visit';
3
+ import { isExternal } from './url-utils';
4
+
5
+ /**
6
+ * Rehype plugin that strips .md/.mdx extensions from relative link hrefs
7
+ * and ensures they have a trailing slash for Astro's `trailingSlash: "always"` mode.
8
+ *
9
+ * Markdown authors often write links like `[Other doc](./other-doc.md)`.
10
+ * In Astro's static output, the correct URL is `/docs/other-doc/` (no extension).
11
+ *
12
+ * Astro 5+ may strip .md extensions before rehype runs, so this plugin also
13
+ * handles relative links that have already lost their extension by adding
14
+ * a trailing slash when the last path segment has no file extension.
15
+ */
16
+ export function rehypeStripMdExtension() {
17
+ return (tree: Root) => {
18
+ visit(tree, 'element', (node: Element) => {
19
+ if (node.tagName !== 'a') return;
20
+ const href = node.properties?.href;
21
+ if (typeof href !== 'string') return;
22
+
23
+ // Only process relative links (not http://, https://, mailto:, #, etc.)
24
+ if (isExternal(href) || href.startsWith('#')) return;
25
+
26
+ let newHref = href;
27
+
28
+ // Strip .md or .mdx extension (with optional hash fragment)
29
+ newHref = newHref.replace(
30
+ /\.mdx?(#.*)?$/,
31
+ (_match, hash) => (hash ? '/' + hash : '/'),
32
+ );
33
+
34
+ // For relative links where Astro already stripped .md:
35
+ // add trailing slash if missing and the last segment has no file extension
36
+ if (newHref === href && (newHref.startsWith('./') || newHref.startsWith('../'))) {
37
+ // Split off query string and hash fragment
38
+ const qIdx = newHref.indexOf('?');
39
+ const hIdx = newHref.indexOf('#');
40
+ const suffixIdx = qIdx >= 0 ? qIdx : hIdx >= 0 ? hIdx : -1;
41
+ const path = suffixIdx >= 0 ? newHref.slice(0, suffixIdx) : newHref;
42
+ const suffix = suffixIdx >= 0 ? newHref.slice(suffixIdx) : '';
43
+
44
+ if (!path.endsWith('/')) {
45
+ const lastSegment = path.split('/').pop() || '';
46
+ // Only add slash if there's no file extension (like .png, .pdf, etc.)
47
+ if (!/\.[a-zA-Z]\w*$/.test(lastSegment)) {
48
+ newHref = path + '/' + suffix;
49
+ }
50
+ }
51
+ }
52
+
53
+ if (newHref !== href) {
54
+ node.properties.href = newHref;
55
+ }
56
+ });
57
+ };
58
+ }