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
+ * Concrete entry type for docs collections.
3
+ *
4
+ * Mirrors the public surface that pages consume from `getCollection(...)`.
5
+ * Originally this was structurally identical to Astro's `CollectionEntry`
6
+ * but is defined locally now that the project runs on the zfb content
7
+ * engine — collection-name-specific generics are not exposed by zfb, so
8
+ * pages cast collection entries to this shape via `pages/_data.ts`.
9
+ */
10
+ // Structural shape of zfb's optional rendered-content payload for a doc
11
+ // entry (kept loose to stay engine-agnostic — pages do not rely on the
12
+ // exact field set today).
13
+ type RenderedContent = unknown;
14
+ export interface DocsEntry {
15
+ id: string;
16
+ body?: string;
17
+ collection: string;
18
+ data: {
19
+ title: string;
20
+ description?: string;
21
+ category?: string;
22
+ sidebar_position?: number;
23
+ sidebar_label?: string;
24
+ tags?: string[];
25
+ search_exclude?: boolean;
26
+ pagination_next?: string | null;
27
+ pagination_prev?: string | null;
28
+ draft?: boolean;
29
+ unlisted?: boolean;
30
+ hide_sidebar?: boolean;
31
+ hide_toc?: boolean;
32
+ doc_history?: boolean;
33
+ standalone?: boolean;
34
+ slug?: string;
35
+ generated?: boolean;
36
+ };
37
+ rendered?: RenderedContent;
38
+ filePath?: string;
39
+ }
@@ -0,0 +1,5 @@
1
+ export interface Heading {
2
+ depth: number;
3
+ slug: string;
4
+ text: string;
5
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Represents a locale switcher link.
3
+ * Used by both SidebarFooter (Preact) and language-switcher (Astro).
4
+ */
5
+ export interface LocaleLink {
6
+ code: string;
7
+ label: string;
8
+ href: string;
9
+ active: boolean;
10
+ }
@@ -0,0 +1,139 @@
1
+ import { settings } from "@/config/settings";
2
+ import { defaultLocale, locales, getLocaleLabel, type Locale } from "@/config/i18n";
3
+ import type { LocaleLink } from "@/types/locale";
4
+
5
+ /** Normalized base path with no trailing slash (empty string when "/"). */
6
+ export const normalizedBase = settings.base.replace(/\/+$/, "");
7
+
8
+ /**
9
+ * Append a trailing slash to page URLs when `settings.trailingSlash` is true.
10
+ * Skips paths that already end with `/`, contain a file extension, or have a
11
+ * query string / fragment before the slash would be inserted.
12
+ */
13
+ export function applyTrailingSlash(url: string): string {
14
+ if (!settings.trailingSlash) return url;
15
+ if (url.endsWith("/")) return url;
16
+ // Split off query string and fragment
17
+ const suffixIdx = url.search(/[?#]/);
18
+ const pathPart = suffixIdx >= 0 ? url.slice(0, suffixIdx) : url;
19
+ const suffix = suffixIdx >= 0 ? url.slice(suffixIdx) : "";
20
+ if (pathPart.endsWith("/")) return url;
21
+ // Check file extension on the last path segment only, requiring the extension
22
+ // to start with a letter to avoid false positives on version-like paths (e.g. /docs/v2.0)
23
+ const lastSegment = pathPart.split("/").pop() ?? "";
24
+ if (/\.[a-zA-Z]\w*$/.test(lastSegment)) return url;
25
+ return pathPart + "/" + suffix;
26
+ }
27
+
28
+ /** Prefix a path with the configured base directory. */
29
+ export function withBase(path: string): string {
30
+ const raw =
31
+ normalizedBase === ""
32
+ ? path
33
+ : `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`;
34
+ return applyTrailingSlash(raw);
35
+ }
36
+
37
+ /** Strip the base prefix from a URL pathname. */
38
+ export function stripBase(path: string): string {
39
+ if (normalizedBase === "") return path;
40
+ return path.startsWith(normalizedBase)
41
+ ? path.slice(normalizedBase.length) || "/"
42
+ : path;
43
+ }
44
+
45
+ /** Build a docs URL for the given slug and lang. */
46
+ export function docsUrl(slug: string, lang: Locale = defaultLocale): string {
47
+ const path = lang === defaultLocale ? `/docs/${slug}` : `/${lang}/docs/${slug}`;
48
+ return withBase(path);
49
+ }
50
+
51
+ /** Check if a URL is external (starts with http:// or https://). */
52
+ export function isExternal(href: string): boolean {
53
+ return href.startsWith("http://") || href.startsWith("https://");
54
+ }
55
+
56
+ /** Resolve a href: external URLs pass through, internal ones get the base prefix. */
57
+ export function resolveHref(href: string): string {
58
+ return isExternal(href) ? href : withBase(href);
59
+ }
60
+
61
+ /**
62
+ * Build a localized, versioned nav href.
63
+ * Note: uses /{lang}/v/{version}/... ordering (for header/sidebar nav links).
64
+ * This differs from versionedDocsUrl() which uses /v/{version}/{lang}/... (for doc page links).
65
+ * Both orderings are handled by the routing layer.
66
+ */
67
+ export function navHref(
68
+ path: string,
69
+ lang: Locale | undefined,
70
+ currentVersion: string | undefined,
71
+ ): string {
72
+ const isNonDefaultLocale = lang != null && lang !== defaultLocale;
73
+ const versionPrefix = currentVersion ? `/v/${currentVersion}` : "";
74
+ return withBase(
75
+ isNonDefaultLocale
76
+ ? `/${lang}${versionPrefix}${path}`
77
+ : `${versionPrefix}${path}`,
78
+ );
79
+ }
80
+
81
+ /** Build a locale-switched path from the current page path. */
82
+ export function getPathForLocale(
83
+ path: string,
84
+ currentLang: Locale,
85
+ targetLang: Locale,
86
+ ): string {
87
+ let relativePath = stripBase(path);
88
+ if (currentLang !== defaultLocale) {
89
+ relativePath = relativePath.replace(new RegExp(`^/${currentLang}/`), "/");
90
+ }
91
+ if (targetLang !== defaultLocale) {
92
+ relativePath = `/${targetLang}${relativePath}`;
93
+ }
94
+ return withBase(relativePath);
95
+ }
96
+
97
+ /** Build locale links for locale switcher UI components. */
98
+ export function buildLocaleLinks(currentPath: string, currentLang: Locale): LocaleLink[] {
99
+ let defaultLocalePath = stripBase(currentPath);
100
+ if (currentLang !== defaultLocale) {
101
+ defaultLocalePath = defaultLocalePath.replace(new RegExp(`^/${currentLang}/`), "/");
102
+ }
103
+ if (isDefaultLocaleOnlyPath(defaultLocalePath)) {
104
+ return [{
105
+ code: currentLang,
106
+ label: getLocaleLabel(currentLang),
107
+ href: getPathForLocale(currentPath, currentLang, currentLang),
108
+ active: true,
109
+ }];
110
+ }
111
+ return locales.map((code) => ({
112
+ code,
113
+ label: getLocaleLabel(code),
114
+ href: getPathForLocale(currentPath, currentLang, code),
115
+ active: code === currentLang,
116
+ }));
117
+ }
118
+
119
+ /**
120
+ * Returns true when the given default-locale-shaped path falls under one of
121
+ * the configured `defaultLocaleOnlyPrefixes`. Callers that work with
122
+ * locale-prefixed paths (e.g. `/ja/docs/...`) are responsible for stripping
123
+ * the locale segment before calling this function. The path is normalized to
124
+ * end with `/` before the comparison so the helper is robust to projects that
125
+ * disable `settings.trailingSlash` (where `docsUrl` returns slashless paths).
126
+ */
127
+ export function isDefaultLocaleOnlyPath(path: string): boolean {
128
+ const stripped = stripBase(path);
129
+ const normalized = stripped.endsWith("/") ? stripped : `${stripped}/`;
130
+ return settings.defaultLocaleOnlyPrefixes.some((prefix) => normalized.startsWith(prefix));
131
+ }
132
+
133
+ /** Build a versioned docs URL for the given slug, version, and lang. */
134
+ export function versionedDocsUrl(slug: string, versionSlug: string, lang: Locale = defaultLocale): string {
135
+ const path = lang === defaultLocale
136
+ ? `/v/${versionSlug}/docs/${slug}`
137
+ : `/v/${versionSlug}/${lang}/docs/${slug}`;
138
+ return withBase(path);
139
+ }
@@ -0,0 +1,106 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import matter from "gray-matter";
4
+ import { settings } from "../config/settings";
5
+
6
+ /** Strip markdown formatting to produce plain text */
7
+ export function stripMarkdown(md: string): string {
8
+ return (
9
+ md
10
+ // Remove code blocks
11
+ .replace(/```[\s\S]*?```/g, "")
12
+ .replace(/`[^`]+`/g, "")
13
+ // Remove HTML tags
14
+ .replace(/<[^>]+>/g, "")
15
+ // Remove headings markers
16
+ .replace(/^#{1,6}\s+/gm, "")
17
+ // Remove emphasis/bold markers
18
+ .replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1")
19
+ .replace(/_{1,3}([^_]+)_{1,3}/g, "$1")
20
+ // Remove images (must run before link removal)
21
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, "")
22
+ // Remove links but keep text
23
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
24
+ // Remove blockquote markers
25
+ .replace(/^>\s+/gm, "")
26
+ // Remove horizontal rules
27
+ .replace(/^[-*_]{3,}\s*$/gm, "")
28
+ // Remove list markers
29
+ .replace(/^[\s]*[-*+]\s+/gm, "")
30
+ .replace(/^[\s]*\d+\.\s+/gm, "")
31
+ // Remove import statements
32
+ .replace(/^import\s+.*$/gm, "")
33
+ // Remove export statements
34
+ .replace(/^export\s+.*$/gm, "")
35
+ // Collapse whitespace
36
+ .replace(/\n{3,}/g, "\n\n")
37
+ .trim()
38
+ );
39
+ }
40
+
41
+ /** Walk a directory and collect all .md/.mdx files */
42
+ export function collectMdFiles(
43
+ dir: string,
44
+ ): Array<{ filePath: string; slug: string }> {
45
+ const results: Array<{ filePath: string; slug: string }> = [];
46
+
47
+ function walk(currentDir: string, baseDir: string): void {
48
+ let entries;
49
+ try {
50
+ entries = readdirSync(currentDir, { withFileTypes: true });
51
+ } catch {
52
+ return;
53
+ }
54
+ for (const entry of entries) {
55
+ const fullPath = join(currentDir, entry.name);
56
+ if (entry.isDirectory()) {
57
+ walk(fullPath, baseDir);
58
+ } else if (/\.mdx?$/.test(entry.name) && !entry.name.startsWith("_")) {
59
+ const rel = relative(baseDir, fullPath)
60
+ .replace(/\.mdx?$/, "")
61
+ .replace(/\/index$/, "");
62
+ results.push({ filePath: fullPath, slug: rel });
63
+ }
64
+ }
65
+ }
66
+
67
+ walk(dir, dir);
68
+ return results;
69
+ }
70
+
71
+ /** Compute a URL from a slug and locale. When absolute is true and siteUrl is configured, returns a full URL. */
72
+ export function slugToUrl(slug: string, locale: string | null, absolute = false): string {
73
+ const base = settings.base.replace(/\/$/, "");
74
+ const path = locale ? `${base}/${locale}/docs/${slug}` : `${base}/docs/${slug}`;
75
+ if (absolute && settings.siteUrl) {
76
+ return `${settings.siteUrl.replace(/\/$/, "")}${path}`;
77
+ }
78
+ return path;
79
+ }
80
+
81
+ /** Frontmatter fields used across the project */
82
+ export interface DocFrontmatter {
83
+ title?: string;
84
+ description?: string;
85
+ sidebar_position?: number;
86
+ category?: string;
87
+ draft?: boolean;
88
+ unlisted?: boolean;
89
+ search_exclude?: boolean;
90
+ [key: string]: unknown;
91
+ }
92
+
93
+ /** Parse a markdown file and return frontmatter + content */
94
+ export function parseMarkdownFile(filePath: string): { data: DocFrontmatter; content: string } | null {
95
+ try {
96
+ const raw = readFileSync(filePath, "utf-8");
97
+ return matter(raw);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /** Check if a document should be excluded from indexing */
104
+ export function isExcluded(data: DocFrontmatter): boolean {
105
+ return !!(data.search_exclude || data.draft || data.unlisted);
106
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Strip common leading whitespace from all lines of a template literal string.
3
+ * Similar to Python's textwrap.dedent().
4
+ */
5
+ export function dedent(text: string): string {
6
+ const lines = text.split('\n');
7
+
8
+ // Find minimum indentation (ignoring empty/whitespace-only lines)
9
+ let minIndent = Infinity;
10
+ for (const line of lines) {
11
+ if (line.trim().length === 0) continue;
12
+ const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
13
+ if (indent < minIndent) minIndent = indent;
14
+ }
15
+
16
+ if (minIndent === 0 || minIndent === Infinity) {
17
+ return text.trim();
18
+ }
19
+
20
+ return lines
21
+ .map((line) => (line.trim().length === 0 ? '' : line.slice(minIndent)))
22
+ .join('\n')
23
+ .trim();
24
+ }
@@ -0,0 +1,335 @@
1
+ import type { DocsEntry } from "@/types/docs-entry";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { toTitleCase, toRouteSlug } from "@/utils/slug";
5
+ import { docsUrl, withBase } from "@/utils/base";
6
+ import { defaultLocale, type Locale } from "@/config/i18n";
7
+
8
+ /** Filter predicate: true when a doc should appear in navigation (sidebar, index, sitemap). */
9
+ export function isNavVisible(doc: DocsEntry): boolean {
10
+ return !doc.data.unlisted && !doc.data.standalone;
11
+ }
12
+
13
+ export interface CategoryMeta {
14
+ label?: string;
15
+ position?: number;
16
+ description?: string;
17
+ sortOrder?: "asc" | "desc";
18
+ noPage?: boolean;
19
+ }
20
+
21
+ export interface NavNode {
22
+ slug: string;
23
+ label: string;
24
+ description?: string;
25
+ position: number;
26
+ href?: string;
27
+ hasPage: boolean;
28
+ children: NavNode[];
29
+ sortOrder?: "asc" | "desc";
30
+ collapsed?: boolean;
31
+ }
32
+
33
+ interface BuildNode {
34
+ segment: string;
35
+ fullPath: string;
36
+ doc?: DocsEntry;
37
+ children: Map<string, BuildNode>;
38
+ }
39
+
40
+ // Module-level caches — persist across all page renders during a single Astro build.
41
+ const categoryMetaCache = new Map<string, Map<string, CategoryMeta>>();
42
+ const navTreeCache = new Map<string, NavNode[]>();
43
+
44
+ /** Build a cache key from docs array + locale + category meta.
45
+ * Includes nav-affecting frontmatter so HMR picks up changes. */
46
+ function navTreeCacheKey(
47
+ docs: DocsEntry[],
48
+ lang: Locale,
49
+ categoryMeta?: Map<string, CategoryMeta>,
50
+ ): string {
51
+ const metaKey = categoryMeta
52
+ ? JSON.stringify([...categoryMeta.entries()].sort(([a], [b]) => a.localeCompare(b)))
53
+ : "_";
54
+ return `${lang}:${metaKey}:${docs
55
+ .map((d) => {
56
+ const { sidebar_position, sidebar_label, title, description, unlisted, standalone, slug } =
57
+ d.data;
58
+ return JSON.stringify([
59
+ d.id,
60
+ sidebar_position,
61
+ sidebar_label,
62
+ title,
63
+ description,
64
+ unlisted,
65
+ standalone,
66
+ slug,
67
+ ]);
68
+ })
69
+ .sort()
70
+ .join(",")}`;
71
+ }
72
+
73
+ /**
74
+ * Build a recursive navigation tree from a flat Astro content collection.
75
+ * Mirrors the filesystem: directories become category nodes, files become leaves.
76
+ *
77
+ * Astro 5 glob() strips /index from IDs:
78
+ * getting-started/index.mdx → ID "getting-started" (category index)
79
+ * getting-started/intro.mdx → ID "getting-started/intro" (child page)
80
+ */
81
+ export function buildNavTree(
82
+ docs: DocsEntry[],
83
+ lang: Locale = defaultLocale,
84
+ categoryMeta?: Map<string, CategoryMeta>,
85
+ ): NavNode[] {
86
+ const cacheKey = navTreeCacheKey(docs, lang, categoryMeta);
87
+ const cached = navTreeCache.get(cacheKey);
88
+ if (cached) return cached;
89
+
90
+ const root: BuildNode = {
91
+ segment: "",
92
+ fullPath: "",
93
+ children: new Map(),
94
+ };
95
+
96
+ for (const doc of docs) {
97
+ const slug = doc.data.slug ?? toRouteSlug(doc.id);
98
+ const parts = slug.split("/");
99
+
100
+ if (parts.length <= 1) {
101
+ // Category index: Astro 5 stripped /index → single segment like "guides"
102
+ const segment = doc.id;
103
+ if (!root.children.has(segment)) {
104
+ root.children.set(segment, {
105
+ segment,
106
+ fullPath: segment,
107
+ children: new Map(),
108
+ });
109
+ }
110
+ root.children.get(segment)!.doc = doc;
111
+ } else {
112
+ // Multi-segment: walk the tree creating intermediate nodes as needed
113
+ let current = root;
114
+ for (let i = 0; i < parts.length; i++) {
115
+ const segment = parts[i];
116
+ const fullPath = parts.slice(0, i + 1).join("/");
117
+ if (!current.children.has(segment)) {
118
+ current.children.set(segment, {
119
+ segment,
120
+ fullPath,
121
+ children: new Map(),
122
+ });
123
+ }
124
+ if (i === parts.length - 1) {
125
+ current.children.get(segment)!.doc = doc;
126
+ }
127
+ current = current.children.get(segment)!;
128
+ }
129
+ }
130
+ }
131
+
132
+ const result = toNavNodes(root, lang, categoryMeta);
133
+ navTreeCache.set(cacheKey, result);
134
+ return result;
135
+ }
136
+
137
+ function toNavNodes(
138
+ parent: BuildNode,
139
+ lang: Locale,
140
+ categoryMeta?: Map<string, CategoryMeta>,
141
+ parentSortOrder?: "asc" | "desc",
142
+ ): NavNode[] {
143
+ const nodes: NavNode[] = [];
144
+
145
+ for (const child of parent.children.values()) {
146
+ const doc = child.doc;
147
+ const meta = categoryMeta?.get(child.fullPath);
148
+ const sortOrder = meta?.sortOrder ?? "asc";
149
+ const children = toNavNodes(child, lang, categoryMeta, sortOrder);
150
+
151
+ nodes.push({
152
+ slug: child.fullPath,
153
+ label:
154
+ doc?.data.sidebar_label ?? doc?.data.title ?? meta?.label ?? toTitleCase(child.segment),
155
+ description: doc?.data.description ?? meta?.description,
156
+ position: doc?.data.sidebar_position ?? meta?.position ?? 999,
157
+ href: meta?.noPage
158
+ ? undefined
159
+ : doc || children.length > 0
160
+ ? docsUrl(child.fullPath, lang)
161
+ : undefined,
162
+ hasPage: !!doc,
163
+ children,
164
+ sortOrder,
165
+ });
166
+ }
167
+
168
+ // Use the PARENT's sortOrder to sort these sibling nodes
169
+ const order = parentSortOrder ?? "asc";
170
+ nodes.sort((a, b) => {
171
+ const posCompare = a.position - b.position;
172
+ if (posCompare !== 0) return order === "desc" ? -posCompare : posCompare;
173
+ const slugCompare = a.slug.localeCompare(b.slug);
174
+ return order === "desc" ? -slugCompare : slugCompare;
175
+ });
176
+
177
+ return nodes;
178
+ }
179
+
180
+ /**
181
+ * Group "satellite" nodes under their primary node based on slug prefixes.
182
+ * E.g. with prefix "claude", nodes "claude-md", "claude-commands" get moved
183
+ * under the "claude" node as children.
184
+ */
185
+ export function groupSatelliteNodes(tree: NavNode[], prefixes: string[]): NavNode[] {
186
+ const result = [...tree];
187
+ for (const prefix of prefixes) {
188
+ const primaryIdx = result.findIndex((n) => n.slug === prefix);
189
+ if (primaryIdx < 0) continue;
190
+ const primary = result[primaryIdx];
191
+ const satelliteIdxs: number[] = [];
192
+ for (let i = 0; i < result.length; i++) {
193
+ if (i !== primaryIdx && result[i].slug.startsWith(`${prefix}-`)) {
194
+ satelliteIdxs.push(i);
195
+ }
196
+ }
197
+ if (satelliteIdxs.length === 0) continue;
198
+ const extraChildren: NavNode[] = [];
199
+ for (const idx of satelliteIdxs) {
200
+ extraChildren.push(result[idx]);
201
+ }
202
+ result[primaryIdx] = {
203
+ ...primary,
204
+ children: [...primary.children, ...extraChildren],
205
+ };
206
+ for (const idx of satelliteIdxs.reverse()) {
207
+ result.splice(idx, 1);
208
+ }
209
+ }
210
+ return result;
211
+ }
212
+
213
+ /** DFS flatten the tree for prev/next navigation. Only includes nodes with pages. */
214
+ export function flattenTree(nodes: NavNode[]): NavNode[] {
215
+ const result: NavNode[] = [];
216
+ flattenInto(nodes, result);
217
+ return result;
218
+ }
219
+
220
+ function flattenInto(nodes: NavNode[], acc: NavNode[]): void {
221
+ for (const node of nodes) {
222
+ if (node.hasPage) {
223
+ acc.push(node);
224
+ }
225
+ flattenInto(node.children, acc);
226
+ }
227
+ }
228
+
229
+ /** Collect all category nodes that have children but no page (no index.mdx).
230
+ * Nodes without href (e.g. noPage categories) are skipped — they are toggle-only. */
231
+ export function collectAutoIndexNodes(nodes: NavNode[]): NavNode[] {
232
+ const result: NavNode[] = [];
233
+ for (const node of nodes) {
234
+ if (!node.hasPage && node.children.length > 0 && node.href) {
235
+ result.push(node);
236
+ }
237
+ result.push(...collectAutoIndexNodes(node.children));
238
+ }
239
+ return result;
240
+ }
241
+
242
+ /** Find a node by slug anywhere in the tree. */
243
+ export function findNode(nodes: NavNode[], slug: string): NavNode | undefined {
244
+ for (const node of nodes) {
245
+ if (node.slug === slug) return node;
246
+ const found = findNode(node.children, slug);
247
+ if (found) return found;
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ export interface BreadcrumbItem {
253
+ label: string;
254
+ href?: string;
255
+ }
256
+
257
+ /**
258
+ * Build breadcrumb trail by walking the nav tree.
259
+ */
260
+ export function buildBreadcrumbs(
261
+ tree: NavNode[],
262
+ slug: string,
263
+ lang: Locale = defaultLocale,
264
+ ): BreadcrumbItem[] {
265
+ const parts = slug.split("/");
266
+ const homeHref = lang === defaultLocale ? withBase("/") : withBase(`/${lang}/`);
267
+ const crumbs: BreadcrumbItem[] = [{ label: "", href: homeHref }];
268
+ let nodes = tree;
269
+
270
+ for (let i = 0; i < parts.length; i++) {
271
+ const partialSlug = parts.slice(0, i + 1).join("/");
272
+ const node = nodes.find((n) => n.slug === partialSlug);
273
+ if (!node) break;
274
+
275
+ const isLast = i === parts.length - 1;
276
+ crumbs.push({
277
+ label: node.label,
278
+ href: isLast ? undefined : node.href,
279
+ });
280
+ nodes = node.children;
281
+ }
282
+
283
+ return crumbs;
284
+ }
285
+
286
+ /**
287
+ * Scan a content directory for _category_.json files and return a map
288
+ * of relative paths to category metadata.
289
+ * Results are memoized by contentDir.
290
+ */
291
+ export function loadCategoryMeta(contentDir: string): Map<string, CategoryMeta> {
292
+ const cached = categoryMetaCache.get(contentDir);
293
+ if (cached) return cached;
294
+ const result = new Map<string, CategoryMeta>();
295
+ scanDir(contentDir, contentDir, result);
296
+ categoryMetaCache.set(contentDir, result);
297
+ return result;
298
+ }
299
+
300
+ function scanDir(baseDir: string, currentDir: string, result: Map<string, CategoryMeta>): void {
301
+ let entries: fs.Dirent[];
302
+ try {
303
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
304
+ } catch {
305
+ return;
306
+ }
307
+
308
+ for (const entry of entries) {
309
+ if (entry.isDirectory()) {
310
+ const fullPath = path.join(currentDir, entry.name);
311
+ const categoryFile = path.join(fullPath, "_category_.json");
312
+ if (fs.existsSync(categoryFile)) {
313
+ try {
314
+ const raw = fs.readFileSync(categoryFile, "utf-8");
315
+ const parsed: unknown = JSON.parse(raw);
316
+ if (typeof parsed === "object" && parsed !== null) {
317
+ const obj = parsed as Record<string, unknown>;
318
+ const meta: CategoryMeta = {
319
+ label: typeof obj.label === "string" ? obj.label : undefined,
320
+ position: typeof obj.position === "number" ? obj.position : undefined,
321
+ description: typeof obj.description === "string" ? obj.description : undefined,
322
+ sortOrder: obj.sortOrder === "asc" || obj.sortOrder === "desc" ? obj.sortOrder : undefined,
323
+ noPage: obj.noPage === true ? true : undefined,
324
+ };
325
+ const relativePath = path.relative(baseDir, fullPath);
326
+ result.set(relativePath, meta);
327
+ }
328
+ } catch {
329
+ // skip invalid JSON
330
+ }
331
+ }
332
+ scanDir(baseDir, fullPath, result);
333
+ }
334
+ }
335
+ }