svelora 2.2.0 → 3.0.1

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.
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
9
+ import { codeToHtml } from 'shiki'
10
+ import { z } from 'zod'
11
+
12
+ const __filename = fileURLToPath(import.meta.url)
13
+ const __dirname = path.dirname(__filename)
14
+ const repoRoot = path.resolve(__dirname, '../../')
15
+ const packagedDataPath = path.resolve(__dirname, './svelora-docs.data.json')
16
+
17
+ let packagedDataPromise
18
+
19
+ function resolveRepoPath(relativePath) {
20
+ const fullPath = path.resolve(repoRoot, relativePath)
21
+ if (!fullPath.startsWith(repoRoot)) {
22
+ throw new Error('Invalid path')
23
+ }
24
+ return fullPath
25
+ }
26
+
27
+ async function readJsonIfExists(filePath) {
28
+ try {
29
+ const source = await readFile(filePath, 'utf8')
30
+ return JSON.parse(source)
31
+ } catch (error) {
32
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
33
+ return null
34
+ }
35
+ throw error
36
+ }
37
+ }
38
+
39
+ async function getPackagedData() {
40
+ packagedDataPromise ??= readJsonIfExists(packagedDataPath)
41
+ return await packagedDataPromise
42
+ }
43
+
44
+ function normalizeCode(code) {
45
+ const lines = code.replaceAll('\r\n', '\n').split('\n')
46
+ const firstNonEmpty = lines.findIndex((line) => line.trim().length > 0)
47
+ if (firstNonEmpty === -1) return ''
48
+
49
+ let lastNonEmptyFromEnd = 0
50
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
51
+ if (lines[index].trim().length > 0) break
52
+ lastNonEmptyFromEnd += 1
53
+ }
54
+
55
+ const trimmed = lines.slice(firstNonEmpty, lines.length - lastNonEmptyFromEnd)
56
+ const indents = trimmed
57
+ .filter((line) => line.trim().length > 0)
58
+ .map((line) => line.match(/^(\s*)/)?.[1].length ?? 0)
59
+
60
+ const minIndent = indents.length > 0 ? Math.min(...indents) : 0
61
+ return trimmed
62
+ .map((line) => line.slice(minIndent))
63
+ .join('\n')
64
+ .trim()
65
+ }
66
+
67
+ function unwrapPreviewWrappers(source) {
68
+ let current = source.trim()
69
+
70
+ const stripPrefix = (token) => token.split(':').at(-1) ?? token
71
+ const isPresentationToken = (token) => {
72
+ const t = stripPrefix(token)
73
+ if (t.length === 0) return false
74
+
75
+ if (t.startsWith('max-w-') || t.startsWith('min-w-') || t.startsWith('w-')) return true
76
+ if (t.startsWith('max-h-') || t.startsWith('min-h-') || t.startsWith('h-')) return true
77
+ if (/^(m|p)[trblxyse]?-\d+$/.test(t)) return true
78
+ if (t.startsWith('gap-') || t.startsWith('space-')) return true
79
+ if (t.startsWith('overflow-')) return true
80
+ if (t === 'grid' || t.startsWith('grid-')) return true
81
+ if (t === 'flex' || t === 'inline-flex' || t.startsWith('flex-')) return true
82
+ if (t.startsWith('items-') || t.startsWith('justify-') || t.startsWith('content-'))
83
+ return true
84
+ if (t.startsWith('self-') || t.startsWith('place-')) return true
85
+ if (t.startsWith('rounded')) return true
86
+ if (t.startsWith('border') || t.startsWith('ring') || t.startsWith('shadow')) return true
87
+ if (t === 'bg-transparent') return true
88
+ if (t.startsWith('bg-surface')) return true
89
+ if (t.startsWith('bg-[')) return true
90
+ if (t.startsWith('text-') || t.startsWith('font-') || t.startsWith('leading-')) return true
91
+ if (t.startsWith('tracking-')) return true
92
+ if (t === 'uppercase' || t === 'lowercase' || t === 'capitalize') return true
93
+ if (t.startsWith('opacity-')) return true
94
+ if (t.startsWith('backdrop-blur') || t.startsWith('blur')) return true
95
+ if (t.startsWith('aspect-') || t.startsWith('object-')) return true
96
+ if (t.startsWith('size-')) return true
97
+ if (t.startsWith('[') && t.includes(']:')) return true
98
+
99
+ return false
100
+ }
101
+
102
+ for (let pass = 0; pass < 3; pass += 1) {
103
+ const match = current.match(/^<div\b([^>]*)>([\s\S]*)<\/div>$/)
104
+ if (!match) break
105
+
106
+ const attrs = match[1]
107
+ const attrsWithoutClass = attrs
108
+ .replace(/\s*\bclass=(?:"[^"]*"|'[^']*')/, '')
109
+ .replace(/\s+/g, ' ')
110
+ .trim()
111
+ if (attrsWithoutClass.length > 0) break
112
+
113
+ const classMatch = attrs.match(/\bclass=(?:"([^"]*)"|'([^']*)')/)
114
+ const classValue = (classMatch?.[1] ?? classMatch?.[2] ?? '').trim()
115
+ const tokens = classValue.length > 0 ? classValue.split(/\s+/) : []
116
+
117
+ const isPreviewBg = tokens.some((token) => token.startsWith('bg-surface-container'))
118
+ const isBordered = tokens.some((token) => token.startsWith('border-outline-variant'))
119
+ const isRounded = tokens.some((token) => token.startsWith('rounded'))
120
+ const hasPadding = tokens.some((token) => /^p[trblxy]?-\d+$/.test(token))
121
+ const isMaxWidth = tokens.some((token) => token.startsWith('max-w-'))
122
+ const isOverflow = tokens.some((token) => token.startsWith('overflow-'))
123
+ const isPresentationOnly = tokens.length > 0 && tokens.every(isPresentationToken)
124
+
125
+ const shouldUnwrap = ((isPreviewBg || isBordered || isRounded) && hasPadding) || isOverflow
126
+ const shouldUnwrapMaxWidth = isMaxWidth && isPresentationOnly
127
+
128
+ if (!shouldUnwrap && !shouldUnwrapMaxWidth) break
129
+
130
+ current = match[2].trim()
131
+ }
132
+
133
+ return current
134
+ }
135
+
136
+ function decodeHeading(value) {
137
+ return value
138
+ .replaceAll('&amp;', '&')
139
+ .replaceAll('&times;', '×')
140
+ .replaceAll('&#39;', "'")
141
+ .replaceAll('&quot;', '"')
142
+ .trim()
143
+ }
144
+
145
+ function extractSectionHeading(sectionSource) {
146
+ const match = sectionSource.match(/<h2\b[^>]*>([\s\S]*?)<\/h2>/)
147
+ if (!match) return ''
148
+ return decodeHeading(match[1].replace(/<[^>]+>/g, ' '))
149
+ }
150
+
151
+ function extractSectionSnippet(sectionSource) {
152
+ const openTagEnd = sectionSource.indexOf('>')
153
+ const closeTagStart = sectionSource.lastIndexOf('</section>')
154
+
155
+ if (openTagEnd < 0 || closeTagStart < 0) {
156
+ return normalizeCode(sectionSource)
157
+ }
158
+
159
+ let inner = sectionSource.slice(openTagEnd + 1, closeTagStart)
160
+ inner = inner.replace(/^\s*<h2\b[\s\S]*?<\/h2>\s*/, '')
161
+ inner = inner.replace(/^\s*<!--[\s\S]*?-->\s*/g, '')
162
+
163
+ while (/^\s*<p\b[\s\S]*?<\/p>\s*/.test(inner)) {
164
+ inner = inner.replace(/^\s*<p\b[\s\S]*?<\/p>\s*/, '')
165
+ }
166
+
167
+ inner = inner.replace(/\s*<!--[\s\S]*?-->\s*$/g, '')
168
+ inner = unwrapPreviewWrappers(inner)
169
+ return normalizeCode(inner)
170
+ }
171
+
172
+ function getSectionSnippets(source) {
173
+ return [...source.matchAll(/<section\b[\s\S]*?<\/section>/g)].map((match) => {
174
+ const sectionSource = match[0]
175
+ return {
176
+ heading: extractSectionHeading(sectionSource),
177
+ snippet: extractSectionSnippet(sectionSource)
178
+ }
179
+ })
180
+ }
181
+
182
+ function detectLanguage(code) {
183
+ const source = code.trim()
184
+
185
+ if (
186
+ source.includes('<script lang="ts">') ||
187
+ source.includes('$state(') ||
188
+ source.includes('{#each') ||
189
+ source.includes('<Button') ||
190
+ source.includes('<FormField')
191
+ ) {
192
+ return 'svelte'
193
+ }
194
+
195
+ if (source.startsWith('{') || source.startsWith('[')) {
196
+ return 'json'
197
+ }
198
+
199
+ if (
200
+ source.startsWith('@import') ||
201
+ (source.includes('{') && source.includes(':') && source.includes(';'))
202
+ ) {
203
+ return 'css'
204
+ }
205
+
206
+ if (source.startsWith('#') || source.startsWith('bun ') || source.startsWith('npm ')) {
207
+ return 'bash'
208
+ }
209
+
210
+ if (source.includes('export ') || source.includes('import ') || source.includes('interface ')) {
211
+ return 'ts'
212
+ }
213
+
214
+ return 'html'
215
+ }
216
+
217
+ function decorateHighlightedHtml(html) {
218
+ return html.replace(/<pre class="shiki[\s\S]*?"[^>]*>/, '<pre class="shiki">')
219
+ }
220
+
221
+ async function renderHighlightedCode(code, isDarkMode = true) {
222
+ const html = await codeToHtml(code, {
223
+ lang: detectLanguage(code),
224
+ theme: isDarkMode ? 'github-dark' : 'github-light'
225
+ })
226
+ return decorateHighlightedHtml(html)
227
+ }
228
+
229
+ function getPagePathFromSlug(slug) {
230
+ return `src/routes/${slug}/+page.svelte`
231
+ }
232
+
233
+ async function readRouteSource(slug) {
234
+ const packagedData = await getPackagedData()
235
+ if (packagedData?.pages?.[slug]) {
236
+ return packagedData.pages[slug]
237
+ }
238
+
239
+ const filePath = resolveRepoPath(getPagePathFromSlug(slug))
240
+ return await readFile(filePath, 'utf8')
241
+ }
242
+
243
+ async function readDocsNavigationSource() {
244
+ const filePath = resolveRepoPath('src/lib/docs/navigation.ts')
245
+ return await readFile(filePath, 'utf8')
246
+ }
247
+
248
+ async function listDocSlugs() {
249
+ const packagedData = await getPackagedData()
250
+ if (packagedData?.slugs) {
251
+ return packagedData.slugs
252
+ }
253
+
254
+ const navigationSource = await readDocsNavigationSource()
255
+ return extractDocSlugs(navigationSource)
256
+ }
257
+
258
+ function extractDocSlugs(navigationSource) {
259
+ const componentSlugs = []
260
+ const hookSlugs = []
261
+
262
+ for (const match of navigationSource.matchAll(/href:\s*'\/docs\/components\/([^']+)'/g)) {
263
+ componentSlugs.push(match[1])
264
+ }
265
+
266
+ for (const match of navigationSource.matchAll(/href:\s*'\/docs\/hooks\/([^']+)'/g)) {
267
+ hookSlugs.push(match[1])
268
+ }
269
+
270
+ return {
271
+ components: Array.from(new Set(componentSlugs)),
272
+ hooks: Array.from(new Set(hookSlugs))
273
+ }
274
+ }
275
+
276
+ const server = new McpServer({
277
+ name: 'svelora-docs',
278
+ version: '0.1.0'
279
+ })
280
+
281
+ server.tool(
282
+ 'svelora_docs_list_slugs',
283
+ 'List docs slugs from src/lib/docs/navigation.ts (components + hooks)',
284
+ {},
285
+ async () => {
286
+ const slugs = await listDocSlugs()
287
+
288
+ return {
289
+ content: [
290
+ {
291
+ type: 'text',
292
+ text: JSON.stringify(slugs, null, 2)
293
+ }
294
+ ]
295
+ }
296
+ }
297
+ )
298
+
299
+ server.tool(
300
+ 'svelora_docs_get_page_source',
301
+ 'Read src/routes/[slug]/+page.svelte and return as text',
302
+ {
303
+ slug: z.string().min(1).describe('Route slug, e.g. button, select-menu, use-form-field')
304
+ },
305
+ async ({ slug }) => {
306
+ const source = await readRouteSource(slug)
307
+ return { content: [{ type: 'text', text: source }] }
308
+ }
309
+ )
310
+
311
+ server.tool(
312
+ 'svelora_docs_get_section_snippets',
313
+ 'Extract all <section> headings and code snippets from a route page source',
314
+ {
315
+ slug: z.string().min(1).describe('Route slug, e.g. button, select-menu, use-form-field')
316
+ },
317
+ async ({ slug }) => {
318
+ const source = await readRouteSource(slug)
319
+ const sections = getSectionSnippets(source)
320
+ return { content: [{ type: 'text', text: JSON.stringify(sections, null, 2) }] }
321
+ }
322
+ )
323
+
324
+ server.tool(
325
+ 'svelora_docs_render_shiki',
326
+ 'Render highlighted HTML (Shiki) for a code snippet. Returns <pre class="shiki">...</pre> HTML',
327
+ {
328
+ code: z.string().min(1).describe('Code string'),
329
+ isDarkMode: z.boolean().optional().describe('Use dark theme when true')
330
+ },
331
+ async ({ code, isDarkMode }) => {
332
+ const html = await renderHighlightedCode(code, isDarkMode ?? true)
333
+ return { content: [{ type: 'text', text: html }] }
334
+ }
335
+ )
336
+
337
+ const transport = new StdioServerTransport()
338
+ await server.connect(transport)