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.
- package/dist/api.js +4 -1
- package/dist/cli.js +4 -6
- package/dist/compose.d.ts +2 -3
- package/dist/compose.js +7 -4
- package/dist/features/tauri.d.ts +10 -5
- package/dist/features/tauri.js +49 -6
- package/dist/preset.js +11 -0
- package/dist/prompts.js +2 -6
- package/dist/scaffold.js +15 -9
- package/dist/settings-gen.js +9 -6
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +25 -0
- package/dist/zfb-config-gen.js +11 -50
- package/package.json +1 -1
- package/templates/base/pages/_data.ts +10 -23
- package/templates/base/pages/docs/[[...slug]].tsx +27 -168
- package/templates/base/pages/lib/_body-end-islands.tsx +3 -0
- package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
- package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
- package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
- package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
- package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
- package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
- package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
- package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
- package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
- package/templates/base/pages/lib/_header-with-defaults.tsx +54 -89
- package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
- package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
- package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
- package/templates/base/pages/lib/_search-widget-script.ts +32 -9
- package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
- package/templates/base/pages/lib/locale-merge.ts +1 -1
- package/templates/base/pages/lib/route-enumerators.ts +11 -7
- package/templates/base/plugins/connect-adapter.mjs +30 -1
- package/templates/base/plugins/copy-public-plugin.mjs +10 -2
- package/templates/base/plugins/search-index-plugin.mjs +20 -8
- package/templates/base/src/components/ai-chat-modal.tsx +2 -0
- package/templates/base/src/components/doc-history.tsx +2 -0
- package/templates/base/src/components/image-enlarge.tsx +2 -0
- package/templates/base/src/components/sidebar-toggle.tsx +1 -1
- package/templates/base/src/components/sidebar-tree.tsx +11 -5
- package/templates/base/src/components/theme-toggle.tsx +18 -102
- package/templates/base/src/config/color-schemes.ts +4 -0
- package/templates/base/src/config/docs-schema.ts +94 -0
- package/templates/base/src/config/i18n.ts +10 -3
- package/templates/base/src/styles/global.css +14 -0
- package/templates/base/src/types/docs-entry.ts +8 -26
- package/templates/base/src/utils/base.ts +5 -3
- package/templates/base/src/utils/docs.ts +144 -169
- package/templates/base/zfb-shim.d.ts +167 -0
- package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
- package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
- package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
- package/templates/features/docHistory/files/src/components/doc-history.tsx +30 -8
- package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
- package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
- package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
- package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
- package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
- package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
- package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
- package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +2 -0
- package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
- package/templates/features/tauri/files/src/components/find-in-page-init.tsx +9 -3
- package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
- package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
- package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
- package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
- package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
- package/templates/base/src/components/content/heading-h3.tsx +0 -20
- package/templates/base/src/hooks/use-active-heading.ts +0 -133
- package/templates/base/src/plugins/docs-source-map.ts +0 -103
- package/templates/base/src/plugins/hast-utils.ts +0 -10
- package/templates/base/src/plugins/rehype-code-title.ts +0 -50
- package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
- package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
- package/templates/base/src/plugins/url-utils.ts +0 -4
- package/templates/base/src/utils/dedent.ts +0 -24
- package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
- 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,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
|
-
}
|