docsprout 0.1.3

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/dist/index.cjs +9725 -0
  4. package/dist/index.d.cts +1 -0
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +9730 -0
  7. package/package.json +36 -0
  8. package/templates/base/config.json +18 -0
  9. package/templates/base/content/index.md +10 -0
  10. package/templates/base/generated/pages.json +2 -0
  11. package/templates/base/generated/search-index.json +2 -0
  12. package/templates/base/sidebar.json +9 -0
  13. package/templates/base/themes/default.json +8 -0
  14. package/templates/web/app/(public)/docs/[[...slug]]/loading.tsx +9 -0
  15. package/templates/web/app/(public)/docs/[[...slug]]/page.tsx +74 -0
  16. package/templates/web/app/api/config/route.ts +47 -0
  17. package/templates/web/app/api/publish/route.ts +82 -0
  18. package/templates/web/app/api/search/route.ts +11 -0
  19. package/templates/web/app/docs-admin/loading.tsx +9 -0
  20. package/templates/web/app/docs-admin/page.tsx +237 -0
  21. package/templates/web/app/globals.css +141 -0
  22. package/templates/web/app/layout.tsx +46 -0
  23. package/templates/web/app/page.tsx +17 -0
  24. package/templates/web/components/markdown-content.tsx +100 -0
  25. package/templates/web/components/providers.tsx +12 -0
  26. package/templates/web/components/rich-editor.tsx +94 -0
  27. package/templates/web/components/search-box.tsx +43 -0
  28. package/templates/web/components/sidebar.tsx +36 -0
  29. package/templates/web/components/toc.tsx +53 -0
  30. package/templates/web/lib/content.ts +41 -0
  31. package/templates/web/lib/db.ts +9 -0
  32. package/templates/web/lib/types.ts +33 -0
  33. package/templates/web/next-env.d.ts +5 -0
  34. package/templates/web/next.config.mjs +16 -0
  35. package/templates/web/package.json +40 -0
  36. package/templates/web/postcss.config.cjs +8 -0
  37. package/templates/web/prisma/schema.prisma +21 -0
  38. package/templates/web/tailwind.config.cjs +10 -0
  39. package/templates/web/tsconfig.json +20 -0
