Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/site-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
25 changes: 23 additions & 2 deletions packages/site-kit/src/lib/markdown/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];

Expand All @@ -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,
Expand Down Expand Up @@ -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 = /<script\b[^>]*>/;
if (scriptTag.test(source)) {
return source.replace(
scriptTag,
match => `${match}\n${prelude}\n`
);
}
return `<script>\n${prelude}\n</script>\n\n${source}`;
}
return prelude + source;
}
277 changes: 277 additions & 0 deletions packages/site-kit/src/lib/markdown/twoslash-svelte.ts
Original file line number Diff line number Diff line change
@@ -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(/(?<=<script[\s\S]*>\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<T>(iterator: IterableIterator<T> | Generator<T>, 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)
}
Loading