@tiramisu-docs/kit 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 (99) hide show
  1. package/README.md +103 -0
  2. package/components.json +14 -0
  3. package/dist/bin/mcp.d.ts +2 -0
  4. package/dist/bin/mcp.js +4 -0
  5. package/dist/config.d.ts +99 -0
  6. package/dist/config.js +36 -0
  7. package/dist/highlight.d.ts +10 -0
  8. package/dist/highlight.js +93 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +3 -0
  11. package/dist/lib/components/index.d.ts +16 -0
  12. package/dist/lib/components/index.js +18 -0
  13. package/dist/lib/components/tiramisu/lang-icons.d.ts +4 -0
  14. package/dist/lib/components/tiramisu/lang-icons.js +77 -0
  15. package/dist/lib/components/ui/alert/index.d.ts +5 -0
  16. package/dist/lib/components/ui/alert/index.js +6 -0
  17. package/dist/lib/components/ui/badge/index.d.ts +2 -0
  18. package/dist/lib/components/ui/badge/index.js +1 -0
  19. package/dist/lib/components/ui/button/index.d.ts +4 -0
  20. package/dist/lib/components/ui/button/index.js +2 -0
  21. package/dist/lib/components/ui/card/index.d.ts +8 -0
  22. package/dist/lib/components/ui/card/index.js +10 -0
  23. package/dist/lib/components/ui/collapsible/index.d.ts +1 -0
  24. package/dist/lib/components/ui/collapsible/index.js +1 -0
  25. package/dist/lib/components/ui/dropdown-menu/index.d.ts +18 -0
  26. package/dist/lib/components/ui/dropdown-menu/index.js +18 -0
  27. package/dist/lib/components/ui/scroll-area/index.d.ts +1 -0
  28. package/dist/lib/components/ui/scroll-area/index.js +1 -0
  29. package/dist/lib/components/ui/separator/index.d.ts +1 -0
  30. package/dist/lib/components/ui/separator/index.js +1 -0
  31. package/dist/lib/components/ui/sheet/index.d.ts +3 -0
  32. package/dist/lib/components/ui/sheet/index.js +3 -0
  33. package/dist/lib/components/ui/tabs/index.d.ts +5 -0
  34. package/dist/lib/components/ui/tabs/index.js +7 -0
  35. package/dist/lib/open-links.d.ts +22 -0
  36. package/dist/lib/open-links.js +33 -0
  37. package/dist/lib/routes/docs/[...slug]/+page.d.ts +25 -0
  38. package/dist/lib/routes/docs/[...slug]/+page.js +109 -0
  39. package/dist/lib/utils.d.ts +5 -0
  40. package/dist/lib/utils.js +5 -0
  41. package/dist/mcp.d.ts +24 -0
  42. package/dist/mcp.js +155 -0
  43. package/dist/scan.d.ts +15 -0
  44. package/dist/scan.js +72 -0
  45. package/dist/seo.d.ts +63 -0
  46. package/dist/seo.js +160 -0
  47. package/dist/tiramisu-grammar.d.ts +2 -0
  48. package/dist/tiramisu-grammar.js +77 -0
  49. package/dist/types.d.ts +66 -0
  50. package/dist/types.js +1 -0
  51. package/dist/vite.d.ts +33 -0
  52. package/dist/vite.js +406 -0
  53. package/package.json +74 -0
  54. package/src/config.ts +133 -0
  55. package/src/highlight.ts +110 -0
  56. package/src/index.ts +6 -0
  57. package/src/lib/components/DocPage.svelte +430 -0
  58. package/src/lib/components/DocsLayout.svelte +145 -0
  59. package/src/lib/components/Footer.svelte +26 -0
  60. package/src/lib/components/Navbar.svelte +117 -0
  61. package/src/lib/components/PageFooter.svelte +63 -0
  62. package/src/lib/components/PrevNextNav.svelte +83 -0
  63. package/src/lib/components/SearchDialog.svelte +130 -0
  64. package/src/lib/components/Sidebar.svelte +237 -0
  65. package/src/lib/components/TableOfContents.svelte +50 -0
  66. package/src/lib/components/TopBar.svelte +407 -0
  67. package/src/lib/components/index.ts +19 -0
  68. package/src/lib/components/tiramisu/Accordion.svelte +16 -0
  69. package/src/lib/components/tiramisu/Badge.svelte +16 -0
  70. package/src/lib/components/tiramisu/Callout.svelte +26 -0
  71. package/src/lib/components/tiramisu/CodeBlock.svelte +56 -0
  72. package/src/lib/components/tiramisu/CodeTabs.svelte +123 -0
  73. package/src/lib/components/tiramisu/Demo.svelte +15 -0
  74. package/src/lib/components/tiramisu/FileTree.svelte +67 -0
  75. package/src/lib/components/tiramisu/MathBlock.svelte +26 -0
  76. package/src/lib/components/tiramisu/Mermaid.svelte +30 -0
  77. package/src/lib/components/tiramisu/NavCard.svelte +49 -0
  78. package/src/lib/components/tiramisu/Steps.svelte +60 -0
  79. package/src/lib/components/tiramisu/Tabs.svelte +87 -0
  80. package/src/lib/components/tiramisu/ZoomImage.svelte +114 -0
  81. package/src/lib/components/tiramisu/lang-icons.ts +81 -0
  82. package/src/lib/open-links.ts +50 -0
  83. package/src/lib/routes/docs/[...slug]/+page.svelte +26 -0
  84. package/src/lib/routes/docs/[...slug]/+page.ts +117 -0
  85. package/src/lib/styles/theme.css +222 -0
  86. package/src/lib/utils.ts +10 -0
  87. package/src/mcp.ts +180 -0
  88. package/src/scan.ts +92 -0
  89. package/src/seo.ts +193 -0
  90. package/src/tiramisu-grammar.ts +80 -0
  91. package/src/types.ts +71 -0
  92. package/src/virtual.d.ts +11 -0
  93. package/src/vite.ts +478 -0
  94. package/tests/config.test.ts +60 -0
  95. package/tests/mcp.test.ts +116 -0
  96. package/tests/scan.test.ts +48 -0
  97. package/tests/seo.test.ts +174 -0
  98. package/tests/vite.test.ts +283 -0
  99. package/tsconfig.json +19 -0
