Skip to content

Commit 0cea9af

Browse files
committed
feat: metadata autocompletion
1 parent 5a1f108 commit 0cea9af

File tree

8 files changed

+497
-54
lines changed

8 files changed

+497
-54
lines changed

extensions/vscode/build.mjs

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,60 @@
1+
import { chapterSchema, lessonSchema, partSchema, tutorialSchema } from '@tutorialkit/types';
2+
import { watch } from 'chokidar';
13
import * as esbuild from 'esbuild';
2-
import fs from 'node:fs';
34
import { execa } from 'execa';
5+
import fs from 'node:fs';
6+
import { createRequire } from 'node:module';
7+
import { zodToJsonSchema } from 'zod-to-json-schema';
48

9+
const require = createRequire(import.meta.url);
510
const production = process.argv.includes('--production');
6-
const watch = process.argv.includes('--watch');
11+
const isWatch = process.argv.includes('--watch');
712

813
async function main() {
914
const ctx = await esbuild.context({
10-
entryPoints: ['src/extension.ts'],
15+
entryPoints: {
16+
extension: 'src/extension.ts',
17+
server: './src/language-server/index.ts',
18+
},
1119
bundle: true,
1220
format: 'cjs',
1321
minify: production,
1422
sourcemap: !production,
1523
sourcesContent: false,
24+
tsconfig: './tsconfig.json',
1625
platform: 'node',
17-
outfile: 'dist/extension.js',
26+
outdir: 'dist',
27+
define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' },
1828
external: ['vscode'],
19-
logLevel: 'silent',
20-
plugins: [
21-
/* add to the end of plugins array */
22-
esbuildProblemMatcherPlugin,
23-
],
29+
plugins: [esbuildUMD2ESMPlugin],
2430
});
2531

26-
if (watch) {
32+
if (isWatch) {
33+
const buildMetadataSchemaDebounced = debounce(buildMetadataSchema);
34+
35+
watch(join(require.resolve('@tutorialkit/types'), 'dist'), {
36+
followSymlinks: false,
37+
}).on('all', (eventName, path) => {
38+
if (eventName !== 'change' && eventName !== 'add' && eventName !== 'unlink') {
39+
return;
40+
}
41+
42+
buildMetadataSchemaDebounced();
43+
});
44+
2745
await Promise.all([
2846
ctx.watch(),
29-
execa('tsc', ['--noEmit', '--watch', '--project', 'tsconfig.json'], { stdio: 'inherit', preferLocal: true }),
47+
execa('tsc', ['--noEmit', '--watch', '--preserveWatchOutput', '--project', 'tsconfig.json'], {
48+
stdio: 'inherit',
49+
preferLocal: true,
50+
}),
3051
]);
3152
} else {
3253
await ctx.rebuild();
3354
await ctx.dispose();
3455

56+
buildMetadataSchema();
57+
3558
if (production) {
3659
// rename name in package json to match extension name on store:
3760
const pkgJSON = JSON.parse(fs.readFileSync('./package.json', { encoding: 'utf8' }));
@@ -43,21 +66,24 @@ async function main() {
4366
}
4467
}
4568

69+
function buildMetadataSchema() {
70+
const schema = tutorialSchema.strict().or(partSchema.strict()).or(chapterSchema.strict()).or(lessonSchema.strict());
71+
72+
fs.mkdirSync('./dist', { recursive: true });
73+
fs.writeFileSync('./dist/schema.json', JSON.stringify(zodToJsonSchema(schema), undefined, 2), 'utf-8');
74+
}
75+
4676
/**
4777
* @type {import('esbuild').Plugin}
4878
*/
49-
const esbuildProblemMatcherPlugin = {
50-
name: 'esbuild-problem-matcher',
79+
const esbuildUMD2ESMPlugin = {
80+
name: 'umd2esm',
5181
setup(build) {
52-
build.onStart(() => {
53-
console.log('[watch] build started');
54-
});
55-
build.onEnd((result) => {
56-
result.errors.forEach(({ text, location }) => {
57-
console.error(`✘ [ERROR] ${text}`);
58-
console.error(` ${location.file}:${location.line}:${location.column}:`);
59-
});
60-
console.log('[watch] build finished');
82+
build.onResolve({ filter: /^(vscode-.*-languageservice|jsonc-parser)/ }, (args) => {
83+
const pathUmdMay = require.resolve(args.path, { paths: [args.resolveDir] });
84+
const pathEsm = pathUmdMay.replace('/umd/', '/esm/').replace('\\umd\\', '\\esm\\');
85+
86+
return { path: pathEsm };
6187
});
6288
},
6389
};
@@ -66,3 +92,20 @@ main().catch((error) => {
6692
console.error(error);
6793
process.exit(1);
6894
});
95+
96+
/**
97+
* Debounce the provided function.
98+
*
99+
* @param {Function} fn Function to debounce
100+
* @param {number} duration Duration of the debounce
101+
* @returns {Function} Debounced function
102+
*/
103+
function debounce(fn, duration) {
104+
let timeoutId = 0;
105+
106+
return function () {
107+
clearTimeout(timeoutId);
108+
109+
timeoutId = setTimeout(fn.bind(this), duration, ...arguments);
110+
};
111+
}

extensions/vscode/package.json

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,21 @@
102102
"when": "view == tutorialkit-lessons-tree && viewItem == part"
103103
}
104104
]
105-
}
105+
},
106+
"languages": [
107+
{
108+
"id": "markdown",
109+
"extensions": [
110+
".md"
111+
]
112+
},
113+
{
114+
"id": "mdx",
115+
"extensions": [
116+
".mdx"
117+
]
118+
}
119+
]
106120
},
107121
"scripts": {
108122
"__esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node",
@@ -116,15 +130,25 @@
116130
"package": "pnpm run check-types && node build.mjs --production"
117131
},
118132
"dependencies": {
133+
"@volar/language-core": "2.3.4",
134+
"@volar/language-server": "2.3.4",
135+
"@volar/language-service": "2.3.4",
136+
"@volar/vscode": "2.3.4",
119137
"case-anything": "^3.1.0",
120-
"gray-matter": "^4.0.3"
138+
"gray-matter": "^4.0.3",
139+
"volar-service-yaml": "volar-2.3",
140+
"vscode-languageclient": "^9.0.1",
141+
"vscode-uri": "^3.0.8",
142+
"yaml-language-server": "1.15.0"
121143
},
122144
"devDependencies": {
123-
"@types/mocha": "^10.0.6",
145+
"@tutorialkit/types": "workspace:*",
124146
"@types/node": "20.14.11",
125147
"@types/vscode": "^1.80.0",
148+
"chokidar": "3.6.0",
126149
"esbuild": "^0.21.5",
127150
"execa": "^9.2.0",
128-
"typescript": "^5.4.5"
151+
"typescript": "^5.4.5",
152+
"zod-to-json-schema": "3.23.1"
129153
}
130154
}

