Skip to content

Commit be0a096

Browse files
authored
feat(extension): metadata autocompletion (#143)
1 parent 8dde2c3 commit be0a096

File tree

18 files changed

+696
-162
lines changed

18 files changed

+696
-162
lines changed

docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ Navigating to a lesson that specifies `autoReload` will always reload the previe
174174
<PropertyTable inherited type="boolean" />
175175

176176
##### `template`
177-
Specified which folder from the `src/templates/` directory should be used as the basis for the code. See the "[Code templates](/guides/creating-content/#code-templates)" guide for a detailed explainer.
177+
Specifies which folder from the `src/templates/` directory should be used as the basis for the code. See the "[Code templates](/guides/creating-content/#code-templates)" guide for a detailed explainer.
178178
<PropertyTable inherited type="string" />
179179

180180
#### `editPageLink`

extensions/vscode/build.mjs

Lines changed: 0 additions & 70 deletions
This file was deleted.

extensions/vscode/package.json

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,29 +102,53 @@
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",
109123
"__dev": "pnpm run esbuild-base -- --sourcemap --watch",
110124
"__vscode:prepublish": "pnpm run esbuild-base -- --minify",
111125
"__build": "vsce package",
112-
"dev": "node build.mjs --watch",
113-
"build": "pnpm run check-types && node build.mjs",
126+
"dev": "node scripts/build.mjs --watch",
127+
"build": "pnpm run check-types && node scripts/build.mjs",
114128
"check-types": "tsc --noEmit",
115129
"vscode:prepublish": "pnpm run package",
116-
"package": "pnpm run check-types && node build.mjs --production"
130+
"package": "pnpm run check-types && node scripts/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/scripts/build.mjs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { watch } from 'chokidar';
2+
import * as esbuild from 'esbuild';
3+
import { execa } from 'execa';
4+
import fs from 'node:fs';
5+
import { createRequire } from 'node:module';
6+
import { join, dirname } from 'path';
7+
import { Worker } from 'node:worker_threads';
8+
import { fileURLToPath } from 'node:url';
9+
10+
const __dirname = dirname(fileURLToPath(import.meta.url));
11+
const require = createRequire(import.meta.url);
12+
const production = process.argv.includes('--production');
13+
const isWatch = process.argv.includes('--watch');
14+
15+
async function main() {
16+
const ctx = await esbuild.context({
17+
entryPoints: {
18+
extension: 'src/extension.ts',
19+
server: './src/language-server/index.ts',
20+
},
21+
bundle: true,
22+
format: 'cjs',
23+
minify: production,
24+
sourcemap: !production,
25+
sourcesContent: false,
26+
tsconfig: './tsconfig.json',
27+
platform: 'node',
28+
outdir: 'dist',
29+
define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' },
30+
external: ['vscode'],
31+
plugins: [esbuildUMD2ESMPlugin],
32+
});
33+
34+
if (isWatch) {
35+
const buildMetadataSchemaDebounced = debounce(buildMetadataSchema, 100);
36+
const dependencyPath = dirname(require.resolve('@tutorialkit/types'));
37+
38+
watch(dependencyPath).on('all', (eventName, path) => {
39+
if (eventName !== 'change' && eventName !== 'add' && eventName !== 'unlink') {
40+
return;
41+
}
42+
43+
buildMetadataSchemaDebounced();
44+
});
45+
46+
await Promise.all([
47+
ctx.watch(),
48+
execa('tsc', ['--noEmit', '--watch', '--preserveWatchOutput', '--project', 'tsconfig.json'], {
49+
stdio: 'inherit',
50+
preferLocal: true,
51+
}),
52+
]);
53+
} else {
54+
await ctx.rebuild();
55+
await ctx.dispose();
56+
57+
await buildMetadataSchema();
58+
59+
if (production) {
60+
// rename name in `package.json` to match extension name on store
61+
const pkgJSON = JSON.parse(fs.readFileSync('./package.json', { encoding: 'utf8' }));
62+
63+
pkgJSON.name = 'tutorialkit';
64+
65+
fs.writeFileSync('./package.json', JSON.stringify(pkgJSON, undefined, 2), 'utf8');
66+
}
67+
}
68+
}
69+
70+
async function buildMetadataSchema() {
71+
const schema = await new Promise((resolve) => {
72+
const worker = new Worker(join(__dirname, './load-schema-worker.mjs'));
73+
worker.on('message', (value) => resolve(value));
74+
});
75+
76+
fs.mkdirSync('./dist', { recursive: true });
77+
fs.writeFileSync('./dist/schema.json', JSON.stringify(schema, undefined, 2), 'utf-8');
78+
79+
console.log('Updated schema.json');
80+
}
81+
82+
/**
83+
* @type {import('esbuild').Plugin}
84+
*/
85+
const esbuildUMD2ESMPlugin = {
86+
name: 'umd2esm',
87+
setup(build) {
88+
build.onResolve({ filter: /^(vscode-.*-languageservice|jsonc-parser)/ }, (args) => {
89+
const pathUmdMay = require.resolve(args.path, { paths: [args.resolveDir] });
90+
const pathEsm = pathUmdMay.replace('/umd/', '/esm/').replace('\\umd\\', '\\esm\\');
91+
92+
return { path: pathEsm };
93+
});
94+
},
95+
};
96+
97+
main().catch((error) => {
98+
console.error(error);
99+
process.exit(1);
100+
});
101+
102+
/**
103+
* Debounce the provided function.
104+
*
105+
* @param {Function} fn Function to debounce
106+
* @param {number} duration Duration of the debounce
107+
* @returns {Function} Debounced function
108+
*/
109+
function debounce(fn, duration) {
110+
let timeoutId = 0;
111+
112+
return function () {
113+
clearTimeout(timeoutId);
114+
115+
timeoutId = setTimeout(fn.bind(this), duration, ...arguments);
116+
};
117+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { parentPort } from 'node:worker_threads';
2+
import { zodToJsonSchema } from 'zod-to-json-schema';
3+
import { contentSchema } from '@tutorialkit/types';
4+
5+
parentPort.postMessage(zodToJsonSchema(contentSchema));

extensions/vscode/src/extension.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,52 @@
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+
22+
const serverOptions: lsp.ServerOptions = {
23+
run: {
24+
module: serverModule.fsPath,
25+
transport: lsp.TransportKind.ipc,
26+
options: runOptions,
27+
},
28+
debug: {
29+
module: serverModule.fsPath,
30+
transport: lsp.TransportKind.ipc,
31+
options: debugOptions,
32+
},
33+
};
34+
35+
const clientOptions: lsp.LanguageClientOptions = {
36+
documentSelector: [{ language: 'markdown' }, { language: 'mdx' }],
37+
initializationOptions: {},
38+
};
39+
40+
client = new lsp.LanguageClient('tutorialkit-language-server', 'TutorialKit', serverOptions, clientOptions);
41+
42+
await client.start();
43+
44+
const labsInfo = createLabsInfo(serverProtocol);
45+
labsInfo.addLanguageClient(client);
46+
47+
return labsInfo.extensionExports;
1248
}
1349

1450
export function deactivate() {
15-
// do nothing
51+
return client?.stop();
1652
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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+
const yamlService = createYamlService({
14+
getLanguageSettings(_context) {
15+
const schema = readSchema();
16+
17+
return {
18+
completion: true,
19+
validate: true,
20+
hover: true,
21+
format: true,
22+
yamlVersion: '1.2',
23+
isKubernetes: false,
24+
schemas: [
25+
{
26+
uri: 'https://tutorialkit.dev/reference/configuration',
27+
schema,
28+
fileMatch: [
29+
'**/*',
30+
31+
// TODO: these don't work
32+
'src/content/*.md',
33+
'src/content/**/*.md',
34+
'src/content/**/*.mdx',
35+
],
36+
priority: SchemaPriority.Settings,
37+
},
38+
],
39+
};
40+
},
41+
});
42+
43+
delete yamlService.capabilities.codeLensProvider;
44+
45+
return server.initialize(
46+
params,
47+
createSimpleProject([frontmatterPlugin(connection.console.debug.bind(connection.console.debug))]),
48+
[yamlService],
49+
);
50+
});
51+
52+
connection.onInitialized(server.initialized);
53+
54+
connection.onShutdown(server.shutdown);

0 commit comments

Comments
 (0)