@@ -0,0 +1,50 @@
1
+ export interface OpenLinkOptions {
2
+ baseUrl: string
3
+ slug: string
4
+ locale?: string
5
+ github?: { repo: string; branch?: string; dir?: string }
6
+ mcp?: boolean | string
7
+ title?: string
8
+ }
9
+
10
+ export interface OpenLink {
11
+ label: string
12
+ href?: string
13
+ copy?: string
14
+ icon: "chatgpt" | "claude" | "cursor" | "github" | "mcp" | "vscode"
15
+ type: "open" | "edit" | "mcp"
16
+ }
17
+
18
+ export function getPageUrl(opts: OpenLinkOptions): string {
19
+ const localePart = opts.locale ? `${opts.locale}/` : ""
20
+ const slug = opts.slug === "index" ? "" : opts.slug.replace(/\/index$/, "")
21
+ return `${opts.baseUrl}/docs/${localePart}${slug}`
22
+ }
23
+
24
+ export function getGitHubEditUrl(opts: OpenLinkOptions): string | null {
25
+ if (!opts.github) return null
26
+ const branch = opts.github.branch ?? "main"
27
+ const dir = opts.github.dir ?? "src/docs"
28
+ const localePart = opts.locale ? `${opts.locale}/` : ""
29
+ return `https://github.com/${opts.github.repo}/edit/${branch}/${dir}/${localePart}${opts.slug}.tiramisu`
30
+ }
31
+
32
+ export function getOpenLinks(opts: OpenLinkOptions): OpenLink[] {
33
+ const pageUrl = getPageUrl(opts)
34
+ const prompt = encodeURIComponent(`Read ${pageUrl} and answer questions about the content.`)
35
+ const cursorPrompt = encodeURIComponent(`Read ${pageUrl}, I want to ask questions about it.`)
36
+ const links: OpenLink[] = [
37
+ { label: "Open in ChatGPT", href: `https://chat.openai.com/?q=${prompt}`, icon: "chatgpt", type: "open" },
38
+ { label: "Open in Claude", href: `https://claude.ai/new?q=${prompt}`, icon: "claude", type: "open" },
39
+ { label: "Open in Cursor", href: `https://cursor.com/link/prompt?text=${cursorPrompt}`, icon: "cursor", type: "open" },
40
+ ]
41
+ if (opts.mcp) {
42
+ const mcpUrl = typeof opts.mcp === "string" ? opts.mcp : `${opts.baseUrl}/mcp`
43
+ links.push({ label: "Connect with MCP", copy: mcpUrl, icon: "mcp", type: "mcp" })
44
+ const mcpMeta = JSON.stringify({ name: opts.title ?? "Documentation", url: mcpUrl })
45
+ links.push({ label: "Connect to VSCode", href: `vscode:mcp/install?${encodeURIComponent(mcpMeta)}`, icon: "vscode", type: "mcp" })
46
+ }
47
+ const ghUrl = getGitHubEditUrl(opts)
48
+ if (ghUrl) links.push({ label: "Edit on GitHub", href: ghUrl, icon: "github", type: "edit" })
49
+ return links
50
+ }
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { DocsLayout, DocPage } from "$lib/components";
3
+
4
+ let { data }: { data: Record<string, any> } = $props();
5
+ </script>
6
+
7
+ <DocsLayout
8
+ config={data.config}
9
+ sidebar={data.activeSidebar}
10
+ headings={data.headings}
11
+ sections={data.sections}
12
+ locale={data.locale}
13
+ locales={data.locales}
14
+ showFallbackBanner={data.showFallbackBanner}
15
+ >
16
+ <DocPage
17
+ meta={data.meta}
18
+ lastEdited={data.lastEdited}
19
+ slug={data.slug}
20
+ baseUrl={data.config?.url}
21
+ sidebar={data.activeSidebar}
22
+ siteName={data.config?.title}
23
+ >
24
+ <svelte:component this={data.component} />
25
+ </DocPage>
26
+ </DocsLayout>
@@ -0,0 +1,117 @@
1
+ import { error, redirect } from "@sveltejs/kit";
2
+ import type { VirtualModule, LocaleData, ResolvedSection, SidebarGroup } from "../../../../types.js";
3
+
4
+ export async function load({ params }: { params: { slug?: string } }) {
5
+ const rawSlug = params.slug || "index";
6
+ const mod: VirtualModule = await import("virtual:tiramisu-docs");
7
+
8
+ // If i18n is enabled, parse locale from slug
9
+ if (mod.locales && mod.defaultLocale) {
10
+ const segments = rawSlug.split("/");
11
+ const possibleLocale = segments[0];
12
+ const localeData = mod.locales[possibleLocale];
13
+
14
+ if (localeData) {
15
+ const locale = possibleLocale;
16
+ const slug = segments.slice(1).join("/") || "index";
17
+ return loadDoc(localeData, slug, locale, mod);
18
+ } else {
19
+ // No locale prefix — redirect to default locale
20
+ throw redirect(302, `/docs/${mod.defaultLocale}/${rawSlug}`);
21
+ }
22
+ }
23
+
24
+ // No i18n — legacy behavior
25
+ return loadLegacy(mod, rawSlug);
26
+ }
27
+
28
+ function loadLegacy(mod: VirtualModule, slug: string) {
29
+ let importFn = mod.docImports[slug] ?? mod.docImports[slug + "/index"];
30
+
31
+ // Root /docs with no page — redirect to first section
32
+ if (!importFn && slug === "index" && mod.sections) {
33
+ const firstSection = mod.sections.find((s) => s.path);
34
+ if (firstSection) throw redirect(302, `/docs/${firstSection.path}`);
35
+ }
36
+
37
+ if (!importFn) throw error(404, "Page not found");
38
+ if (!mod.docImports[slug]) slug = slug + "/index";
39
+
40
+ return importFn().then((c) => {
41
+ const doc = mod.docs.find((d) => d.slug === slug);
42
+ let activeSidebar: SidebarGroup[] = mod.sidebar;
43
+ if (mod.sections) {
44
+ activeSidebar = findActiveSectionSidebar(mod.sections, slug) ?? mod.sidebar;
45
+ }
46
+ return {
47
+ component: c.default ?? c,
48
+ meta: doc?.meta ?? {},
49
+ headings: doc?.headings ?? [],
50
+ lastEdited: doc?.lastEdited,
51
+ slug,
52
+ sections: mod.sections ?? undefined,
53
+ activeSidebar,
54
+ };
55
+ });
56
+ }
57
+
58
+ async function loadDoc(localeData: LocaleData, slug: string, locale: string, mod: VirtualModule) {
59
+ type ImportFn = () => Promise<{ default: any }>;
60
+ let importFn: ImportFn | undefined = localeData.docImports[slug] ?? localeData.docImports[slug + "/index"];
61
+ if (!localeData.docImports[slug] && localeData.docImports[slug + "/index"]) slug = slug + "/index";
62
+ let showFallbackBanner = false;
63
+
64
+ if (!importFn) {
65
+ const defaultData = mod.locales?.[mod.defaultLocale!];
66
+ importFn = defaultData?.docImports[slug] ?? defaultData?.docImports[slug + "/index"];
67
+ if (!defaultData?.docImports[slug] && defaultData?.docImports[slug + "/index"]) slug = slug + "/index";
68
+ if (!importFn) {
69
+ // Root /docs/<locale> with no index page — redirect to first section
70
+ if (slug === "index" && localeData.sections) {
71
+ const firstSection = localeData.sections.find((s) => s.path);
72
+ if (firstSection) throw redirect(302, `/docs/${locale}/${firstSection.path}`);
73
+ }
74
+ throw error(404, "Page not found");
75
+ }
76
+ showFallbackBanner = true;
77
+ }
78
+
79
+ const component = await importFn();
80
+ const doc = localeData.docs.find((d) => d.slug === slug)
81
+ ?? mod.locales?.[mod.defaultLocale!]?.docs.find((d) => d.slug === slug);
82
+
83
+ const sections = localeData.sections;
84
+ let activeSidebar: SidebarGroup[] = localeData.sidebar;
85
+ if (sections) {
86
+ activeSidebar = findActiveSectionSidebar(sections, slug) ?? localeData.sidebar;
87
+ }
88
+
89
+ return {
90
+ component: component.default ?? component,
91
+ meta: doc?.meta ?? {},
92
+ headings: doc?.headings ?? [],
93
+ lastEdited: doc?.lastEdited,
94
+ slug,
95
+ locale,
96
+ locales: Object.keys(mod.locales!),
97
+ sections,
98
+ activeSidebar,
99
+ showFallbackBanner,
100
+ };
101
+ }
102
+
103
+ function findActiveSectionSidebar(sections: ResolvedSection[], slug: string): SidebarGroup[] | null {
104
+ for (const section of sections) {
105
+ if (section.path && (slug === section.path || slug.startsWith(section.path + "/"))) {
106
+ return section.sidebar ?? null;
107
+ }
108
+ if (section.children) {
109
+ const found = findActiveSectionSidebar(section.children, slug);
110
+ if (found) return found;
111
+ }
112
+ }
113
+ if (sections.length > 0 && sections[0].sidebar && !sections[0].path) {
114
+ return sections[0].sidebar;
115
+ }
116
+ return null;
117
+ }
@@ -0,0 +1,222 @@
1
+ @custom-variant dark (&:is(.dark *));
2
+
3
+ :root {
4
+ --radius: 0.5rem;
5
+ --font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
6
+ --font-mono: 'Geist Mono', ui-monospace, monospace;
7
+
8
+ /* Zinc neutral palette */
9
+ --background: oklch(1 0 0);
10
+ --foreground: oklch(0.145 0 0);
11
+ --card: oklch(1 0 0);
12
+ --card-foreground: oklch(0.145 0 0);
13
+ --popover: oklch(1 0 0);
14
+ --popover-foreground: oklch(0.145 0 0);
15
+ --primary: oklch(0.205 0 0);
16
+ --primary-foreground: oklch(0.985 0 0);
17
+ --secondary: oklch(0.965 0.001 286.375);
18
+ --secondary-foreground: oklch(0.205 0 0);
19
+ --muted: oklch(0.965 0.001 286.375);
20
+ --muted-foreground: oklch(0.44 0.003 286.286);
21
+ --accent: oklch(0.965 0.001 286.375);
22
+ --accent-foreground: oklch(0.205 0 0);
23
+ --destructive: oklch(0.577 0.245 27.325);
24
+ --border: oklch(0.922 0.004 286.32);
25
+ --input: oklch(0.922 0.004 286.32);
26
+ --ring: oklch(0.708 0.003 286.286);
27
+
28
+ /* Sidebar */
29
+ --sidebar: oklch(0.985 0 0);
30
+ --sidebar-foreground: oklch(0.145 0 0);
31
+ --sidebar-primary: oklch(0.205 0 0);
32
+ --sidebar-primary-foreground: oklch(0.985 0 0);
33
+ --sidebar-accent: oklch(0.965 0.001 286.375);
34
+ --sidebar-accent-foreground: oklch(0.205 0 0);
35
+ --sidebar-border: oklch(0.922 0.004 286.32);
36
+ }
37
+
38
+ .dark {
39
+ --background: oklch(0.119 0 0);
40
+ --foreground: oklch(0.985 0 0);
41
+ --card: oklch(0.119 0 0);
42
+ --card-foreground: oklch(0.985 0 0);
43
+ --popover: oklch(0.119 0 0);
44
+ --popover-foreground: oklch(0.985 0 0);
45
+ --primary: oklch(0.985 0 0);
46
+ --primary-foreground: oklch(0.205 0 0);
47
+ --secondary: oklch(0.269 0.006 286.033);
48
+ --secondary-foreground: oklch(0.985 0 0);
49
+ --muted: oklch(0.269 0.006 286.033);
50
+ --muted-foreground: oklch(0.708 0.003 286.286);
51
+ --accent: oklch(0.269 0.006 286.033);
52
+ --accent-foreground: oklch(0.985 0 0);
53
+ --destructive: oklch(0.704 0.191 22.216);
54
+ --border: oklch(0.24 0.005 286);
55
+ --input: oklch(0.24 0.005 286);
56
+ --ring: oklch(0.556 0.002 286.286);
57
+
58
+ --sidebar: oklch(0.15 0 0);
59
+ --sidebar-foreground: oklch(0.985 0 0);
60
+ --sidebar-primary: oklch(0.985 0 0);
61
+ --sidebar-primary-foreground: oklch(0.205 0 0);
62
+ --sidebar-accent: oklch(0.269 0.006 286.033);
63
+ --sidebar-accent-foreground: oklch(0.985 0 0);
64
+ --sidebar-border: oklch(0.269 0.006 286.033);
65
+ }
66
+
67
+ /* Tailwind v4 theme mapping */
68
+ @theme inline {
69
+ --color-background: var(--background);
70
+ --color-foreground: var(--foreground);
71
+ --color-card: var(--card);
72
+ --color-card-foreground: var(--card-foreground);
73
+ --color-popover: var(--popover);
74
+ --color-popover-foreground: var(--popover-foreground);
75
+ --color-primary: var(--primary);
76
+ --color-primary-foreground: var(--primary-foreground);
77
+ --color-secondary: var(--secondary);
78
+ --color-secondary-foreground: var(--secondary-foreground);
79
+ --color-muted: var(--muted);
80
+ --color-muted-foreground: var(--muted-foreground);
81
+ --color-accent: var(--accent);
82
+ --color-accent-foreground: var(--accent-foreground);
83
+ --color-destructive: var(--destructive);
84
+ --color-border: var(--border);
85
+ --color-input: var(--input);
86
+ --color-ring: var(--ring);
87
+ --color-sidebar: var(--sidebar);
88
+ --color-sidebar-foreground: var(--sidebar-foreground);
89
+ --color-sidebar-primary: var(--sidebar-primary);
90
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
91
+ --color-sidebar-accent: var(--sidebar-accent);
92
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
93
+ --color-sidebar-border: var(--sidebar-border);
94
+
95
+ --font-family-sans: var(--font-sans);
96
+ --font-family-mono: var(--font-mono);
97
+
98
+ --radius-sm: calc(var(--radius) - 4px);
99
+ --radius-md: calc(var(--radius) - 2px);
100
+ --radius-lg: var(--radius);
101
+ --radius-xl: calc(var(--radius) + 4px);
102
+ }
103
+
104
+ /* Base styles */
105
+ * {
106
+ border-color: var(--border);
107
+ }
108
+
109
+ body {
110
+ font-family: var(--font-sans);
111
+ background-color: var(--background);
112
+ color: var(--foreground);
113
+ -webkit-font-smoothing: antialiased;
114
+ -moz-osx-font-smoothing: grayscale;
115
+ }
116
+
117
+ /* Smooth scrolling with offset for anchors */
118
+ html {
119
+ scroll-behavior: smooth;
120
+ scroll-padding-top: 4rem;
121
+ }
122
+
123
+ /* Custom scrollbar */
124
+ ::-webkit-scrollbar {
125
+ width: 6px;
126
+ height: 6px;
127
+ }
128
+ ::-webkit-scrollbar-track {
129
+ background: transparent;
130
+ }
131
+ ::-webkit-scrollbar-thumb {
132
+ background: var(--border);
133
+ border-radius: 3px;
134
+ }
135
+ ::-webkit-scrollbar-thumb:hover {
136
+ background: var(--muted-foreground);
137
+ }
138
+
139
+ /* Selection */
140
+ ::selection {
141
+ background: var(--primary);
142
+ color: var(--primary-foreground);
143
+ }
144
+
145
+ /* Blockquote */
146
+ blockquote {
147
+ border-left: 3px solid var(--border);
148
+ margin: 1.5rem 0;
149
+ padding: 0.75rem 1rem;
150
+ color: var(--muted-foreground);
151
+ font-style: italic;
152
+ }
153
+
154
+ blockquote footer {
155
+ margin-top: 0.5rem;
156
+ font-style: normal;
157
+ font-size: 0.875rem;
158
+ }
159
+
160
+ blockquote cite {
161
+ color: var(--foreground);
162
+ font-weight: 500;
163
+ }
164
+
165
+ /* Task list */
166
+ .task-list {
167
+ list-style: none;
168
+ padding-left: 0;
169
+ margin: 1rem 0;
170
+ }
171
+
172
+ .task-list li {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 0.5rem;
176
+ padding: 0.25rem 0;
177
+ }
178
+
179
+ .task-checkbox {
180
+ display: inline-flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ width: 1rem;
184
+ height: 1rem;
185
+ border: 2px solid var(--border);
186
+ border-radius: 3px;
187
+ flex-shrink: 0;
188
+ }
189
+
190
+ .task-checkbox.checked {
191
+ background: var(--primary);
192
+ border-color: var(--primary);
193
+ }
194
+
195
+ .task-checkbox.checked::after {
196
+ content: "";
197
+ width: 0.375rem;
198
+ height: 0.625rem;
199
+ border: solid var(--primary-foreground);
200
+ border-width: 0 2px 2px 0;
201
+ transform: rotate(45deg) translateY(-1px);
202
+ }
203
+
204
+ /* Columns */
205
+ .columns {
206
+ display: grid;
207
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
208
+ gap: 1.5rem;
209
+ margin: 1.5rem 0;
210
+ }
211
+
212
+ .column {
213
+ min-width: 0;
214
+ }
215
+
216
+ /* Cards grid */
217
+ .cards-grid {
218
+ display: grid;
219
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
220
+ gap: 1rem;
221
+ margin: 1.5rem 0;
222
+ }
@@ -0,0 +1,10 @@
1
+ import { type ClassValue, clsx } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
7
+
8
+ export type WithElementRef<T, El extends HTMLElement = HTMLElement> = T & {
9
+ ref?: El | null
10
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,180 @@
1
+ import type { VirtualDoc, SearchIndexEntry, SidebarGroup } from "./types.js"
2
+
3
+ export interface McpData {
4
+ docs: VirtualDoc[]
5
+ searchIndex: SearchIndexEntry[]
6
+ sidebar: SidebarGroup[]
7
+ }
8
+
9
+ interface JsonRpcRequest {
10
+ jsonrpc: "2.0"
11
+ id?: string | number
12
+ method: string
13
+ params?: Record<string, unknown>
14
+ }
15
+
16
+ interface JsonRpcResponse {
17
+ jsonrpc: "2.0"
18
+ id?: string | number | null
19
+ result?: unknown
20
+ error?: { code: number; message: string; data?: unknown }
21
+ }
22
+
23
+ const TOOL_DEFINITIONS = [
24
+ {
25
+ name: "search_docs",
26
+ description: "Search documentation pages by keyword",
27
+ inputSchema: {
28
+ type: "object" as const,
29
+ properties: {
30
+ query: { type: "string", description: "Search query" },
31
+ limit: { type: "number", description: "Max results (default 10)" },
32
+ },
33
+ required: ["query"],
34
+ },
35
+ },
36
+ {
37
+ name: "read_doc",
38
+ description: "Read a specific documentation page by slug",
39
+ inputSchema: {
40
+ type: "object" as const,
41
+ properties: {
42
+ slug: { type: "string", description: "Page slug" },
43
+ },
44
+ required: ["slug"],
45
+ },
46
+ },
47
+ {
48
+ name: "list_pages",
49
+ description: "List documentation pages, optionally filtered by section",
50
+ inputSchema: {
51
+ type: "object" as const,
52
+ properties: {
53
+ section: { type: "string", description: "Filter by section name" },
54
+ },
55
+ },
56
+ },
57
+ {
58
+ name: "list_sections",
59
+ description: "List all documentation sections with page counts",
60
+ inputSchema: { type: "object" as const, properties: {} },
61
+ },
62
+ {
63
+ name: "get_table_of_contents",
64
+ description: "Get headings for a documentation page",
65
+ inputSchema: {
66
+ type: "object" as const,
67
+ properties: {
68
+ slug: { type: "string", description: "Page slug" },
69
+ },
70
+ required: ["slug"],
71
+ },
72
+ },
73
+ ]
74
+
75
+ function toolResult(data: unknown) {
76
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }
77
+ }
78
+
79
+ function toolError(message: string) {
80
+ return { content: [{ type: "text", text: message }], isError: true }
81
+ }
82
+
83
+ function callTool(name: string, args: Record<string, unknown>, data: McpData) {
84
+ switch (name) {
85
+ case "search_docs": {
86
+ const query = String(args.query ?? "")
87
+ const limit = Number(args.limit ?? 10)
88
+ const terms = query.toLowerCase().split(/\s+/)
89
+ const scored = data.searchIndex
90
+ .map((entry) => {
91
+ const haystack = `${entry.title} ${entry.headings} ${entry.text}`.toLowerCase()
92
+ const score = terms.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0)
93
+ return { entry, score }
94
+ })
95
+ .filter((r) => r.score > 0)
96
+ .sort((a, b) => b.score - a.score)
97
+ .slice(0, limit)
98
+ return toolResult(scored.map((r) => ({
99
+ title: r.entry.title,
100
+ slug: r.entry.slug,
101
+ snippet: r.entry.text.slice(0, 200),
102
+ })))
103
+ }
104
+ case "read_doc": {
105
+ const slug = String(args.slug ?? "")
106
+ const doc = data.docs.find((d) => d.slug === slug)
107
+ const idx = data.searchIndex.find((e) => e.slug === slug)
108
+ if (!doc || !idx) return toolError(`Page not found: ${slug}`)
109
+ return toolResult({
110
+ title: doc.meta.title ?? doc.slug,
111
+ description: doc.meta.description ?? "",
112
+ content: idx.text,
113
+ headings: doc.headings,
114
+ })
115
+ }
116
+ case "list_pages": {
117
+ const section = args.section ? String(args.section).toLowerCase() : ""
118
+ let filtered = data.searchIndex
119
+ if (section) {
120
+ filtered = filtered.filter((e) => e.group.toLowerCase().includes(section))
121
+ }
122
+ return toolResult(filtered.map((e) => ({
123
+ title: e.title,
124
+ slug: e.slug,
125
+ description: data.docs.find((d) => d.slug === e.slug)?.meta.description ?? "",
126
+ })))
127
+ }
128
+ case "list_sections": {
129
+ const groups = new Map<string, number>()
130
+ for (const entry of data.searchIndex) {
131
+ groups.set(entry.group, (groups.get(entry.group) ?? 0) + 1)
132
+ }
133
+ return toolResult(Array.from(groups.entries()).map(([label, pageCount]) => ({
134
+ label,
135
+ path: label.toLowerCase().replace(/ > /g, "/").replace(/ /g, "-"),
136
+ pageCount,
137
+ })))
138
+ }
139
+ case "get_table_of_contents": {
140
+ const slug = String(args.slug ?? "")
141
+ const doc = data.docs.find((d) => d.slug === slug)
142
+ if (!doc) return toolError(`Page not found: ${slug}`)
143
+ return toolResult(doc.headings.map((h) => ({ level: h.level, text: h.text, id: h.id })))
144
+ }
145
+ default:
146
+ return toolError(`Unknown tool: ${name}`)
147
+ }
148
+ }
149
+
150
+ export function handleMcpRequest(body: JsonRpcRequest, data: McpData): JsonRpcResponse {
151
+ const { method, id, params } = body
152
+
153
+ switch (method) {
154
+ case "initialize":
155
+ return {
156
+ jsonrpc: "2.0",
157
+ id: id ?? null,
158
+ result: {
159
+ protocolVersion: "2024-11-05",
160
+ capabilities: { tools: {} },
161
+ serverInfo: { name: "tiramisu-docs", version: "0.1.0" },
162
+ },
163
+ }
164
+ case "notifications/initialized":
165
+ return { jsonrpc: "2.0", id: id ?? null, result: {} }
166
+ case "tools/list":
167
+ return { jsonrpc: "2.0", id: id ?? null, result: { tools: TOOL_DEFINITIONS } }
168
+ case "tools/call": {
169
+ const name = String((params as any)?.name ?? "")
170
+ const args = ((params as any)?.arguments ?? {}) as Record<string, unknown>
171
+ return { jsonrpc: "2.0", id: id ?? null, result: callTool(name, args, data) }
172
+ }
173
+ default:
174
+ return {
175
+ jsonrpc: "2.0",
176
+ id: id ?? null,
177
+ error: { code: -32601, message: `Method not found: ${method}` },
178
+ }
179
+ }
180
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { compileTiramisu } from "@tiramisu-docs/core"
2
+ import type { DocMeta, Heading } from "@tiramisu-docs/core"
3
+ import fs from "node:fs"
4
+ import path from "node:path"
5
+
6
+ export interface ScannedDoc {
7
+ slug: string
8
+ meta: DocMeta
9
+ headings: Heading[]
10
+ }
11
+
12
+ import type { SearchIndexEntry } from "./types.js"
13
+ export type { SearchIndexEntry } from "./types.js"
14
+
15
+ export function findTiramisuFiles(dir: string): string[] {
16
+ const results: string[] = []
17
+ if (!fs.existsSync(dir)) return results
18
+
19
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
20
+ for (const entry of entries) {
21
+ const fullPath = path.join(dir, entry.name)
22
+ if (entry.isDirectory()) {
23
+ results.push(...findTiramisuFiles(fullPath))
24
+ } else if (entry.name.endsWith(".tiramisu")) {
25
+ results.push(fullPath)
26
+ }
27
+ }
28
+ return results
29
+ }
30
+
31
+ export function extractPlainText(html: string): string {
32
+ return html
33
+ .replace(/<script[\s\S]*?<\/script>/g, "")
34
+ .replace(/<[^>]+>/g, " ")
35
+ .replace(/&amp;/g, "&")
36
+ .replace(/&lt;/g, "<")
37
+ .replace(/&gt;/g, ">")
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/\s+/g, " ")
40
+ .trim()
41
+ }
42
+
43
+ export function titleCase(slug: string): string {
44
+ return slug
45
+ .split("-")
46
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
47
+ .join(" ")
48
+ }
49
+
50
+ function resolveSearchGroup(slug: string, meta: DocMeta): string {
51
+ const segments = slug.split("/")
52
+ if (segments.length === 1) return meta.group ?? "Docs"
53
+ return segments
54
+ .slice(0, -1)
55
+ .map(titleCase)
56
+ .join(" > ")
57
+ }
58
+
59
+ export function scanDocs(docsDir: string): {
60
+ docs: ScannedDoc[]
61
+ searchIndex: SearchIndexEntry[]
62
+ } {
63
+ const absDocsDir = path.resolve(docsDir)
64
+ const files = findTiramisuFiles(absDocsDir)
65
+
66
+ const docs: ScannedDoc[] = []
67
+
68
+ for (const file of files) {
69
+ const source = fs.readFileSync(file, "utf-8")
70
+ const { meta, headings } = compileTiramisu(source)
71
+ const relativePath = path.relative(absDocsDir, file)
72
+ const slug = relativePath.replace(/\.tiramisu$/, "").replace(/\\/g, "/")
73
+ docs.push({ slug, meta, headings })
74
+ }
75
+
76
+ const searchIndex: SearchIndexEntry[] = docs.map((doc) => {
77
+ const file = path.resolve(absDocsDir, doc.slug + ".tiramisu")
78
+ const source = fs.readFileSync(file, "utf-8")
79
+ const { svelte } = compileTiramisu(source)
80
+ const text = extractPlainText(svelte)
81
+ return {
82
+ id: doc.slug,
83
+ title: doc.meta.title ?? doc.slug,
84
+ group: resolveSearchGroup(doc.slug, doc.meta),
85
+ slug: doc.slug,
86
+ headings: doc.headings.map((h) => h.text).join(" "),
87
+ text,
88
+ }
89
+ })
90
+
91
+ return { docs, searchIndex }
92
+ }