Language Service Plugin
import path from 'node:path';import { bundleMarkdown } from '@sterashima78/ts-md-core';import { type LanguagePlugin, forEachEmbeddedCode } from '@volar/language-core';import type { TypeScriptExtraServiceScript } from '@volar/typescript';import ts from 'typescript';import { getChunkDict } from './parsers.ts.md';import { TsMdVirtualFile } from './virtual-file.ts.md';
export const tsMdLanguagePlugin = { getLanguageId(fileName: string) { const name = typeof fileName === 'string' ? fileName : (fileName as unknown as { fsPath: string }).fsPath; return name.endsWith('.ts.md') ? 'ts-md' : undefined; }, createVirtualCode( fileName: string, languageId: string, snapshot: ts.IScriptSnapshot, ) { if (languageId !== 'ts-md') return; const filePath = typeof fileName === 'string' ? fileName : (fileName as unknown as { fsPath: string }).fsPath; const dict = getChunkDict(snapshot, filePath); const uri = typeof fileName === 'string' ? fileName : (fileName as unknown as { toString(): string }).toString(); return new TsMdVirtualFile(snapshot, uri, dict); },
updateVirtualCode( fileName: string, oldFile: TsMdVirtualFile, snapshot: ts.IScriptSnapshot, ) { const name = typeof fileName === 'string' ? fileName : (fileName as unknown as { fsPath: string }).fsPath; if (!name.endsWith('.ts.md')) return; const filePath = typeof fileName === 'string' ? fileName : (fileName as unknown as { fsPath: string }).fsPath; const dict = getChunkDict(snapshot, filePath); oldFile.update(snapshot, dict); return oldFile; },
resolveFileName(specifier: string, fromFile: string) { const baseFile = typeof fromFile === 'string' ? fromFile : (fromFile as unknown as { fsPath: string }).fsPath; if (specifier.endsWith('.ts.md') && !specifier.includes(':')) { const abs = path.resolve(path.dirname(baseFile), specifier); return `${abs}__main.ts`; } if (!(specifier.includes('.ts.md:') || specifier.startsWith(':'))) return; const idx = specifier.lastIndexOf(':'); if (idx === -1) return; const rel = specifier.slice(0, idx); const chunk = specifier.slice(idx + 1); if (!chunk) return; const abs = rel ? path.resolve(path.dirname(baseFile), rel) : baseFile; if (path.resolve(abs) !== path.resolve(baseFile)) return; return `${abs}__${chunk}.ts`; }, typescript: { extraFileExtensions: [ { extension: 'ts.md', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred, }, ], getServiceScript(root: TsMdVirtualFile) { const main = root.embeddedCodes.find((c) => c.id.endsWith('__main.ts')); if (!main) return undefined; const text = root.snapshot.getText(0, root.snapshot.getLength()); const codeText = bundleMarkdown(text, root.id, 'main'); main.snapshot = { getText: (s, e) => codeText.slice(s, e), getLength: () => codeText.length, getChangeRange: () => undefined, }; return { code: main, extension: '.ts', scriptKind: ts.ScriptKind.TS, }; }, getExtraServiceScripts(_fileName: string, root: TsMdVirtualFile) { const scripts: TypeScriptExtraServiceScript[] = []; for (const code of forEachEmbeddedCode(root)) { if (code.languageId === 'ts') { scripts.push({ fileName: code.id, code, extension: '.ts', scriptKind: ts.ScriptKind.TS, }); } } return scripts; }, },} as LanguagePlugin<string, TsMdVirtualFile> & { resolveFileName(specifier: string, fromFile: string): string | undefined;};
if (import.meta.vitest) { await import(':plugin.test');}
Tests
import fs from 'node:fs';import path from 'node:path';import type { LanguagePlugin } from '@volar/language-core';import { type Language, type SourceScript, createLanguage, createLanguageService,} from '@volar/language-service';import ts from 'typescript';import { describe, expect, it } from 'vitest';import { URI } from 'vscode-uri';import { tsMdLanguagePlugin as createTsMdPlugin } from ':main';import type { TsMdVirtualFile } from './virtual-file.ts.md';
describe('ts-md-ls-core diagnostics', () => { const dir = path.join(process.cwd(), 'test', 'fixtures'); const aPath = path.join(dir, 'a.ts.md'); const mainPath = path.join(dir, 'main.ts.md');
it('reports diagnostics across docs', async () => { const scripts = new Map<URI, SourceScript<URI>>(); const plugin = createTsMdPlugin as unknown as LanguagePlugin<URI, TsMdVirtualFile>; let language!: Language<URI>; language = createLanguage<URI>([plugin], scripts, (id) => { if (scripts.has(id)) return; let filePath: string; if (typeof id === 'string') { const m = /^(.*)__/.exec(id); if (!m) return; filePath = URI.parse(m[1]).fsPath; } else { filePath = id.fsPath; } const snapshot = ts.ScriptSnapshot.fromString( fs.readFileSync(filePath, 'utf8'), ); language.scripts.set( typeof id === 'string' ? URI.parse(id) : id, snapshot, 'ts-md', ); }); const ls = createLanguageService(language, [], { workspaceFolders: [] }, {}); const uri = URI.file(mainPath); language.scripts.get(uri); const diags = await ls.getDiagnostics(uri); expect(diags.length).toBeGreaterThanOrEqual(0); });});