@@ -0,0 +1,141 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --bg: #f8fafc;
7
+ --fg: #0f172a;
8
+ --glow-a: rgba(34, 197, 94, 0.18);
9
+ --glow-b: rgba(14, 165, 233, 0.14);
10
+ --link: #059669;
11
+ }
12
+
13
+ [data-docsprout-theme="default"] {
14
+ --glow-a: rgba(34, 197, 94, 0.18);
15
+ --glow-b: rgba(14, 165, 233, 0.14);
16
+ --link: #059669;
17
+ }
18
+
19
+ [data-docsprout-theme="modern"] {
20
+ --glow-a: rgba(99, 102, 241, 0.2);
21
+ --glow-b: rgba(6, 182, 212, 0.18);
22
+ --link: #4f46e5;
23
+ }
24
+
25
+ [data-docsprout-theme="minimal"] {
26
+ --glow-a: rgba(148, 163, 184, 0.12);
27
+ --glow-b: rgba(148, 163, 184, 0.1);
28
+ --link: #334155;
29
+ }
30
+
31
+ [data-docsprout-theme="classic"] {
32
+ --glow-a: rgba(217, 119, 6, 0.16);
33
+ --glow-b: rgba(220, 38, 38, 0.14);
34
+ --link: #b45309;
35
+ }
36
+
37
+ [data-docsprout-theme="ocean"] {
38
+ --glow-a: rgba(2, 132, 199, 0.2);
39
+ --glow-b: rgba(15, 118, 110, 0.18);
40
+ --link: #0369a1;
41
+ }
42
+
43
+ [data-docsprout-theme="forest"] {
44
+ --glow-a: rgba(21, 128, 61, 0.2);
45
+ --glow-b: rgba(132, 204, 22, 0.16);
46
+ --link: #166534;
47
+ }
48
+
49
+ [data-docsprout-theme="midnight"] {
50
+ --glow-a: rgba(30, 64, 175, 0.22);
51
+ --glow-b: rgba(88, 28, 135, 0.2);
52
+ --link: #1d4ed8;
53
+ }
54
+
55
+ .dark {
56
+ --bg: #020617;
57
+ --fg: #e2e8f0;
58
+ }
59
+
60
+ html,
61
+ body {
62
+ overflow-x: clip;
63
+ }
64
+
65
+ body {
66
+ min-height: 100vh;
67
+ background:
68
+ radial-gradient(900px 500px at 0% -10%, var(--glow-a), transparent 60%),
69
+ radial-gradient(900px 500px at 100% -10%, var(--glow-b), transparent 60%),
70
+ var(--bg);
71
+ color: var(--fg);
72
+ }
73
+
74
+ .markdown-content {
75
+ @apply text-slate-700 dark:text-slate-300;
76
+ }
77
+
78
+ .markdown-content h1,
79
+ .markdown-content h2,
80
+ .markdown-content h3,
81
+ .markdown-content h4 {
82
+ scroll-margin-top: 92px;
83
+ }
84
+
85
+ .markdown-content h1,
86
+ .markdown-content h2,
87
+ .markdown-content h3,
88
+ .markdown-content h4 {
89
+ @apply mt-8 mb-3 font-semibold tracking-tight text-slate-950 dark:text-slate-100;
90
+ }
91
+
92
+ .markdown-content h1 { @apply text-3xl; }
93
+ .markdown-content h2 { @apply text-2xl; }
94
+ .markdown-content h3 { @apply text-xl; }
95
+
96
+ .markdown-content p {
97
+ @apply my-4 leading-7;
98
+ }
99
+
100
+ .markdown-content a {
101
+ color: var(--link);
102
+ @apply underline underline-offset-4;
103
+ }
104
+
105
+ .markdown-content a:hover {
106
+ filter: brightness(1.1);
107
+ }
108
+
109
+ .markdown-content ul,
110
+ .markdown-content ol {
111
+ @apply my-4 pl-6;
112
+ }
113
+
114
+ .markdown-content li {
115
+ @apply my-1;
116
+ }
117
+
118
+ .markdown-content blockquote {
119
+ @apply my-5 border-l-4 border-slate-300 pl-4 italic text-slate-600 dark:border-slate-700 dark:text-slate-400;
120
+ }
121
+
122
+ .markdown-content pre {
123
+ @apply my-5 overflow-x-auto rounded-xl border border-slate-200 bg-slate-950 p-4 text-slate-100 dark:border-slate-700;
124
+ }
125
+
126
+ .markdown-content :not(pre) > code {
127
+ @apply rounded bg-slate-200 px-1.5 py-0.5 text-sm text-slate-800 dark:bg-slate-800 dark:text-slate-100;
128
+ }
129
+
130
+ .table-wrap {
131
+ @apply my-5 w-full overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700;
132
+ }
133
+
134
+ .markdown-content table {
135
+ @apply min-w-full border-collapse text-sm;
136
+ }
137
+
138
+ .markdown-content th,
139
+ .markdown-content td {
140
+ @apply whitespace-nowrap border-b border-slate-200 px-3 py-2 text-left dark:border-slate-700;
141
+ }
@@ -0,0 +1,46 @@
1
+ import "./globals.css";
2
+ import type { ReactNode } from "react";
3
+ import { Providers } from "../components/providers";
4
+ import { readConfig } from "../lib/content";
5
+
6
+ export async function generateMetadata() {
7
+ const config = await readConfig();
8
+ const project = config.projectName || "Project";
9
+ return {
10
+ title: `${project} - Docsprout`,
11
+ description: "Plug-and-play documentation"
12
+ };
13
+ }
14
+
15
+ export default async function RootLayout({ children }: { children: ReactNode }) {
16
+ const config = await readConfig();
17
+ const project = config.projectName || "docsprout";
18
+ const theme = config.theme || "default";
19
+
20
+ return (
21
+ <html lang="en" suppressHydrationWarning>
22
+ <body className="min-h-screen" suppressHydrationWarning data-docsprout-theme={theme}>
23
+ <Providers>
24
+ <div className="min-h-screen">
25
+ <header className="fixed inset-x-0 top-0 z-50 border-b border-slate-200/70 bg-white/75 backdrop-blur dark:border-slate-800/70 dark:bg-slate-950/80">
26
+ <div className="flex w-full items-center justify-between px-6 py-4">
27
+ <a href="/" className="text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100">{project}</a>
28
+ <nav className="flex items-center gap-5 text-sm text-slate-600 dark:text-slate-300">
29
+ <a className="hover:text-slate-950 dark:hover:text-white" href="/docs">Docs</a>
30
+ <a className="hover:text-slate-950 dark:hover:text-white" href="/docs-admin">Admin</a>
31
+ </nav>
32
+ </div>
33
+ </header>
34
+ <div className="pt-[72px]">{children}</div>
35
+ <footer className="mt-10 border-t border-slate-200/70 bg-white/60 dark:border-slate-800/70 dark:bg-slate-950/60">
36
+ <div className="flex w-full items-center justify-between px-6 py-4 text-sm text-slate-600 dark:text-slate-300">
37
+ <p>Built with docsprout</p>
38
+ <p className="text-xs uppercase tracking-[0.14em]">Modern Docs Platform</p>
39
+ </div>
40
+ </footer>
41
+ </div>
42
+ </Providers>
43
+ </body>
44
+ </html>
45
+ );
46
+ }
@@ -0,0 +1,17 @@
1
+ import { readConfig } from "../lib/content";
2
+
3
+ export default async function HomePage() {
4
+ const cfg = await readConfig();
5
+ return (
6
+ <main className="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 p-6">
7
+ <h1 className="text-4xl font-bold">{cfg.projectName}</h1>
8
+ <p className="text-slate-600 dark:text-slate-300">Documentation and admin are ready.</p>
9
+ <div className="flex gap-3">
10
+ <a href="/docs/index" className="rounded bg-green-600 px-4 py-2 text-white">Open Docs</a>
11
+ <a href="/docs-admin" className="rounded border border-slate-400 px-4 py-2">Open Admin</a>
12
+ </div>
13
+ </main>
14
+ );
15
+ }
16
+
17
+
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import rehypeSlug from "rehype-slug";
7
+ import rehypeHighlight from "rehype-highlight";
8
+ import mermaid from "mermaid";
9
+
10
+ const MermaidBlock = ({ code }: { code: string }) => {
11
+ const [svg, setSvg] = useState<string>("");
12
+
13
+ useEffect(() => {
14
+ let mounted = true;
15
+ const id = `mermaid-${Math.random().toString(36).slice(2)}`;
16
+
17
+ mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "loose" });
18
+ mermaid
19
+ .render(id, code)
20
+ .then((result) => {
21
+ if (mounted) setSvg(result.svg);
22
+ })
23
+ .catch((error) => {
24
+ if (mounted) setSvg(`<pre>Diagram error: ${String(error?.message ?? "invalid syntax")}</pre>`);
25
+ });
26
+
27
+ return () => {
28
+ mounted = false;
29
+ };
30
+ }, [code]);
31
+
32
+ return <div className="mermaid-wrap" dangerouslySetInnerHTML={{ __html: svg }} />;
33
+ };
34
+
35
+ const normalizeDiagramCode = (lang: string, raw: string) => {
36
+ const code = raw.trim();
37
+ const first = code.split("\n")[0]?.trim().toLowerCase() ?? "";
38
+ const token = lang.toLowerCase();
39
+
40
+ if (token === "mermaid") return code;
41
+ if (token === "flowchart" || token === "graph") {
42
+ return first.startsWith("flowchart") || first.startsWith("graph") ? code : `flowchart TD\n${code}`;
43
+ }
44
+ if (token === "sequence") {
45
+ return first.startsWith("sequencediagram") ? code : `sequenceDiagram\n${code}`;
46
+ }
47
+ if (token === "gantt") {
48
+ return first.startsWith("gantt") ? code : `gantt\n${code}`;
49
+ }
50
+ if (token === "state") {
51
+ return first.startsWith("statediagram") ? code : `stateDiagram-v2\n${code}`;
52
+ }
53
+ if (token === "er") {
54
+ return first.startsWith("erdiagram") ? code : `erDiagram\n${code}`;
55
+ }
56
+
57
+ if (
58
+ first.startsWith("graph") ||
59
+ first.startsWith("flowchart") ||
60
+ first.startsWith("sequencediagram") ||
61
+ first.startsWith("gantt") ||
62
+ first.startsWith("statediagram") ||
63
+ first.startsWith("erdiagram")
64
+ ) {
65
+ return code;
66
+ }
67
+
68
+ return null;
69
+ };
70
+
71
+ export const MarkdownContent = ({ content }: { content: string }) => {
72
+ const normalized = useMemo(() => content.replace(/\r\n/g, "\n"), [content]);
73
+
74
+ return (
75
+ <ReactMarkdown
76
+ remarkPlugins={[remarkGfm]}
77
+ rehypePlugins={[rehypeSlug, rehypeHighlight]}
78
+ components={{
79
+ table: ({ ...props }) => (
80
+ <div className="table-wrap">
81
+ <table {...props} />
82
+ </div>
83
+ ),
84
+ code({ className, children, inline }) {
85
+ const raw = String(children).replace(/\n$/, "");
86
+ const lang = className?.replace("language-", "") ?? "";
87
+ const diagram = !inline ? normalizeDiagramCode(lang, raw) : null;
88
+
89
+ if (diagram) {
90
+ return <MermaidBlock code={diagram} />;
91
+ }
92
+
93
+ return <code className={className}>{children}</code>;
94
+ }
95
+ }}
96
+ >
97
+ {normalized}
98
+ </ReactMarkdown>
99
+ );
100
+ };
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import { ThemeProvider } from "next-themes";
4
+ import { ReactNode } from "react";
5
+
6
+ export const Providers = ({ children }: { children: ReactNode }) => (
7
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
8
+ {children}
9
+ </ThemeProvider>
10
+ );
11
+
12
+
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { useMemo, useRef, useState } from "react";
4
+ import { MarkdownContent } from "./markdown-content";
5
+
6
+ type Mode = "write" | "preview";
7
+
8
+ const wrapSelection = (value: string, start: number, end: number, before: string, after: string) => {
9
+ const selected = value.slice(start, end);
10
+ return `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
11
+ };
12
+
13
+ const insertLine = (value: string, start: number, text: string) => {
14
+ return `${value.slice(0, start)}${text}${value.slice(start)}`;
15
+ };
16
+
17
+ export const RichEditor = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => {
18
+ const [mode, setMode] = useState<Mode>("write");
19
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
20
+
21
+ const apply = (handler: (value: string, start: number, end: number) => string) => {
22
+ const el = textareaRef.current;
23
+ if (!el) return;
24
+ const start = el.selectionStart;
25
+ const end = el.selectionEnd;
26
+ const next = handler(value, start, end);
27
+ onChange(next);
28
+
29
+ queueMicrotask(() => {
30
+ el.focus();
31
+ el.setSelectionRange(start, end);
32
+ });
33
+ };
34
+
35
+ const actions = useMemo(() => ([
36
+ { label: "H1", fn: () => apply((v, s) => insertLine(v, s, "# ")) },
37
+ { label: "H2", fn: () => apply((v, s) => insertLine(v, s, "## ")) },
38
+ { label: "H3", fn: () => apply((v, s) => insertLine(v, s, "### ")) },
39
+ { label: "Bold", fn: () => apply((v, s, e) => wrapSelection(v, s, e, "**", "**")) },
40
+ { label: "Italic", fn: () => apply((v, s, e) => wrapSelection(v, s, e, "*", "*")) },
41
+ { label: "Strike", fn: () => apply((v, s, e) => wrapSelection(v, s, e, "~~", "~~")) },
42
+ { label: "Quote", fn: () => apply((v, s) => insertLine(v, s, "> ")) },
43
+ { label: "Code", fn: () => apply((v, s, e) => wrapSelection(v, s, e, "```\n", "\n```")) },
44
+ { label: "Bullet", fn: () => apply((v, s) => insertLine(v, s, "- ")) },
45
+ { label: "Number", fn: () => apply((v, s) => insertLine(v, s, "1. ")) },
46
+ { label: "Task", fn: () => apply((v, s) => insertLine(v, s, "- [ ] ")) },
47
+ { label: "Link", fn: () => apply((v, s, e) => wrapSelection(v, s, e, "[", "](https://)")) },
48
+ { label: "Image", fn: () => apply((v, s) => insertLine(v, s, "![alt](https://)")) },
49
+ { label: "Table", fn: () => apply((v, s) => insertLine(v, s, "| Col A | Col B |\n| --- | --- |\n| Value | Value |\n")) },
50
+ { label: "Mermaid", fn: () => apply((v, s) => insertLine(v, s, "```mermaid\nflowchart LR\n A[Start] --> B[Done]\n```\n")) }
51
+ ]), [value]);
52
+
53
+ return (
54
+ <div className="rounded-xl border border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-900">
55
+ <div className="flex flex-wrap items-center gap-2 border-b border-slate-200 p-2 dark:border-slate-800">
56
+ <button
57
+ className={`rounded px-2 py-1 text-xs ${mode === "write" ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" : "border border-slate-300 dark:border-slate-700"}`}
58
+ onClick={() => setMode("write")}
59
+ type="button"
60
+ >
61
+ Write
62
+ </button>
63
+ <button
64
+ className={`rounded px-2 py-1 text-xs ${mode === "preview" ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" : "border border-slate-300 dark:border-slate-700"}`}
65
+ onClick={() => setMode("preview")}
66
+ type="button"
67
+ >
68
+ Preview
69
+ </button>
70
+ <div className="h-4 w-px bg-slate-300 dark:bg-slate-700" />
71
+ {actions.map((action) => (
72
+ <button key={action.label} type="button" onClick={action.fn} className="rounded border border-slate-300 px-2 py-1 text-xs hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800">
73
+ {action.label}
74
+ </button>
75
+ ))}
76
+ </div>
77
+
78
+ {mode === "write" ? (
79
+ <textarea
80
+ ref={textareaRef}
81
+ value={value}
82
+ onChange={(e) => onChange(e.target.value)}
83
+ className="h-[calc(100%-44px)] min-h-[360px] w-full resize-none rounded-b-xl bg-transparent p-4 font-mono text-sm outline-none"
84
+ spellCheck={false}
85
+ />
86
+ ) : (
87
+ <div className="h-[calc(100%-44px)] min-h-[360px] overflow-y-auto p-4 markdown-content">
88
+ <MarkdownContent content={value} />
89
+ </div>
90
+ )}
91
+ </div>
92
+ );
93
+ };
94
+
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+
5
+ type SearchEntry = {
6
+ slug: string;
7
+ title: string;
8
+ excerpt: string;
9
+ tags: string[];
10
+ };
11
+
12
+ export const SearchBox = ({ entries }: { entries: SearchEntry[] }) => {
13
+ const [query, setQuery] = useState("");
14
+ const results = useMemo(() => {
15
+ if (!query.trim()) return [];
16
+ return entries.filter((entry) =>
17
+ `${entry.title} ${entry.excerpt} ${entry.tags.join(" ")}`.toLowerCase().includes(query.toLowerCase())
18
+ );
19
+ }, [entries, query]);
20
+
21
+ return (
22
+ <div className="relative">
23
+ <input
24
+ value={query}
25
+ onChange={(event) => setQuery(event.target.value)}
26
+ className="w-full rounded border border-slate-300 p-2 text-sm dark:border-slate-700 dark:bg-slate-950"
27
+ placeholder="Search docs..."
28
+ />
29
+ {results.length > 0 && (
30
+ <div className="absolute z-10 mt-2 max-h-64 w-full overflow-auto rounded border bg-white p-2 shadow dark:border-slate-700 dark:bg-slate-900">
31
+ {results.map((result) => (
32
+ <a key={result.slug} href={`/docs/${result.slug}`} className="block rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
33
+ <p className="text-sm font-semibold">{result.title}</p>
34
+ <p className="text-xs text-slate-500">{result.excerpt}</p>
35
+ </a>
36
+ ))}
37
+ </div>
38
+ )}
39
+ </div>
40
+ );
41
+ };
42
+
43
+
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import type { SidebarItem } from "../lib/types";
6
+
7
+ export const Sidebar = ({ items }: { items: SidebarItem[] }) => {
8
+ const pathname = usePathname();
9
+
10
+ return (
11
+ <aside className="w-full shrink-0 lg:w-72">
12
+ <div className="sticky top-20 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/70">
13
+ <h2 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">Pages</h2>
14
+ <ul className="space-y-1">
15
+ {items.map((item) => {
16
+ const href = `/docs/${item.slug}`;
17
+ const active = pathname === href;
18
+ return (
19
+ <li key={item.slug}>
20
+ <Link
21
+ href={href}
22
+ className={`block rounded-lg px-3 py-2 text-sm transition ${active
23
+ ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
24
+ : "text-slate-700 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
25
+ }`}
26
+ >
27
+ {item.title}
28
+ </Link>
29
+ </li>
30
+ );
31
+ })}
32
+ </ul>
33
+ </div>
34
+ </aside>
35
+ );
36
+ };
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ type Heading = { text: string; id: string };
6
+
7
+ export const Toc = ({ headings }: { headings: Heading[] }) => {
8
+ const [active, setActive] = useState<string>(headings[0]?.id ?? "");
9
+
10
+ useEffect(() => {
11
+ if (headings.length === 0) return;
12
+
13
+ const observer = new IntersectionObserver(
14
+ (entries) => {
15
+ const visible = entries
16
+ .filter((entry) => entry.isIntersecting)
17
+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio);
18
+
19
+ if (visible[0]?.target?.id) {
20
+ setActive(visible[0].target.id);
21
+ }
22
+ },
23
+ { rootMargin: "-20% 0px -60% 0px", threshold: [0.1, 0.3, 0.6] }
24
+ );
25
+
26
+ headings.forEach((heading) => {
27
+ const el = document.getElementById(heading.id);
28
+ if (el) observer.observe(el);
29
+ });
30
+
31
+ return () => observer.disconnect();
32
+ }, [headings]);
33
+
34
+ return (
35
+ <div className="sticky top-20 rounded-2xl border border-slate-200 bg-white/80 p-4 text-sm shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-950/70">
36
+ <h2 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">On This Page</h2>
37
+ <ul className="space-y-2">
38
+ {headings.map((heading) => (
39
+ <li key={heading.id}>
40
+ <a
41
+ className={active === heading.id
42
+ ? "font-medium text-slate-950 dark:text-white"
43
+ : "text-slate-600 hover:text-slate-950 dark:text-slate-300 dark:hover:text-white"}
44
+ href={`#${heading.id}`}
45
+ >
46
+ {heading.text}
47
+ </a>
48
+ </li>
49
+ ))}
50
+ </ul>
51
+ </div>
52
+ );
53
+ };
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { DocPage, DocsproutConfig, SidebarItem } from "./types";
4
+
5
+ const resolveDocsRoot = async () => {
6
+ let current = process.cwd();
7
+ while (true) {
8
+ const candidate = path.join(current, "docsprout");
9
+ try {
10
+ await fs.access(candidate);
11
+ return current;
12
+ } catch {
13
+ const parent = path.dirname(current);
14
+ if (parent === current) return process.cwd();
15
+ current = parent;
16
+ }
17
+ }
18
+ };
19
+
20
+ const readJson = async <T>(parts: string[]): Promise<T> => {
21
+ const root = await resolveDocsRoot();
22
+ const file = path.join(root, ...parts);
23
+ const raw = await fs.readFile(file, "utf-8");
24
+ return JSON.parse(raw) as T;
25
+ };
26
+
27
+ export const readConfig = async (): Promise<DocsproutConfig> => readJson(["docsprout", "config.json"]);
28
+
29
+ export const readPages = async (): Promise<DocPage[]> => readJson(["docsprout", "generated", "pages.json"]);
30
+
31
+ export const readSidebar = async (): Promise<SidebarItem[]> => readJson(["docsprout", "sidebar.json"]);
32
+
33
+ export const getPublishedPages = async () => {
34
+ const pages = await readPages();
35
+ return pages.filter((page) => page.status === "published");
36
+ };
37
+
38
+ export const getPageBySlug = async (slug: string) => {
39
+ const pages = await readPages();
40
+ return pages.find((p) => p.slug === slug && p.status === "published") ?? null;
41
+ };
@@ -0,0 +1,9 @@
1
+ import { PrismaClient } from "@prisma/client";
2
+
3
+ const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
4
+
5
+ export const prisma = globalForPrisma.prisma ?? new PrismaClient();
6
+
7
+ if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
8
+
9
+
@@ -0,0 +1,33 @@
1
+ export type DocStatus = "draft" | "published";
2
+
3
+ export interface DocsproutConfig {
4
+ projectName: string;
5
+ contentRoots: string[];
6
+ ignore: string[];
7
+ theme: string;
8
+ basePath: string;
9
+ adminPath: string;
10
+ outputDir: string;
11
+ includeDraftsInDev: boolean;
12
+ }
13
+
14
+ export interface DocPage {
15
+ id: string;
16
+ title: string;
17
+ slug: string;
18
+ sourcePath: string;
19
+ content: string;
20
+ excerpt?: string;
21
+ order?: number;
22
+ status: DocStatus;
23
+ tags: string[];
24
+ metadata: Record<string, unknown>;
25
+ updatedAt: string;
26
+ }
27
+
28
+ export interface SidebarItem {
29
+ title: string;
30
+ slug: string;
31
+ path: string;
32
+ children?: SidebarItem[];
33
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
@@ -0,0 +1,16 @@
1
+ import createMDX from "@next/mdx";
2
+
3
+ const withMDX = createMDX({
4
+ extension: /\.(md|mdx)$/
5
+ });
6
+
7
+ const nextConfig = {
8
+ pageExtensions: ["ts", "tsx", "md", "mdx"],
9
+ experimental: {
10
+ mdxRs: true
11
+ }
12
+ };
13
+
14
+ export default withMDX(nextConfig);
15
+
16
+