@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.
- package/README.md +103 -0
- package/components.json +14 -0
- package/dist/bin/mcp.d.ts +2 -0
- package/dist/bin/mcp.js +4 -0
- package/dist/config.d.ts +99 -0
- package/dist/config.js +36 -0
- package/dist/highlight.d.ts +10 -0
- package/dist/highlight.js +93 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/lib/components/index.d.ts +16 -0
- package/dist/lib/components/index.js +18 -0
- package/dist/lib/components/tiramisu/lang-icons.d.ts +4 -0
- package/dist/lib/components/tiramisu/lang-icons.js +77 -0
- package/dist/lib/components/ui/alert/index.d.ts +5 -0
- package/dist/lib/components/ui/alert/index.js +6 -0
- package/dist/lib/components/ui/badge/index.d.ts +2 -0
- package/dist/lib/components/ui/badge/index.js +1 -0
- package/dist/lib/components/ui/button/index.d.ts +4 -0
- package/dist/lib/components/ui/button/index.js +2 -0
- package/dist/lib/components/ui/card/index.d.ts +8 -0
- package/dist/lib/components/ui/card/index.js +10 -0
- package/dist/lib/components/ui/collapsible/index.d.ts +1 -0
- package/dist/lib/components/ui/collapsible/index.js +1 -0
- package/dist/lib/components/ui/dropdown-menu/index.d.ts +18 -0
- package/dist/lib/components/ui/dropdown-menu/index.js +18 -0
- package/dist/lib/components/ui/scroll-area/index.d.ts +1 -0
- package/dist/lib/components/ui/scroll-area/index.js +1 -0
- package/dist/lib/components/ui/separator/index.d.ts +1 -0
- package/dist/lib/components/ui/separator/index.js +1 -0
- package/dist/lib/components/ui/sheet/index.d.ts +3 -0
- package/dist/lib/components/ui/sheet/index.js +3 -0
- package/dist/lib/components/ui/tabs/index.d.ts +5 -0
- package/dist/lib/components/ui/tabs/index.js +7 -0
- package/dist/lib/open-links.d.ts +22 -0
- package/dist/lib/open-links.js +33 -0
- package/dist/lib/routes/docs/[...slug]/+page.d.ts +25 -0
- package/dist/lib/routes/docs/[...slug]/+page.js +109 -0
- package/dist/lib/utils.d.ts +5 -0
- package/dist/lib/utils.js +5 -0
- package/dist/mcp.d.ts +24 -0
- package/dist/mcp.js +155 -0
- package/dist/scan.d.ts +15 -0
- package/dist/scan.js +72 -0
- package/dist/seo.d.ts +63 -0
- package/dist/seo.js +160 -0
- package/dist/tiramisu-grammar.d.ts +2 -0
- package/dist/tiramisu-grammar.js +77 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +1 -0
- package/dist/vite.d.ts +33 -0
- package/dist/vite.js +406 -0
- package/package.json +74 -0
- package/src/config.ts +133 -0
- package/src/highlight.ts +110 -0
- package/src/index.ts +6 -0
- package/src/lib/components/DocPage.svelte +430 -0
- package/src/lib/components/DocsLayout.svelte +145 -0
- package/src/lib/components/Footer.svelte +26 -0
- package/src/lib/components/Navbar.svelte +117 -0
- package/src/lib/components/PageFooter.svelte +63 -0
- package/src/lib/components/PrevNextNav.svelte +83 -0
- package/src/lib/components/SearchDialog.svelte +130 -0
- package/src/lib/components/Sidebar.svelte +237 -0
- package/src/lib/components/TableOfContents.svelte +50 -0
- package/src/lib/components/TopBar.svelte +407 -0
- package/src/lib/components/index.ts +19 -0
- package/src/lib/components/tiramisu/Accordion.svelte +16 -0
- package/src/lib/components/tiramisu/Badge.svelte +16 -0
- package/src/lib/components/tiramisu/Callout.svelte +26 -0
- package/src/lib/components/tiramisu/CodeBlock.svelte +56 -0
- package/src/lib/components/tiramisu/CodeTabs.svelte +123 -0
- package/src/lib/components/tiramisu/Demo.svelte +15 -0
- package/src/lib/components/tiramisu/FileTree.svelte +67 -0
- package/src/lib/components/tiramisu/MathBlock.svelte +26 -0
- package/src/lib/components/tiramisu/Mermaid.svelte +30 -0
- package/src/lib/components/tiramisu/NavCard.svelte +49 -0
- package/src/lib/components/tiramisu/Steps.svelte +60 -0
- package/src/lib/components/tiramisu/Tabs.svelte +87 -0
- package/src/lib/components/tiramisu/ZoomImage.svelte +114 -0
- package/src/lib/components/tiramisu/lang-icons.ts +81 -0
- package/src/lib/open-links.ts +50 -0
- package/src/lib/routes/docs/[...slug]/+page.svelte +26 -0
- package/src/lib/routes/docs/[...slug]/+page.ts +117 -0
- package/src/lib/styles/theme.css +222 -0
- package/src/lib/utils.ts +10 -0
- package/src/mcp.ts +180 -0
- package/src/scan.ts +92 -0
- package/src/seo.ts +193 -0
- package/src/tiramisu-grammar.ts +80 -0
- package/src/types.ts +71 -0
- package/src/virtual.d.ts +11 -0
- package/src/vite.ts +478 -0
- package/tests/config.test.ts +60 -0
- package/tests/mcp.test.ts +116 -0
- package/tests/scan.test.ts +48 -0
- package/tests/seo.test.ts +174 -0
- package/tests/vite.test.ts +283 -0
- package/tsconfig.json +19 -0
package/src/vite.ts
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { compileTiramisu } from "@tiramisu-docs/core"
|
|
2
|
+
import type { DocMeta, Heading } from "@tiramisu-docs/core"
|
|
3
|
+
import { execSync } from "node:child_process"
|
|
4
|
+
import fs from "node:fs"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import type { Plugin, ResolvedConfig as ViteConfig, HmrContext } from "vite"
|
|
7
|
+
import { highlightCodeBlocks } from "./highlight.js"
|
|
8
|
+
import type { SectionConfig, TiramisuDocsConfig } from "./config.js"
|
|
9
|
+
import { findTiramisuFiles, extractPlainText, titleCase } from "./scan.js"
|
|
10
|
+
import type { SidebarItem, SidebarSubgroup, SidebarEntry, SidebarGroup, ResolvedSection } from "./types.js"
|
|
11
|
+
|
|
12
|
+
export type { SidebarItem, SidebarSubgroup, SidebarEntry, SidebarGroup, ResolvedSection } from "./types.js"
|
|
13
|
+
|
|
14
|
+
/** Wrap compileTiramisu to produce Vite-friendly errors with file location */
|
|
15
|
+
function compileWithLocation(source: string, filePath: string) {
|
|
16
|
+
const dir = path.dirname(filePath)
|
|
17
|
+
try {
|
|
18
|
+
return compileTiramisu(source, {
|
|
19
|
+
resolveFile(src) {
|
|
20
|
+
const resolved = path.resolve(dir, src)
|
|
21
|
+
return fs.readFileSync(resolved, "utf-8")
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
} catch (err: unknown) {
|
|
25
|
+
const isParseError = err instanceof Error && "line" in err
|
|
26
|
+
if (isParseError) {
|
|
27
|
+
const parseErr = err as Error & { line: number; column?: number; hint?: string }
|
|
28
|
+
const enhanced = new Error(parseErr.hint ?? parseErr.message)
|
|
29
|
+
Object.assign(enhanced, {
|
|
30
|
+
id: filePath,
|
|
31
|
+
loc: { file: filePath, line: parseErr.line, column: parseErr.column ?? 0 },
|
|
32
|
+
plugin: "tiramisu-docs",
|
|
33
|
+
})
|
|
34
|
+
if (parseErr.stack) enhanced.stack = parseErr.stack
|
|
35
|
+
throw enhanced
|
|
36
|
+
}
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TiramisuPluginOptions {
|
|
42
|
+
docsDir?: string
|
|
43
|
+
config?: TiramisuDocsConfig
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
function resolveLastEdited(filePath: string, meta: DocMeta): string {
|
|
48
|
+
if (meta.lastEdited) return new Date(meta.lastEdited).toISOString()
|
|
49
|
+
try {
|
|
50
|
+
const stdout = execSync(`git log -1 --format=%cI "${filePath}"`, {
|
|
51
|
+
encoding: "utf-8",
|
|
52
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
53
|
+
}).trim()
|
|
54
|
+
if (stdout) return new Date(stdout).toISOString()
|
|
55
|
+
} catch {}
|
|
56
|
+
return fs.statSync(filePath).mtime.toISOString()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const VIRTUAL_MODULE_ID = "virtual:tiramisu-docs"
|
|
60
|
+
const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID
|
|
61
|
+
const TIRAMISU_SVELTE_SUFFIX = ".tiramisu.svelte"
|
|
62
|
+
|
|
63
|
+
export function buildSidebarTree(
|
|
64
|
+
docs: { slug: string; meta: DocMeta }[],
|
|
65
|
+
groupOrder: string[] = []
|
|
66
|
+
): SidebarGroup[] {
|
|
67
|
+
const groupMap = new Map<string, { label: string; items: SidebarEntry[]; icon?: string }>()
|
|
68
|
+
|
|
69
|
+
function getOrCreateGroup(label: string): { label: string; items: SidebarEntry[]; icon?: string } {
|
|
70
|
+
// Normalize lookup: case-insensitive match against existing groups
|
|
71
|
+
for (const [key, group] of groupMap) {
|
|
72
|
+
if (key.toLowerCase() === label.toLowerCase()) {
|
|
73
|
+
return group
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const group = { label, items: [] as SidebarEntry[] }
|
|
77
|
+
groupMap.set(label, group)
|
|
78
|
+
return group
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getOrCreateSubgroup(
|
|
82
|
+
parent: SidebarEntry[],
|
|
83
|
+
segment: string
|
|
84
|
+
): SidebarSubgroup {
|
|
85
|
+
const key = segment.toLowerCase()
|
|
86
|
+
const existing = parent.find(
|
|
87
|
+
(e): e is SidebarSubgroup =>
|
|
88
|
+
e.type === "subgroup" && e.key === key
|
|
89
|
+
)
|
|
90
|
+
if (existing) return existing
|
|
91
|
+
const sub: SidebarSubgroup = {
|
|
92
|
+
type: "subgroup",
|
|
93
|
+
key,
|
|
94
|
+
label: titleCase(segment),
|
|
95
|
+
items: [],
|
|
96
|
+
order: 999,
|
|
97
|
+
}
|
|
98
|
+
parent.push(sub)
|
|
99
|
+
return sub
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const doc of docs) {
|
|
103
|
+
const segments = doc.slug.split("/")
|
|
104
|
+
const item: SidebarItem = {
|
|
105
|
+
type: "item",
|
|
106
|
+
title: doc.meta.title ?? doc.slug,
|
|
107
|
+
slug: doc.slug,
|
|
108
|
+
order: doc.meta.order ?? 999,
|
|
109
|
+
icon: doc.meta.icon,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (segments.length === 1) {
|
|
113
|
+
// Root-level file: use meta.group
|
|
114
|
+
const groupLabel = doc.meta.group ?? "Docs"
|
|
115
|
+
const group = getOrCreateGroup(groupLabel)
|
|
116
|
+
group.items.push(item)
|
|
117
|
+
} else {
|
|
118
|
+
// Nested file: first segment = group (static heading), deeper segments = subgroups
|
|
119
|
+
const groupLabel = titleCase(segments[0])
|
|
120
|
+
const group = getOrCreateGroup(groupLabel)
|
|
121
|
+
const fileName = segments[segments.length - 1]
|
|
122
|
+
|
|
123
|
+
// Walk/create subgroups for middle segments (starting at segments[1])
|
|
124
|
+
let parent = group.items
|
|
125
|
+
let lastSub: SidebarSubgroup | null = null
|
|
126
|
+
for (let i = 1; i < segments.length - 1; i++) {
|
|
127
|
+
const sub = getOrCreateSubgroup(parent, segments[i])
|
|
128
|
+
lastSub = sub
|
|
129
|
+
parent = sub.items
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (fileName === "index" && lastSub) {
|
|
133
|
+
// Deeper folder index → sets subgroup slug/label
|
|
134
|
+
lastSub.slug = item.slug
|
|
135
|
+
if (item.order < lastSub.order) lastSub.order = item.order
|
|
136
|
+
if (item.title !== item.slug) lastSub.label = item.title
|
|
137
|
+
if (doc.meta.icon) lastSub.icon = doc.meta.icon
|
|
138
|
+
} else if (fileName === "index" && !lastSub) {
|
|
139
|
+
// Top-level folder index (e.g. "integrations/index") → sets group label only
|
|
140
|
+
if (item.title !== item.slug) {
|
|
141
|
+
group.label = item.title
|
|
142
|
+
}
|
|
143
|
+
if (doc.meta.icon) group.icon = doc.meta.icon
|
|
144
|
+
} else {
|
|
145
|
+
parent.push(item)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sort entries recursively
|
|
151
|
+
function sortEntries(entries: SidebarEntry[]): void {
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (entry.type === "subgroup") {
|
|
154
|
+
sortEntries(entry.items)
|
|
155
|
+
// Derive subgroup order from its index page, or min child order
|
|
156
|
+
if (!entry.slug) {
|
|
157
|
+
const minOrder = entry.items.reduce((min, e) => {
|
|
158
|
+
const o = e.order
|
|
159
|
+
return o < min ? o : min
|
|
160
|
+
}, entry.order)
|
|
161
|
+
entry.order = minOrder
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
entries.sort((a, b) => {
|
|
166
|
+
const ao = a.type === "item" ? a.order : a.order
|
|
167
|
+
const bo = b.type === "item" ? b.order : b.order
|
|
168
|
+
return ao - bo
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sidebar: SidebarGroup[] = []
|
|
173
|
+
for (const [, group] of groupMap) {
|
|
174
|
+
sortEntries(group.items)
|
|
175
|
+
sidebar.push({ label: group.label, items: group.items, icon: group.icon })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Sort groups by groupOrder config
|
|
179
|
+
if (groupOrder.length > 0) {
|
|
180
|
+
sidebar.sort((a, b) => {
|
|
181
|
+
const ai = groupOrder.indexOf(a.label)
|
|
182
|
+
const bi = groupOrder.indexOf(b.label)
|
|
183
|
+
const ao = ai === -1 ? Infinity : ai
|
|
184
|
+
const bo = bi === -1 ? Infinity : bi
|
|
185
|
+
return ao - bo
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return sidebar
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
export function buildSectionSidebars(
|
|
194
|
+
docs: { slug: string; meta: DocMeta }[],
|
|
195
|
+
sections: SectionConfig[],
|
|
196
|
+
implicitLabel?: string
|
|
197
|
+
): ResolvedSection[] {
|
|
198
|
+
const allSectionPaths = flattenSectionPaths(sections)
|
|
199
|
+
const rootDocs = docs.filter(
|
|
200
|
+
(d) => !allSectionPaths.some((p) => d.slug === p || d.slug.startsWith(p + "/"))
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const result: ResolvedSection[] = []
|
|
204
|
+
|
|
205
|
+
if (rootDocs.length > 0) {
|
|
206
|
+
result.push({
|
|
207
|
+
label: implicitLabel ?? "Docs",
|
|
208
|
+
sidebar: buildSidebarTree(rootDocs),
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const section of sections) {
|
|
213
|
+
result.push(resolveSection(docs, section))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function flattenSectionPaths(sections: SectionConfig[]): string[] {
|
|
220
|
+
const paths: string[] = []
|
|
221
|
+
for (const s of sections) {
|
|
222
|
+
if (s.path) paths.push(s.path)
|
|
223
|
+
if (s.children) paths.push(...flattenSectionPaths(s.children))
|
|
224
|
+
}
|
|
225
|
+
return paths
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function resolveSection(
|
|
229
|
+
docs: { slug: string; meta: DocMeta }[],
|
|
230
|
+
section: SectionConfig
|
|
231
|
+
): ResolvedSection {
|
|
232
|
+
if (section.href) {
|
|
233
|
+
return { label: section.label, href: section.href, icon: section.icon }
|
|
234
|
+
}
|
|
235
|
+
if (section.children) {
|
|
236
|
+
return {
|
|
237
|
+
label: section.label,
|
|
238
|
+
icon: section.icon,
|
|
239
|
+
children: section.children.map((c) => resolveSection(docs, c)),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const sectionDocs = docs
|
|
243
|
+
.filter((d) => d.slug === section.path || d.slug.startsWith(section.path + "/"))
|
|
244
|
+
|
|
245
|
+
const sidebar = buildSidebarTree(sectionDocs)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
label: section.label,
|
|
249
|
+
path: section.path,
|
|
250
|
+
icon: section.icon,
|
|
251
|
+
sidebar,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function buildLocaleData(
|
|
256
|
+
allDocs: { slug: string; meta: DocMeta; headings: Heading[]; lastEdited: string }[],
|
|
257
|
+
locales: { code: string }[]
|
|
258
|
+
): Record<string, { docs: { slug: string; meta: DocMeta; headings: Heading[]; lastEdited: string }[] }> {
|
|
259
|
+
const result: Record<string, { docs: { slug: string; meta: DocMeta; headings: Heading[]; lastEdited: string }[] }> = {}
|
|
260
|
+
for (const locale of locales) {
|
|
261
|
+
const prefix = locale.code + "/"
|
|
262
|
+
const docs = allDocs
|
|
263
|
+
.filter((d) => d.slug.startsWith(prefix))
|
|
264
|
+
.map((d) => ({ slug: d.slug.slice(prefix.length), meta: d.meta, headings: d.headings, lastEdited: d.lastEdited }))
|
|
265
|
+
result[locale.code] = { docs }
|
|
266
|
+
}
|
|
267
|
+
return result
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function tiramisuPlugin(options: TiramisuPluginOptions = {}): Plugin {
|
|
271
|
+
const docsDir = options.docsDir ?? "src/docs"
|
|
272
|
+
const config = options.config
|
|
273
|
+
const groupOrder = config?.sidebar?.groupOrder ?? []
|
|
274
|
+
let viteRoot = process.cwd()
|
|
275
|
+
|
|
276
|
+
function resolveDocsDir(): string {
|
|
277
|
+
return path.resolve(viteRoot, docsDir)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildVirtualModule(): string {
|
|
281
|
+
const absDocsDir = resolveDocsDir()
|
|
282
|
+
const files = findTiramisuFiles(absDocsDir)
|
|
283
|
+
|
|
284
|
+
const docs: { slug: string; meta: DocMeta; headings: Heading[]; lastEdited: string }[] = []
|
|
285
|
+
|
|
286
|
+
for (const file of files) {
|
|
287
|
+
const source = fs.readFileSync(file, "utf-8")
|
|
288
|
+
const { meta, headings } = compileWithLocation(source, file)
|
|
289
|
+
const relativePath = path.relative(absDocsDir, file)
|
|
290
|
+
const slug = relativePath.replace(/\.tiramisu$/, "").replace(/\\/g, "/")
|
|
291
|
+
const lastEdited = resolveLastEdited(file, meta)
|
|
292
|
+
docs.push({ slug, meta, headings, lastEdited })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Build search index with hierarchical group paths
|
|
296
|
+
function resolveSearchGroup(slug: string, meta: DocMeta): string {
|
|
297
|
+
const segments = slug.split("/")
|
|
298
|
+
if (segments.length === 1) return meta.group ?? "Docs"
|
|
299
|
+
return segments
|
|
300
|
+
.slice(0, -1)
|
|
301
|
+
.map(titleCase)
|
|
302
|
+
.join(" > ")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const searchIndex = docs.map((doc) => {
|
|
306
|
+
const file = path.resolve(absDocsDir, doc.slug + ".tiramisu")
|
|
307
|
+
const source = fs.readFileSync(file, "utf-8")
|
|
308
|
+
const { svelte } = compileWithLocation(source, file)
|
|
309
|
+
const text = extractPlainText(svelte)
|
|
310
|
+
return {
|
|
311
|
+
id: doc.slug,
|
|
312
|
+
title: doc.meta.title ?? doc.slug,
|
|
313
|
+
group: resolveSearchGroup(doc.slug, doc.meta),
|
|
314
|
+
slug: doc.slug,
|
|
315
|
+
headings: doc.headings.map((h) => h.text).join(" "),
|
|
316
|
+
text,
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Build dynamic imports — use .tiramisu.svelte suffix so resolveId maps them
|
|
321
|
+
const importsEntries = docs
|
|
322
|
+
.map((doc) => {
|
|
323
|
+
const absPath = path
|
|
324
|
+
.resolve(absDocsDir, doc.slug + ".tiramisu")
|
|
325
|
+
.replace(/\\/g, "/")
|
|
326
|
+
return ` "${doc.slug}": () => import("${absPath}")`
|
|
327
|
+
})
|
|
328
|
+
.join(",\n")
|
|
329
|
+
|
|
330
|
+
if (config?.i18n) {
|
|
331
|
+
const localeData = buildLocaleData(docs, config.i18n.locales)
|
|
332
|
+
|
|
333
|
+
const localeBlocks = config.i18n.locales.map((locale) => {
|
|
334
|
+
const localeDocs = localeData[locale.code].docs
|
|
335
|
+
const localeSections = config.sections
|
|
336
|
+
? buildSectionSidebars(localeDocs, config.sections, config.title)
|
|
337
|
+
: undefined
|
|
338
|
+
const localeSidebar = buildSidebarTree(localeDocs, groupOrder)
|
|
339
|
+
|
|
340
|
+
// Build locale-specific search index
|
|
341
|
+
const localeSearchIndex = localeDocs.map((doc) => {
|
|
342
|
+
const fullSlug = `${locale.code}/${doc.slug}`
|
|
343
|
+
const file = path.resolve(absDocsDir, fullSlug + ".tiramisu")
|
|
344
|
+
const source = fs.readFileSync(file, "utf-8")
|
|
345
|
+
const { svelte } = compileWithLocation(source, file)
|
|
346
|
+
const text = extractPlainText(svelte)
|
|
347
|
+
return {
|
|
348
|
+
id: doc.slug,
|
|
349
|
+
title: doc.meta.title ?? doc.slug,
|
|
350
|
+
group: resolveSearchGroup(doc.slug, doc.meta),
|
|
351
|
+
slug: doc.slug,
|
|
352
|
+
headings: docs.find(d => d.slug === fullSlug)?.headings?.map(h => h.text).join(" ") ?? "",
|
|
353
|
+
text,
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const localeImports = localeDocs
|
|
358
|
+
.map((doc) => {
|
|
359
|
+
const fullSlug = `${locale.code}/${doc.slug}`
|
|
360
|
+
const absPath = path.resolve(absDocsDir, fullSlug + ".tiramisu").replace(/\\/g, "/")
|
|
361
|
+
return ` "${doc.slug}": () => import("${absPath}")`
|
|
362
|
+
})
|
|
363
|
+
.join(",\n")
|
|
364
|
+
|
|
365
|
+
return ` "${locale.code}": {
|
|
366
|
+
sections: ${JSON.stringify(localeSections, null, 2)},
|
|
367
|
+
sidebar: ${JSON.stringify(localeSidebar, null, 2)},
|
|
368
|
+
docs: ${JSON.stringify(localeDocs, null, 2)},
|
|
369
|
+
searchIndex: ${JSON.stringify(localeSearchIndex)},
|
|
370
|
+
docImports: {\n${localeImports}\n },
|
|
371
|
+
}`
|
|
372
|
+
}).join(",\n")
|
|
373
|
+
|
|
374
|
+
return [
|
|
375
|
+
|
|
376
|
+
`export const locales = {\n${localeBlocks}\n};`,
|
|
377
|
+
`export const defaultLocale = "${config.i18n.defaultLocale}";`,
|
|
378
|
+
`export const sidebar = locales["${config.i18n.defaultLocale}"].sidebar;`,
|
|
379
|
+
`export const sections = locales["${config.i18n.defaultLocale}"].sections;`,
|
|
380
|
+
`export const docs = locales["${config.i18n.defaultLocale}"].docs;`,
|
|
381
|
+
`export const searchIndex = locales["${config.i18n.defaultLocale}"].searchIndex;`,
|
|
382
|
+
`export const docImports = locales["${config.i18n.defaultLocale}"].docImports;`,
|
|
383
|
+
].join("\n\n")
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (config?.sections) {
|
|
387
|
+
const resolvedSections = buildSectionSidebars(docs, config.sections, config.title)
|
|
388
|
+
return [
|
|
389
|
+
|
|
390
|
+
`export const locales = undefined;`,
|
|
391
|
+
`export const defaultLocale = undefined;`,
|
|
392
|
+
`export const sections = ${JSON.stringify(resolvedSections, null, 2)};`,
|
|
393
|
+
`export const sidebar = [];`,
|
|
394
|
+
`export const docs = ${JSON.stringify(docs, null, 2)};`,
|
|
395
|
+
`export const searchIndex = ${JSON.stringify(searchIndex)};`,
|
|
396
|
+
`export const docImports = {\n${importsEntries}\n};`,
|
|
397
|
+
].join("\n\n")
|
|
398
|
+
} else {
|
|
399
|
+
const sidebar = buildSidebarTree(docs, groupOrder)
|
|
400
|
+
return [
|
|
401
|
+
|
|
402
|
+
`export const locales = undefined;`,
|
|
403
|
+
`export const defaultLocale = undefined;`,
|
|
404
|
+
`export const sections = undefined;`,
|
|
405
|
+
`export const sidebar = ${JSON.stringify(sidebar, null, 2)};`,
|
|
406
|
+
`export const docs = ${JSON.stringify(docs, null, 2)};`,
|
|
407
|
+
`export const searchIndex = ${JSON.stringify(searchIndex)};`,
|
|
408
|
+
`export const docImports = {\n${importsEntries}\n};`,
|
|
409
|
+
].join("\n\n")
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
name: "tiramisu-docs",
|
|
415
|
+
enforce: "pre",
|
|
416
|
+
|
|
417
|
+
configResolved(config: ViteConfig) {
|
|
418
|
+
viteRoot = config.root
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
resolveId(id: string) {
|
|
422
|
+
if (id === VIRTUAL_MODULE_ID) {
|
|
423
|
+
return RESOLVED_VIRTUAL_MODULE_ID
|
|
424
|
+
}
|
|
425
|
+
// Resolve .tiramisu imports to a virtual .svelte ID
|
|
426
|
+
// so the Svelte plugin processes the output
|
|
427
|
+
if (id.endsWith(".tiramisu")) {
|
|
428
|
+
const resolved = path.isAbsolute(id) ? id : path.resolve(viteRoot, id)
|
|
429
|
+
if (fs.existsSync(resolved)) {
|
|
430
|
+
return resolved + ".svelte"
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return undefined
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
async load(id: string) {
|
|
437
|
+
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
438
|
+
return buildVirtualModule()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Compile .tiramisu files to Svelte component source
|
|
442
|
+
// These come in as .tiramisu.svelte due to resolveId above
|
|
443
|
+
if (id.endsWith(TIRAMISU_SVELTE_SUFFIX)) {
|
|
444
|
+
const tiramisuPath = id.slice(0, -".svelte".length)
|
|
445
|
+
if (!fs.existsSync(tiramisuPath)) return undefined
|
|
446
|
+
|
|
447
|
+
const source = fs.readFileSync(tiramisuPath, "utf-8")
|
|
448
|
+
const { svelte } = compileWithLocation(source, tiramisuPath)
|
|
449
|
+
return await highlightCodeBlocks(svelte)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return undefined
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
handleHotUpdate(ctx: HmrContext) {
|
|
456
|
+
if (ctx.file.endsWith(".tiramisu")) {
|
|
457
|
+
// Invalidate the virtual module so sidebar/docs get rebuilt
|
|
458
|
+
const virtualMod = ctx.server.moduleGraph.getModuleById(
|
|
459
|
+
RESOLVED_VIRTUAL_MODULE_ID
|
|
460
|
+
)
|
|
461
|
+
if (virtualMod) {
|
|
462
|
+
ctx.server.moduleGraph.invalidateModule(virtualMod)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Invalidate the compiled .svelte version too
|
|
466
|
+
const svelteMod = ctx.server.moduleGraph.getModuleById(
|
|
467
|
+
ctx.file + ".svelte"
|
|
468
|
+
)
|
|
469
|
+
if (svelteMod) {
|
|
470
|
+
ctx.server.moduleGraph.invalidateModule(svelteMod)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
ctx.server.ws.send({ type: "full-reload" })
|
|
474
|
+
return []
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { defineConfig, resolveConfig } from "../src/config"
|
|
3
|
+
|
|
4
|
+
describe("defineConfig", () => {
|
|
5
|
+
it("returns the config as-is (type helper)", () => {
|
|
6
|
+
const config = defineConfig({ title: "Test" })
|
|
7
|
+
expect(config.title).toBe("Test")
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe("resolveConfig", () => {
|
|
12
|
+
it("fills in defaults", () => {
|
|
13
|
+
const config = resolveConfig({})
|
|
14
|
+
expect(config.title).toBe("Documentation")
|
|
15
|
+
expect(config.sidebar.groupOrder).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("resolves sections config", () => {
|
|
19
|
+
const config = resolveConfig({
|
|
20
|
+
sections: [
|
|
21
|
+
{ label: "Guides", path: "guides" },
|
|
22
|
+
{ label: "Reference", children: [
|
|
23
|
+
{ label: "API", path: "api" },
|
|
24
|
+
{ label: "CLI", path: "cli" },
|
|
25
|
+
]},
|
|
26
|
+
{ label: "Blog", href: "https://blog.example.com" },
|
|
27
|
+
],
|
|
28
|
+
})
|
|
29
|
+
expect(config.sections).toHaveLength(3)
|
|
30
|
+
expect(config.sections![0].label).toBe("Guides")
|
|
31
|
+
expect(config.sections![1].children).toHaveLength(2)
|
|
32
|
+
expect(config.sections![2].href).toBe("https://blog.example.com")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("resolves undefined sections as undefined", () => {
|
|
36
|
+
const config = resolveConfig({})
|
|
37
|
+
expect(config.sections).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("resolves i18n config", () => {
|
|
41
|
+
const config = resolveConfig({
|
|
42
|
+
i18n: {
|
|
43
|
+
defaultLocale: "en",
|
|
44
|
+
locales: [
|
|
45
|
+
{ code: "en", label: "English", flag: "🇺🇸" },
|
|
46
|
+
{ code: "fr", label: "Français", flag: "🇫🇷" },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
expect(config.i18n).toBeDefined()
|
|
51
|
+
expect(config.i18n!.defaultLocale).toBe("en")
|
|
52
|
+
expect(config.i18n!.locales).toHaveLength(2)
|
|
53
|
+
expect(config.i18n!.fallback).toBe("default-language")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("resolves undefined i18n as undefined", () => {
|
|
57
|
+
const config = resolveConfig({})
|
|
58
|
+
expect(config.i18n).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { handleMcpRequest } from "../src/mcp"
|
|
3
|
+
import type { McpData } from "../src/mcp"
|
|
4
|
+
|
|
5
|
+
const mockData: McpData = {
|
|
6
|
+
docs: [
|
|
7
|
+
{
|
|
8
|
+
slug: "getting-started",
|
|
9
|
+
meta: { title: "Getting Started", description: "How to begin" },
|
|
10
|
+
headings: [
|
|
11
|
+
{ level: 2, text: "Installation", id: "installation" },
|
|
12
|
+
{ level: 2, text: "Usage", id: "usage" },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
slug: "api/auth",
|
|
17
|
+
meta: { title: "Authentication", description: "Auth docs" },
|
|
18
|
+
headings: [{ level: 2, text: "Tokens", id: "tokens" }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
searchIndex: [
|
|
22
|
+
{ id: "1", title: "Getting Started", group: "Guide", slug: "getting-started", headings: "Installation Usage", text: "Welcome to the docs. Install the package and get started." },
|
|
23
|
+
{ id: "2", title: "Authentication", group: "API", slug: "api/auth", headings: "Tokens", text: "Authentication uses bearer tokens." },
|
|
24
|
+
],
|
|
25
|
+
sidebar: [],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("handleMcpRequest", () => {
|
|
29
|
+
it("handles initialize", () => {
|
|
30
|
+
const res = handleMcpRequest({ jsonrpc: "2.0", id: 1, method: "initialize" }, mockData)
|
|
31
|
+
expect(res.result).toBeDefined()
|
|
32
|
+
expect((res.result as any).serverInfo.name).toBe("tiramisu-docs")
|
|
33
|
+
expect((res.result as any).capabilities.tools).toBeDefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("handles tools/list", () => {
|
|
37
|
+
const res = handleMcpRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" }, mockData)
|
|
38
|
+
const tools = (res.result as any).tools
|
|
39
|
+
expect(tools).toHaveLength(5)
|
|
40
|
+
expect(tools.map((t: any) => t.name)).toContain("search_docs")
|
|
41
|
+
expect(tools.map((t: any) => t.name)).toContain("read_doc")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("search_docs finds matching pages", () => {
|
|
45
|
+
const res = handleMcpRequest({
|
|
46
|
+
jsonrpc: "2.0", id: 3, method: "tools/call",
|
|
47
|
+
params: { name: "search_docs", arguments: { query: "install" } },
|
|
48
|
+
}, mockData)
|
|
49
|
+
const content = JSON.parse((res.result as any).content[0].text)
|
|
50
|
+
expect(content).toHaveLength(1)
|
|
51
|
+
expect(content[0].slug).toBe("getting-started")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("read_doc returns page content", () => {
|
|
55
|
+
const res = handleMcpRequest({
|
|
56
|
+
jsonrpc: "2.0", id: 4, method: "tools/call",
|
|
57
|
+
params: { name: "read_doc", arguments: { slug: "getting-started" } },
|
|
58
|
+
}, mockData)
|
|
59
|
+
const content = JSON.parse((res.result as any).content[0].text)
|
|
60
|
+
expect(content.title).toBe("Getting Started")
|
|
61
|
+
expect(content.content).toContain("Install the package")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("read_doc returns error for missing page", () => {
|
|
65
|
+
const res = handleMcpRequest({
|
|
66
|
+
jsonrpc: "2.0", id: 5, method: "tools/call",
|
|
67
|
+
params: { name: "read_doc", arguments: { slug: "nonexistent" } },
|
|
68
|
+
}, mockData)
|
|
69
|
+
expect((res.result as any).isError).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("list_pages returns all pages", () => {
|
|
73
|
+
const res = handleMcpRequest({
|
|
74
|
+
jsonrpc: "2.0", id: 6, method: "tools/call",
|
|
75
|
+
params: { name: "list_pages", arguments: {} },
|
|
76
|
+
}, mockData)
|
|
77
|
+
const content = JSON.parse((res.result as any).content[0].text)
|
|
78
|
+
expect(content).toHaveLength(2)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("list_pages filters by section", () => {
|
|
82
|
+
const res = handleMcpRequest({
|
|
83
|
+
jsonrpc: "2.0", id: 7, method: "tools/call",
|
|
84
|
+
params: { name: "list_pages", arguments: { section: "API" } },
|
|
85
|
+
}, mockData)
|
|
86
|
+
const content = JSON.parse((res.result as any).content[0].text)
|
|
87
|
+
expect(content).toHaveLength(1)
|
|
88
|
+
expect(content[0].slug).toBe("api/auth")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("list_sections groups pages", () => {
|
|
92
|
+
const res = handleMcpRequest({
|
|
93
|
+
jsonrpc: "2.0", id: 8, method: "tools/call",
|
|
94
|
+
params: { name: "list_sections", arguments: {} },
|
|
95
|
+
}, mockData)
|
|
96
|
+
const content = JSON.parse((res.result as any).content[0].text)
|
|
97
|
+
expect(content).toHaveLength(2)
|
|
98
|
+
expect(content.find((s: any) => s.label === "Guide").pageCount).toBe(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("get_table_of_contents returns headings", () => {
|
|
102
|
+
const res = handleMcpRequest({
|
|
103
|
+
jsonrpc: "2.0", id: 9, method: "tools/call",
|
|
104
|
+
params: { name: "get_table_of_contents", arguments: { slug: "getting-started" } },
|
|
105
|
+
}, mockData)
|
|
106
|
+
const content = JSON.parse((res.result as any).content[0].text)
|
|
107
|
+
expect(content).toHaveLength(2)
|
|
108
|
+
expect(content[0].text).toBe("Installation")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("returns error for unknown method", () => {
|
|
112
|
+
const res = handleMcpRequest({ jsonrpc: "2.0", id: 10, method: "unknown/method" }, mockData)
|
|
113
|
+
expect(res.error).toBeDefined()
|
|
114
|
+
expect(res.error!.code).toBe(-32601)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { scanDocs, extractPlainText, titleCase, findTiramisuFiles } from "../src/scan"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
const playgroundDocs = path.resolve(__dirname, "../../../playground/src/docs")
|
|
6
|
+
|
|
7
|
+
describe("extractPlainText", () => {
|
|
8
|
+
it("strips HTML tags", () => {
|
|
9
|
+
expect(extractPlainText("<p>Hello <strong>world</strong></p>")).toBe("Hello world")
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("decodes entities", () => {
|
|
13
|
+
expect(extractPlainText("& < > "")).toBe('& < > "')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("removes script tags entirely", () => {
|
|
17
|
+
expect(extractPlainText('<script>var x = 1;</script><p>text</p>')).toBe("text")
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("titleCase", () => {
|
|
22
|
+
it("converts slug to title case", () => {
|
|
23
|
+
expect(titleCase("getting-started")).toBe("Getting Started")
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("findTiramisuFiles", () => {
|
|
28
|
+
it("returns empty for nonexistent dir", () => {
|
|
29
|
+
expect(findTiramisuFiles("/nonexistent/path")).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("scanDocs", () => {
|
|
34
|
+
it("scans playground docs and returns docs + searchIndex", () => {
|
|
35
|
+
const { docs, searchIndex } = scanDocs(playgroundDocs)
|
|
36
|
+
expect(docs.length).toBeGreaterThan(0)
|
|
37
|
+
expect(searchIndex.length).toBe(docs.length)
|
|
38
|
+
for (const doc of docs) {
|
|
39
|
+
expect(doc.slug).toBeDefined()
|
|
40
|
+
expect(doc.meta).toBeDefined()
|
|
41
|
+
expect(doc.headings).toBeDefined()
|
|
42
|
+
}
|
|
43
|
+
for (const entry of searchIndex) {
|
|
44
|
+
expect(entry.title).toBeDefined()
|
|
45
|
+
expect(entry.text.length).toBeGreaterThan(0)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|