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 = /]*>/; + if (scriptTag.test(source)) { + return source.replace( + scriptTag, + match => `${match}\n${prelude}\n` + ); + } + return `\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(/(?<=\s)/)?.index + const end = get(map.toSourceLocation(r[1]), 0)?.[0] + if (start == null || end == null || start < 0 || end < 0 || start >= end) { + return undefined + } + return [start, end] as Range + }).filter(value => value != null), + ] + + if (!options.handbookOptions?.keepNotations) { + const removed = removeCodeRanges(code, mappedRemovals, mappedNodes) + result.code = removed.code + result.meta.removals = removed.removals + result.nodes = resolveNodePositions(removed.nodes, result.code) + } + else { + result.code = code + result.meta.removals = mappedRemovals + } + result.nodes = result.nodes.filter((node, index) => { + const next = result.nodes[index + 1] + if (!next) { + return true + } + // When multiple nodes are on the same position, we keep the last one by ignoring the previous ones + if (next.type === node.type && next.start === node.start) { + return false + } + return true + }) + result.meta.extension = 'svelte' + return result + } + + twoslasher.getCacheMap = twoslasherBase.getCacheMap + + return twoslasher +} + +/** + * @deprecated Use `createTwoslasher` instead. + */ +export const createTwoslasherSvelte = createTwoslasher + +function get(iterator: IterableIterator | Generator, index: number): T | undefined { + for (const item of iterator) { + if (index-- === 0) + return item + } + return undefined +} + +function generateSourceMap( + sourceCode: string, + generatedCode: string, + encodedMappings: string, +): SourceMap { + const generatedPositionConverter = createPositionConverter(generatedCode) + const sourcePositionConverter = createPositionConverter(sourceCode) + const decodedMappings = decode(encodedMappings) + const mappings: CodeMapping[] = [] + + let current: + | { + genOffset: number + sourceOffset: number + } + | undefined + + for (let genLine = 0; genLine < decodedMappings.length; genLine++) { + for (const segment of decodedMappings[genLine]) { + const genCharacter = segment[0] + const genOffset = generatedPositionConverter.posToIndex(genLine, genCharacter) + if (current) { + let length = genOffset - current.genOffset + const sourceText = sourceCode.substring(current.sourceOffset, current.sourceOffset + length) + const genText = generatedCode.substring(current.genOffset, current.genOffset + length) + if (sourceText !== genText) { + length = 0 + for (let i = 0; i < genOffset - current.genOffset; i++) { + if (sourceText[i] === genText[i]) { + length = i + 1 + } + else { + break + } + } + } + if (length > 0) { + const lastMapping = mappings.length ? mappings[mappings.length - 1] : undefined + if ( + lastMapping + && lastMapping.generatedOffsets[0] + lastMapping.lengths[0] === current.genOffset + && lastMapping.sourceOffsets[0] + lastMapping.lengths[0] === current.sourceOffset + ) { + lastMapping.lengths[0] += length + } + else { + mappings.push({ + sourceOffsets: [current.sourceOffset], + generatedOffsets: [current.genOffset], + lengths: [length], + data: { + verification: true, + completion: true, + semantic: true, + navigation: true, + structure: true, + format: false, + }, + }) + } + } + current = undefined + } + if (segment[2] !== undefined && segment[3] !== undefined) { + const sourceOffset = sourcePositionConverter.posToIndex(segment[2], segment[3]) + current = { + genOffset, + sourceOffset, + } + } + } + } + return new SourceMap(mappings) +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dec8fcfa2f..4ea5fd7028 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: specifier: ^3.23.0 version: 3.23.0 devDependencies: + '@jridgewell/sourcemap-codec': + specifier: ^1.5.5 + version: 1.5.5 '@shikijs/vitepress-twoslash': specifier: ^4.0.1 version: 4.0.1(typescript@6.0.3) @@ -434,6 +437,9 @@ importers: '@types/node': specifier: ^20.19.33 version: 20.19.33 + '@volar/language-core': + specifier: ^2.4.28 + version: 2.4.28 flexsearch: specifier: ^0.8.212 version: 0.8.212 @@ -458,6 +464,15 @@ importers: svelte-check: specifier: ^4.5.0 version: 4.5.0(picomatch@4.0.3)(svelte@5.55.8(@typescript-eslint/types@8.58.0))(typescript@6.0.3) + svelte2tsx: + specifier: ^0.7.56 + version: 0.7.56(svelte@5.55.8(@typescript-eslint/types@8.58.0))(typescript@6.0.3) + twoslash: + specifier: ^0.3.8 + version: 0.3.8(typescript@6.0.3) + twoslash-protocol: + specifier: ^0.3.8 + version: 0.3.8 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -3252,11 +3267,11 @@ packages: typescript: optional: true - svelte2tsx@0.7.51: - resolution: {integrity: sha512-YbVMQi5LtQkVGOMdATTY8v3SMtkNjzYtrVDGaN3Bv+0LQ47tGXu/Oc8ryTkcYuEJWTZFJ8G2+2I8ORcQVGt9Ag==} + svelte2tsx@0.7.56: + resolution: {integrity: sha512-NTvqqL+goYlW8gWNajk81L07+uu7jw5V2m1Az5MZbYm3GEydcHXh+uTrLHM9SuGuaqCtF90vlMXkOVBotfH94g==} peerDependencies: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 - typescript: ^4.9.4 || ^5.0.0 + typescript: ^4.9.4 || ^5.0.0 || ^6.0.0 svelte@5.55.8: resolution: {integrity: sha512-4D6lyrMHmDaZalQOEBMCWCCidyZjSnec14/oPn0k627G6goxcck9xqMwz1tFLlQz+ZFvtTTHfFOlUayuAz0z6Q==} @@ -3333,6 +3348,9 @@ packages: twoslash-protocol@0.3.6: resolution: {integrity: sha512-FHGsJ9Q+EsNr5bEbgG3hnbkvEBdW5STgPU824AHUjB4kw0Dn4p8tABT7Ncg1Ie6V0+mDg3Qpy41VafZXcQhWMA==} + twoslash-protocol@0.3.8: + resolution: {integrity: sha512-HmvAHoiEviK8LqvAQyc9/irkdvwTUiR1fHmNwH/0gq8EHxyBt4PWVPixjEXg6wJu1u6yBrILEWXGK9Kw58/8yQ==} + twoslash-vue@0.3.6: resolution: {integrity: sha512-HXYxU+Y7jZiMXJN4980fQNMYflLD8uqKey1qVW5ri8bqYTm2t5ILmOoCOli7esdCHlMq4/No3iQUWBWDhZNs9w==} peerDependencies: @@ -3343,6 +3361,11 @@ packages: peerDependencies: typescript: ^5.5.0 + twoslash@0.3.8: + resolution: {integrity: sha512-OeDz0kDl8sqPUN3nr7gqcvOs70f5lZsdhKYTX3/SgB9OvdadzzoYJI/4SBXhXV1HG8E9fLc+e17itoRYTxmoig==} + peerDependencies: + typescript: ^5.5.0 || ^6.0.0 + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -4748,7 +4771,7 @@ snapshots: dependencies: '@shikijs/core': 3.23.0 '@shikijs/types': 3.23.0 - twoslash: 0.3.6(typescript@6.0.3) + twoslash: 0.3.8(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4757,7 +4780,7 @@ snapshots: dependencies: '@shikijs/core': 4.0.1 '@shikijs/types': 4.0.1 - twoslash: 0.3.6(typescript@6.0.3) + twoslash: 0.3.8(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4784,7 +4807,7 @@ snapshots: mdast-util-to-hast: 13.2.1 ohash: 2.0.11 shiki: 4.0.1 - twoslash: 0.3.6(typescript@6.0.3) + twoslash: 0.3.8(typescript@6.0.3) twoslash-vue: 0.3.6(typescript@6.0.3) vue: 3.5.29(typescript@6.0.3) transitivePeerDependencies: @@ -4936,7 +4959,7 @@ snapshots: sade: 1.8.1 semver: 7.7.4 svelte: 5.55.8(@typescript-eslint/types@8.58.0) - svelte2tsx: 0.7.51(svelte@5.55.8(@typescript-eslint/types@8.58.0))(typescript@6.0.3) + svelte2tsx: 0.7.56(svelte@5.55.8(@typescript-eslint/types@8.58.0))(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -6555,7 +6578,7 @@ snapshots: postcss: 8.5.6 typescript: 6.0.3 - svelte2tsx@0.7.51(svelte@5.55.8(@typescript-eslint/types@8.58.0))(typescript@6.0.3): + svelte2tsx@0.7.56(svelte@5.55.8(@typescript-eslint/types@8.58.0))(typescript@6.0.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 @@ -6648,6 +6671,8 @@ snapshots: twoslash-protocol@0.3.6: {} + twoslash-protocol@0.3.8: {} + twoslash-vue@0.3.6(typescript@6.0.3): dependencies: '@vue/language-core': 3.2.5 @@ -6665,6 +6690,14 @@ snapshots: transitivePeerDependencies: - supports-color + twoslash@0.3.8(typescript@6.0.3): + dependencies: + '@typescript/vfs': 1.6.4(typescript@6.0.3) + twoslash-protocol: 0.3.8 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + typescript@6.0.3: {} uc.micro@2.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bcfba92078..b94601a344 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ minimumReleaseAgeExclude: - prettier-plugin-svelte - svelte-check - esm-env + - twoslash-svelte # NOTE: Remove this exclusion before this PR is merged blockExoticSubdeps: true packages: