create-zudo-doc 0.2.0 → 0.2.2

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 (83) hide show
  1. package/dist/api.js +4 -1
  2. package/dist/cli.js +4 -6
  3. package/dist/compose.d.ts +2 -3
  4. package/dist/compose.js +7 -4
  5. package/dist/features/tauri.d.ts +10 -5
  6. package/dist/features/tauri.js +49 -6
  7. package/dist/preset.js +11 -0
  8. package/dist/prompts.js +2 -6
  9. package/dist/scaffold.js +15 -9
  10. package/dist/settings-gen.js +9 -6
  11. package/dist/utils.d.ts +8 -0
  12. package/dist/utils.js +25 -0
  13. package/dist/zfb-config-gen.js +11 -50
  14. package/package.json +1 -1
  15. package/templates/base/pages/_data.ts +10 -23
  16. package/templates/base/pages/docs/[[...slug]].tsx +27 -168
  17. package/templates/base/pages/lib/_body-end-islands.tsx +3 -0
  18. package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
  19. package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
  20. package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
  21. package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
  22. package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
  23. package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
  24. package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
  25. package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
  26. package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
  27. package/templates/base/pages/lib/_header-with-defaults.tsx +54 -89
  28. package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
  29. package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
  30. package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
  31. package/templates/base/pages/lib/_search-widget-script.ts +32 -9
  32. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
  33. package/templates/base/pages/lib/locale-merge.ts +1 -1
  34. package/templates/base/pages/lib/route-enumerators.ts +11 -7
  35. package/templates/base/plugins/connect-adapter.mjs +30 -1
  36. package/templates/base/plugins/copy-public-plugin.mjs +10 -2
  37. package/templates/base/plugins/search-index-plugin.mjs +20 -8
  38. package/templates/base/src/components/ai-chat-modal.tsx +2 -0
  39. package/templates/base/src/components/doc-history.tsx +2 -0
  40. package/templates/base/src/components/image-enlarge.tsx +2 -0
  41. package/templates/base/src/components/sidebar-toggle.tsx +1 -1
  42. package/templates/base/src/components/sidebar-tree.tsx +11 -5
  43. package/templates/base/src/components/theme-toggle.tsx +18 -102
  44. package/templates/base/src/config/color-schemes.ts +4 -0
  45. package/templates/base/src/config/docs-schema.ts +94 -0
  46. package/templates/base/src/config/i18n.ts +10 -3
  47. package/templates/base/src/styles/global.css +14 -0
  48. package/templates/base/src/types/docs-entry.ts +8 -26
  49. package/templates/base/src/utils/base.ts +5 -3
  50. package/templates/base/src/utils/docs.ts +144 -169
  51. package/templates/base/zfb-shim.d.ts +167 -0
  52. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
  53. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
  54. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
  55. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
  56. package/templates/features/docHistory/files/src/components/doc-history.tsx +30 -8
  57. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
  58. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
  59. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
  60. package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
  61. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
  62. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
  63. package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
  64. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +2 -0
  65. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
  66. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
  67. package/templates/features/tauri/files/src/components/find-in-page-init.tsx +9 -3
  68. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
  69. package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
  70. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
  71. package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
  72. package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
  73. package/templates/base/src/components/content/heading-h3.tsx +0 -20
  74. package/templates/base/src/hooks/use-active-heading.ts +0 -133
  75. package/templates/base/src/plugins/docs-source-map.ts +0 -103
  76. package/templates/base/src/plugins/hast-utils.ts +0 -10
  77. package/templates/base/src/plugins/rehype-code-title.ts +0 -50
  78. package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
  79. package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
  80. package/templates/base/src/plugins/url-utils.ts +0 -4
  81. package/templates/base/src/utils/dedent.ts +0 -24
  82. package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
  83. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +0 -198
@@ -1,133 +0,0 @@
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
- }
@@ -1,103 +0,0 @@
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
- }
@@ -1,10 +0,0 @@
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
- }
@@ -1,50 +0,0 @@
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
- }
@@ -1,53 +0,0 @@
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
- * heading text extraction (used for TOC).
14
- *
15
- * Sets heading IDs itself (via github-slugger); skips headings that already
16
- * have an ID assigned upstream.
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
- }
@@ -1,41 +0,0 @@
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
- }
@@ -1,4 +0,0 @@
1
- /** Check if a URL is external (has a protocol scheme). */
2
- export function isExternal(url: string): boolean {
3
- return /^[a-z][a-z0-9+.-]*:/i.test(url);
4
- }
@@ -1,24 +0,0 @@
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
- }
@@ -1,180 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import path from "node:path";
3
- import type { DocHistoryEntry, DocHistoryData } from "@/types/doc-history";
4
- import { collectMdFiles } from "./content-files";
5
-
6
- /** Shared options to suppress git stderr noise */
7
- const QUIET: { encoding: "utf-8"; stdio: ["pipe", "pipe", "pipe"] } = {
8
- encoding: "utf-8",
9
- stdio: ["pipe", "pipe", "pipe"],
10
- };
11
-
12
- /** Cache the repo root to avoid repeated git calls */
13
- let repoRootCache: string | null = null;
14
-
15
- function getRepoRoot(): string {
16
- if (repoRootCache) return repoRootCache;
17
- repoRootCache = execFileSync("git", ["rev-parse", "--show-toplevel"], {
18
- encoding: "utf-8",
19
- }).trim();
20
- return repoRootCache;
21
- }
22
-
23
- /** Convert an absolute path to a repo-relative path for git commands */
24
- function toRepoRelative(absolutePath: string): string {
25
- return path.relative(getRepoRoot(), absolutePath);
26
- }
27
-
28
- /**
29
- * Get the list of commit hashes that touched a file, newest first.
30
- * Uses --follow to track renames.
31
- * Limits to maxEntries commits (default 50).
32
- */
33
- export function getFileCommits(
34
- filePath: string,
35
- maxEntries = 50,
36
- ): string[] {
37
- try {
38
- const output = execFileSync(
39
- "git",
40
- [
41
- "log",
42
- "--follow",
43
- "--format=%H",
44
- "-n",
45
- String(maxEntries),
46
- "--",
47
- filePath,
48
- ],
49
- QUIET,
50
- ).trim();
51
- return output ? [...new Set(output.split("\n"))] : [];
52
- } catch {
53
- return [];
54
- }
55
- }
56
-
57
- /**
58
- * Get metadata for a specific commit on a file.
59
- * Returns { hash, date, author, message } with full hash for unique identification.
60
- */
61
- export function getCommitInfo(
62
- hash: string,
63
- filePath: string,
64
- ): Omit<DocHistoryEntry, "content"> {
65
- try {
66
- const output = execFileSync(
67
- "git",
68
- ["log", "-1", "--format=%H%n%aI%n%aN%n%s", hash, "--", filePath],
69
- QUIET,
70
- ).trim();
71
- const lines = output.split("\n");
72
- return {
73
- hash: lines[0] ?? hash,
74
- date: lines[1] ?? "",
75
- author: lines[2] ?? "",
76
- message: lines[3] ?? "",
77
- };
78
- } catch {
79
- return { hash, date: "", author: "", message: "" };
80
- }
81
- }
82
-
83
- /**
84
- * Get the file content at a specific commit.
85
- * Accepts absolute paths and converts to repo-relative for git show.
86
- * Handles renamed files by falling back to the old path via git log --follow.
87
- */
88
- export function getFileAtCommit(hash: string, filePath: string): string {
89
- const relPath = path.isAbsolute(filePath)
90
- ? toRepoRelative(filePath)
91
- : filePath;
92
-
93
- try {
94
- return execFileSync("git", ["show", `${hash}:${relPath}`], QUIET);
95
- } catch {
96
- // File may have been renamed — find the old path at this commit
97
- try {
98
- const oldPath = execFileSync(
99
- "git",
100
- [
101
- "log",
102
- "-1",
103
- "--follow",
104
- "--diff-filter=R",
105
- "--format=",
106
- "--name-only",
107
- hash,
108
- "--",
109
- relPath,
110
- ],
111
- QUIET,
112
- ).trim();
113
- if (oldPath) {
114
- return execFileSync("git", ["show", `${hash}:${oldPath}`], QUIET);
115
- }
116
- } catch {
117
- // ignore
118
- }
119
-
120
- // Last resort: use git log --follow to find the path at this revision
121
- try {
122
- const followOutput = execFileSync(
123
- "git",
124
- [
125
- "log",
126
- "--follow",
127
- "--format=%H",
128
- "--name-only",
129
- "--diff-filter=AMRC",
130
- "--",
131
- relPath,
132
- ],
133
- QUIET,
134
- ).trim();
135
- const lines = followOutput.split("\n").filter(Boolean);
136
- // Lines alternate: hash, filename, hash, filename...
137
- for (let i = 0; i < lines.length - 1; i += 2) {
138
- if (lines[i] === hash && lines[i + 1]) {
139
- return execFileSync(
140
- "git",
141
- ["show", `${hash}:${lines[i + 1]}`],
142
- QUIET,
143
- );
144
- }
145
- }
146
- } catch {
147
- // ignore
148
- }
149
-
150
- return "";
151
- }
152
- }
153
-
154
- /**
155
- * Get the complete history for a document file.
156
- * Returns DocHistoryData with all entries populated.
157
- */
158
- export function getDocHistory(
159
- filePath: string,
160
- slug: string,
161
- maxEntries = 50,
162
- ): DocHistoryData {
163
- const commits = getFileCommits(filePath, maxEntries);
164
- const entries: DocHistoryEntry[] = commits.map((hash) => {
165
- const info = getCommitInfo(hash, filePath);
166
- const content = getFileAtCommit(hash, filePath);
167
- return { ...info, content };
168
- });
169
- return { slug, filePath, entries };
170
- }
171
-
172
- /**
173
- * Collect all MDX/md files in a content directory.
174
- * Delegates to the shared collectMdFiles utility from content-files.ts.
175
- */
176
- export function collectContentFiles(
177
- contentDir: string,
178
- ): Array<{ filePath: string; slug: string }> {
179
- return collectMdFiles(contentDir);
180
- }