extensions/vscode/src/extension.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,49 @@
1+
import * as serverProtocol from '@volar/language-server/protocol';
2+
import { createLabsInfo } from '@volar/vscode';
13
import * as vscode from 'vscode';
24
import { useCommands } from './commands';
35
import { useLessonTree } from './views/lessonsTree';
6+
import * as lsp from 'vscode-languageclient/node';
47

58
export let extContext: vscode.ExtensionContext;
69

7-
export function activate(context: vscode.ExtensionContext) {
10+
let client: lsp.BaseLanguageClient;
11+
12+
export async function activate(context: vscode.ExtensionContext) {
813
extContext = context;
914

1015
useCommands();
1116
useLessonTree();
17+
18+
const serverModule = vscode.Uri.joinPath(context.extensionUri, 'dist', 'server.js');
19+
const runOptions = { execArgv: <string[]>[] };
20+
const debugOptions = { execArgv: ['--nolazy', '--inspect=' + 6009] };
21+
const serverOptions: lsp.ServerOptions = {
22+
run: {
23+
module: serverModule.fsPath,
24+
transport: lsp.TransportKind.ipc,
25+
options: runOptions,
26+
},
27+
debug: {
28+
module: serverModule.fsPath,
29+
transport: lsp.TransportKind.ipc,
30+
options: debugOptions,
31+
},
32+
};
33+
const clientOptions: lsp.LanguageClientOptions = {
34+
documentSelector: [{ language: 'markdown' }, { language: 'mdx' }],
35+
initializationOptions: {},
36+
};
37+
client = new lsp.LanguageClient('tutorialkit-language-server', 'TutorialKit', serverOptions, clientOptions);
38+
39+
await client.start();
40+
41+
const labsInfo = createLabsInfo(serverProtocol);
42+
labsInfo.addLanguageClient(client);
43+
44+
return labsInfo.extensionExports;
1245
}
1346

1447
export function deactivate() {
15-
// do nothing
48+
return client?.stop();
1649
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createConnection, createServer, createSimpleProject } from '@volar/language-server/node';
2+
import { create as createYamlService } from 'volar-service-yaml';
3+
import { SchemaPriority } from 'yaml-language-server';
4+
import { frontmatterPlugin } from './languagePlugin';
5+
import { readSchema } from './schema';
6+
7+
const connection = createConnection();
8+
const server = createServer(connection);
9+
10+
connection.listen();
11+
12+
connection.onInitialize((params) => {
13+
connection.console.debug('CONNECTED' + params.capabilities);
14+
15+
const yamlService = createYamlService({
16+
getLanguageSettings(_context) {
17+
const schema = readSchema();
18+
19+
return {
20+
completion: true,
21+
validate: true,
22+
hover: true,
23+
format: true,
24+
yamlVersion: '1.2',
25+
isKubernetes: false,
26+
schemas: [
27+
{
28+
uri: 'https://tutorialkit.dev/schema.json',
29+
schema,
30+
fileMatch: [
31+
'**/*',
32+
33+
// TODO: those don't work
34+
'src/content/*.md',
35+
'src/content/**/*.md',
36+
'src/content/**/*.mdx',
37+
],
38+
priority: SchemaPriority.Settings,
39+
},
40+
],
41+
};
42+
},
43+
});
44+
45+
delete yamlService.capabilities.codeLensProvider;
46+
47+
return server.initialize(
48+
params,
49+
createSimpleProject([frontmatterPlugin(connection.console.debug.bind(connection.console.debug))]),
50+
[yamlService],
51+
);
52+
});
53+
54+
connection.onInitialized(server.initialized);
55+
56+
connection.onShutdown(server.shutdown);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { CodeMapping, type LanguagePlugin, type VirtualCode } from '@volar/language-core';
2+
import type * as ts from 'typescript';
3+
import type { URI } from 'vscode-uri';
4+
5+
export const frontmatterPlugin = (debug: (message: string) => void) =>
6+
({
7+
getLanguageId(uri) {
8+
debug('URI: ' + uri.path);
9+
10+
if (uri.path.endsWith('.md')) {
11+
return 'markdown';
12+
}
13+
14+
if (uri.path.endsWith('.mdx')) {
15+
return 'mdx';
16+
}
17+
18+
return undefined;
19+
},
20+
createVirtualCode(_uri, languageId, snapshot) {
21+
if (languageId === 'markdown' || languageId === 'mdx') {
22+
return new FrontMatterVirtualCode(snapshot);
23+
}
24+
25+
return undefined;
26+
},
27+
}) satisfies LanguagePlugin<URI>;
28+
29+
export class FrontMatterVirtualCode implements VirtualCode {
30+
id = 'root';
31+
languageId = 'markdown';
32+
mappings: CodeMapping[];
33+
embeddedCodes: VirtualCode[] = [];
34+
35+
constructor(public snapshot: ts.IScriptSnapshot) {
36+
this.mappings = [
37+
{
38+
sourceOffsets: [0],
39+
generatedOffsets: [0],
40+
lengths: [snapshot.getLength()],
41+
data: {
42+
completion: true,
43+
format: true,
44+
navigation: true,
45+
semantic: true,
46+
structure: true,
47+
verification: true,
48+
},
49+
},
50+
];
51+
52+
this.embeddedCodes = [...frontMatterCode(snapshot)];
53+
}
54+
}
55+
56+
function* frontMatterCode(snapshot: ts.IScriptSnapshot): Generator<VirtualCode> {
57+
const content = snapshot.getText(0, snapshot.getLength());
58+
59+
let frontMatterStartIndex = content.indexOf('---');
60+
61+
if (frontMatterStartIndex === -1) {
62+
return;
63+
}
64+
65+
frontMatterStartIndex += 3;
66+
67+
let frontMatterEndIndex = content.indexOf('---', frontMatterStartIndex);
68+
69+
if (frontMatterEndIndex === -1) {
70+
frontMatterEndIndex = snapshot.getLength();
71+
}
72+
73+
const frontMatterText = content.substring(frontMatterStartIndex, frontMatterEndIndex);
74+
75+
yield {
76+
id: 'frontmatter_1',
77+
languageId: 'yaml',
78+
snapshot: {
79+
getText: (start, end) => frontMatterText.slice(start, end),
80+
getLength: () => frontMatterText.length,
81+
getChangeRange: () => undefined,
82+
},
83+
mappings: [
84+
{
85+
sourceOffsets: [frontMatterStartIndex],
86+
generatedOffsets: [0],
87+
lengths: [frontMatterText.length],
88+
data: {
89+
completion: true,
90+
format: true,
91+
navigation: true,
92+
semantic: true,
93+
structure: true,
94+
verification: true,
95+
},
96+
},
97+
],
98+
embeddedCodes: [],
99+
};
100+
}

0 commit comments

Comments
 (0)