@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
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("&amp; &lt; &gt; &quot;")).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
+ })