diff --git a/packages/site-kit/package.json b/packages/site-kit/package.json
index 7cc9cb2cbc..e6fcb8da6d 100644
--- a/packages/site-kit/package.json
+++ b/packages/site-kit/package.json
@@ -46,9 +46,11 @@
"shiki": "^3.23.0"
},
"devDependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5",
"@shikijs/vitepress-twoslash": "^4.0.1",
"@sveltejs/kit": "^3.0.0-next.1",
"@types/node": "^20.19.33",
+ "@volar/language-core": "^2.4.28",
"flexsearch": "^0.8.212",
"gzip": "workspace:*",
"magic-string": "^0.30.21",
@@ -57,6 +59,9 @@
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.53.5",
"svelte-check": "^4.5.0",
+ "svelte2tsx": "^0.7.56",
+ "twoslash": "^0.3.8",
+ "twoslash-protocol": "^0.3.8",
"typescript": "^6.0.3",
"vite": "^8.0.0-beta.15"
},
diff --git a/packages/site-kit/src/lib/markdown/renderer.ts b/packages/site-kit/src/lib/markdown/renderer.ts
index bf5fee6978..ef2ad5c0aa 100644
--- a/packages/site-kit/src/lib/markdown/renderer.ts
+++ b/packages/site-kit/src/lib/markdown/renderer.ts
@@ -9,6 +9,7 @@ import { createHighlighterCore } from 'shiki/core';
import { createOnigurumaEngine } from 'shiki/engine/oniguruma';
import { createCssVariablesTheme } from 'shiki';
import { transformerTwoslash, rendererRich } from '@shikijs/twoslash';
+import { createTwoslasher } from './twoslash-svelte.ts';
import { createFileSystemTypesCache } from '@shikijs/vitepress-twoslash/cache-fs';
import { compress_and_encode_text } from 'gzip';
import {
@@ -1130,7 +1131,7 @@ async function syntax_highlight({
theme
})
);
- } else if (language === 'js' || language === 'ts') {
+ } else if (language === 'js' || language === 'ts' || language === 'svelte') {
/** We need to stash code wrapped in `---` highlights, because otherwise TS will error on e.g. bad syntax, duplicate declarations */
const redactions: string[] = [];
@@ -1143,13 +1144,18 @@ async function syntax_highlight({
});
try {
- html = highlighter.codeToHtml(prelude + redacted, {
+ html = highlighter.codeToHtml(injectPrelude({
+ prelude,
+ source: redacted,
+ language
+ }), {
lang: language,
theme,
transformers: check
? [
transformerTwoslash({
renderer: rendererRich(),
+ twoslasher: createTwoslasher(),
twoslashOptions: {
compilerOptions: {
allowJs: true,
@@ -1319,3 +1325,18 @@ function indent_multiline_comments(str: string) {
}
);
}
+
+
+function injectPrelude({ prelude, source, language }: { prelude: string; source: string; language: string }) {
+ if (language === 'svelte') {
+ const scriptTag = /\n\n${source}`;
+ }
+ return prelude + source;
+}
\ No newline at end of file
diff --git a/packages/site-kit/src/lib/markdown/twoslash-svelte.ts b/packages/site-kit/src/lib/markdown/twoslash-svelte.ts
new file mode 100644
index 0000000000..ee586024b5
--- /dev/null
+++ b/packages/site-kit/src/lib/markdown/twoslash-svelte.ts
@@ -0,0 +1,277 @@
+import type { CodeMapping } from '@volar/language-core'
+import type { CompilerOptionDeclaration, CreateTwoslashOptions, HandbookOptions, Range, TwoslashExecuteOptions, TwoslashInstance, TwoslashReturnMeta } from 'twoslash'
+import { createRequire } from 'node:module'
+import { decode } from '@jridgewell/sourcemap-codec'
+import { SourceMap } from '@volar/language-core'
+import { svelte2tsx } from 'svelte2tsx'
+import { createTwoslasher as createTwoSlasherBase, defaultCompilerOptions, defaultHandbookOptions, findFlagNotations, findQueryMarkers } from 'twoslash'
+import { createPositionConverter, removeCodeRanges, resolveNodePositions } from 'twoslash-protocol'
+import ts from 'typescript'
+
+export interface CreateTwoslashSvelteOptions extends CreateTwoslashOptions {
+ /**
+ * Render the generated code in the output instead of the Svelte file
+ *
+ * @default false
+ */
+ debugShowGeneratedCode?: boolean
+}
+
+/**
+ * Create a twoslasher instance that add additional support for Svelte
+ */
+export function createTwoslasher(createOptions: CreateTwoslashSvelteOptions = {}): TwoslashInstance {
+ const require = createRequire(import.meta.url)
+ const twoslasherBase = createTwoSlasherBase(createOptions)
+
+ function twoslasher(code: string, extension?: string, options: TwoslashExecuteOptions = {}) {
+ if (extension !== 'svelte') {
+ return twoslasherBase(code, extension, options)
+ }
+
+ const compilerOptions: ts.CompilerOptions = {
+ ...defaultCompilerOptions,
+ ...options.compilerOptions,
+ }
+ const handbookOptions: HandbookOptions = {
+ ...defaultHandbookOptions,
+ noErrorsCutted: true,
+ ...options.handbookOptions,
+ }
+
+ const sourceMeta = findQueryMarkers(code, {
+ removals: [] as Range[],
+ positionCompletions: [] as number[],
+ positionQueries: [] as number[],
+ positionHighlights: [] as TwoslashReturnMeta['positionHighlights'],
+ }, createPositionConverter(code))
+
+ const customTags = options.customTags ?? createOptions.customTags ?? []
+ const optionDeclarations = (ts as any).optionDeclarations as CompilerOptionDeclaration[]
+ const flagNotations = findFlagNotations(code, customTags, optionDeclarations)
+
+ // #region apply flags
+ for (const flag of flagNotations) {
+ switch (flag.type) {
+ case 'unknown': {
+ continue
+ }
+ case 'compilerOptions': {
+ compilerOptions[flag.name] = flag.value
+ break
+ }
+ case 'handbookOptions': {
+ // @ts-expect-error -- this is fine
+ handbookOptions[flag.name] = flag.value
+ break
+ }
+ }
+ sourceMeta.removals.push([flag.start, flag.end])
+ }
+
+ let strippedCode = code
+ for (const [start, end] of sourceMeta.removals) {
+ strippedCode
+ = strippedCode.slice(0, start)
+ + strippedCode.slice(start, end).replace(/\S/g, ' ')
+ + strippedCode.slice(end)
+ }
+
+ const compiled = svelte2tsx(strippedCode)
+ const map = generateSourceMap(strippedCode, compiled.code, compiled.map.mappings)
+
+ function getLastGeneratedOffset(pos: number) {
+ const offsets = [...map.toGeneratedLocation(pos)]
+ if (!offsets.length) {
+ return undefined
+ }
+ return offsets[offsets.length - 1]?.[0]
+ }
+
+ const result = twoslasherBase(compiled.code, 'tsx', {
+ ...options,
+ compilerOptions: {
+ ...compilerOptions,
+ types: [
+ ...(compilerOptions.types ?? []),
+ require.resolve(`svelte2tsx/svelte-jsx-v4.d.ts`),
+ require.resolve(`svelte2tsx/svelte-shims-v4.d.ts`),
+ ],
+ },
+ handbookOptions: {
+ ...handbookOptions,
+ keepNotations: true,
+ },
+ positionCompletions: sourceMeta.positionCompletions
+ .map(p => getLastGeneratedOffset(p)!),
+ positionQueries: sourceMeta.positionQueries
+ .map(p => get(map.toGeneratedLocation(p), 0)?.[0])
+ .filter(value => value != null),
+ positionHighlights: sourceMeta.positionHighlights
+ .map(([start, end]) => [
+ get(map.toGeneratedLocation(start), 0)?.[0],
+ get(map.toGeneratedLocation(end), 0)?.[0],
+ ])
+ .filter((x): x is [number, number] => x[0] != null && x[1] != null),
+ })
+
+ if (createOptions.debugShowGeneratedCode) {
+ return result
+ }
+
+ // Map the tokens
+ const mappedNodes = result.nodes
+ .map((node) => {
+ if ('text' in node && node.text === 'any') {
+ return undefined
+ }
+ const startMap = get(map.toSourceLocation(node.start), 0)
+ if (!startMap) {
+ return undefined
+ }
+ const start = startMap[0]
+ let end = get(map.toSourceLocation(node.start + node.length), 0)?.[0]
+ if (end == null && startMap[1].sourceOffsets[0] === startMap[0]) {
+ end = startMap[1].sourceOffsets[1]
+ }
+ if (end == null || start < 0 || end < 0 || start > end) {
+ return undefined
+ }
+ return {
+ ...node,
+ target: code.slice(start, end),
+ start: startMap[0],
+ length: end - start,
+ }
+ })
+ .filter(value => value != null)
+
+ const mappedRemovals = [
+ ...sourceMeta.removals,
+ ...result.meta.removals.map((r) => {
+ const start = get(map.toSourceLocation(r[0]), 0)?.[0] ?? code.match(/(?<=