Skip to content

Commit cc849fe

Browse files
committed
feat: add sandbox autocompletion
1 parent df106c6 commit cc849fe

8 files changed

Lines changed: 228 additions & 23 deletions

File tree

.vitepress/theme/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import DefaultTheme from 'vitepress/theme';
2-
import { h } from 'vue';
2+
import { defineAsyncComponent, h } from 'vue';
33

44
import './variables.css';
55
import './main.css';
66
import 'virtual:group-icons.css';
7-
import ApiOutline from '../../components/ApiOutline.vue';
8-
import CarbonAds from '../../components/CarbonAds.vue';
9-
import ModuleIndex from '../../components/ModuleIndex.vue';
10-
import StatusContent from '../../components/StatusContent.vue';
11-
import TesterContent from '../../components/TesterContent.vue';
127
import { getRedirectPath } from './redirect.js';
138

149
import type { Theme } from 'vitepress';
1510

11+
const ApiOutline = defineAsyncComponent(() => import('../../components/ApiOutline.vue'));
12+
const CarbonAds = defineAsyncComponent(() => import('../../components/CarbonAds.vue'));
13+
const ModuleIndex = defineAsyncComponent(() => import('../../components/ModuleIndex.vue'));
14+
const StatusContent = defineAsyncComponent(() => import('../../components/StatusContent.vue'));
15+
const TesterContent = defineAsyncComponent(() => import('../../components/TesterContent.vue'));
16+
1617
export default {
1718
Layout() {
1819
return h(DefaultTheme.Layout, null, {

components/CodeMirrorEditor.vue

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@
33
</template>
44

55
<script setup>
6+
import { autocompletion } from '@codemirror/autocomplete';
67
import { javascript } from '@codemirror/lang-javascript';
78
import { json } from '@codemirror/lang-json';
89
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
910
import { Compartment, EditorState, StateEffect, StateField } from '@codemirror/state';
10-
import { Decoration } from '@codemirror/view';
11+
import { Decoration, tooltips } from '@codemirror/view';
1112
import { tags as t } from '@lezer/highlight';
1213
import { darcula } from '@uiw/codemirror-theme-darcula';
1314
import { eclipse } from '@uiw/codemirror-theme-eclipse';
1415
import { EditorView, basicSetup } from 'codemirror';
1516
import { useData } from 'vitepress';
1617
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
1718
18-
const { errorLines, language, readOnly } = defineProps({
19+
import { joiCompletionSource } from '../composables/joiCompletionSource.ts';
20+
21+
const { errorLines, joiVersion, language, readOnly } = defineProps({
1922
errorLines: { default: () => [], type: Array },
23+
joiVersion: { default: null, type: String },
2024
language: { default: 'javascript', type: String },
2125
readOnly: { default: false, type: Boolean },
2226
});
@@ -31,6 +35,7 @@ let view = null;
3135
3236
3337
const themeCompartment = new Compartment();
38+
const joiCompartment = new Compartment();
3439
const setErrorLines = StateEffect.define();
3540
3641
@@ -72,12 +77,23 @@ const getThemeExtension = () => {
7277
};
7378
7479
80+
const getJoiExtension = () => {
81+
if (!joiVersion) {
82+
return [];
83+
}
84+
return autocompletion({
85+
override: [(context) => joiCompletionSource(context, joiVersion)],
86+
});
87+
};
88+
89+
7590
onMounted(() => {
7691
const extensions = [
7792
basicSetup,
7893
language === 'json' ? json() : javascript(),
7994
EditorView.lineWrapping,
8095
themeCompartment.of(getThemeExtension()),
96+
joiCompartment.of(getJoiExtension()),
8197
EditorView.updateListener.of((update) => {
8298
if (update.docChanged) {
8399
data.value = update.state.doc.toString();
@@ -136,6 +152,18 @@ watch(isDark, () => {
136152
});
137153
138154
155+
watch(
156+
() => joiVersion,
157+
() => {
158+
if (view) {
159+
view.dispatch({
160+
effects: joiCompartment.reconfigure(getJoiExtension()),
161+
});
162+
}
163+
},
164+
);
165+
166+
139167
onBeforeUnmount(() => {
140168
if (view) {
141169
view.destroy();
@@ -147,8 +175,11 @@ onBeforeUnmount(() => {
147175
.editor-container {
148176
border: 1px solid var(--vp-c-divider);
149177
border-radius: 4px;
150-
overflow: hidden;
151178
font-family: var(--vp-font-family-mono);
152179
font-size: 14px;
153180
}
181+
182+
.editor-container :deep(.cm-tooltip-autocomplete > ul) {
183+
max-height: 350px;
184+
}
154185
</style>

components/TesterContent.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<div class="field">
1111
<h2 class="tester-subTitle">Schema:</h2>
12-
<CodeMirrorEditor v-model="schema" />
12+
<CodeMirrorEditor v-model="schema" :joi-version="version" />
1313
</div>
1414

1515
<div class="field">
@@ -71,7 +71,7 @@
7171
<script setup>
7272
import { useClipboard, useStorage } from '@vueuse/core';
7373
import { useRoute } from 'vitepress';
74-
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
74+
import { onMounted, ref, watch } from 'vue';
7575
7676
import { annotate } from '../composables/annotate.ts';
7777
import joiInfo from '../generated/modules/joi/info.json' with { type: 'json' };

composables/joiCompletionSource.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { createDefaultMapFromCDN, createSystem, createVirtualTypeScriptEnvironment } from '@typescript/vfs';
2+
3+
import type { CompletionContext } from '@codemirror/autocomplete';
4+
import type { VirtualTypeScriptEnvironment } from '@typescript/vfs';
5+
6+
let env: VirtualTypeScriptEnvironment | null = null;
7+
let currentVersion: string | null = null;
8+
9+
const getEnv = async (version: string) => {
10+
if (env && currentVersion === version) {
11+
return env;
12+
}
13+
14+
const ts = await import('typescript');
15+
16+
const compilerOptions = {
17+
lib: ['es2024'],
18+
module: ts.ModuleKind.ESNext,
19+
target: ts.ScriptTarget.ESNext,
20+
};
21+
22+
const fsMap = await createDefaultMapFromCDN(compilerOptions, ts.version, true, ts);
23+
24+
const { default: joiDts } = version.startsWith('17.')
25+
? await import('joi-17/lib/index.d.ts?raw')
26+
: await import('joi-18/lib/index.d.ts?raw');
27+
28+
fsMap.set('/node_modules/joi/index.d.ts', joiDts);
29+
fsMap.set('/node_modules/joi/package.json', JSON.stringify({ name: 'joi', types: 'index.d.ts' }));
30+
31+
if (version.startsWith('18.')) {
32+
const { default: standardSchemaTypes } = await import('../node_modules/@standard-schema/spec/dist/index.d.ts?raw');
33+
fsMap.set('/node_modules/@standard-schema/spec/index.d.ts', standardSchemaTypes);
34+
fsMap.set(
35+
'/node_modules/@standard-schema/spec/package.json',
36+
JSON.stringify({ name: '@standard-schema/spec', types: 'index.d.ts' }),
37+
);
38+
}
39+
40+
const system = createSystem(fsMap);
41+
env = createVirtualTypeScriptEnvironment(system, [], ts, compilerOptions);
42+
currentVersion = version;
43+
44+
return env;
45+
};
46+
47+
const filterJoiOperations = (name: string) =>
48+
name.startsWith('$') ||
49+
name.startsWith('_') ||
50+
name.startsWith('validate') ||
51+
name === 'cache' ||
52+
name === 'ValidationError';
53+
54+
export const joiCompletionSource = async (context: CompletionContext, version: string) => {
55+
try {
56+
const tsEnv = await getEnv(version);
57+
const code = context.state.doc.toString();
58+
59+
const prefix = "import Joi from 'joi';\n";
60+
const wrappedCode = prefix + code;
61+
const pos = context.pos + prefix.length;
62+
63+
const fileName = 'index.ts';
64+
if (tsEnv.getSourceFile(fileName)) {
65+
tsEnv.updateFile(fileName, wrappedCode);
66+
} else {
67+
tsEnv.createFile(fileName, wrappedCode);
68+
}
69+
70+
const completions = tsEnv.languageService.getCompletionsAtPosition(fileName, pos, {});
71+
if (!completions) {
72+
return null;
73+
}
74+
75+
const word = context.matchBefore(/\w*/);
76+
if (!word && !context.explicit) {
77+
return null;
78+
}
79+
80+
return {
81+
from: word ? word.from : context.pos,
82+
options: completions.entries
83+
.filter((entry) => !filterJoiOperations(entry.name))
84+
.map((entry) => {
85+
let type = 'variable';
86+
if (entry.kind === 'method') {
87+
type = 'method';
88+
} else if (entry.kind === 'property') {
89+
type = 'property';
90+
}
91+
92+
return {
93+
boost: entry.sortText ? -Number(entry.sortText) : 0,
94+
info: () => {
95+
const details = tsEnv.languageService.getCompletionEntryDetails(
96+
fileName,
97+
pos,
98+
entry.name,
99+
{},
100+
entry.source,
101+
{},
102+
entry.data,
103+
);
104+
if (!details) {
105+
return null;
106+
}
107+
108+
const doc = `${entry.kind} ${entry.name}
109+
${details.documentation?.map((d) => d.text).join('\n') || ''}`;
110+
111+
const div = document.createElement('div');
112+
div.className = 'cm-completionInfo-text';
113+
div.style.whiteSpace = 'pre-wrap';
114+
div.textContent = doc;
115+
return div;
116+
},
117+
label: entry.name,
118+
type,
119+
};
120+
}),
121+
};
122+
} catch (error) {
123+
console.error('Joi completion error:', error);
124+
return null;
125+
}
126+
};

env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ declare module '*.vue' {
44
const component: DefineComponent<{}, {}, any>;
55
export default component;
66
}
7+
8+
declare module '*?raw' {
9+
const content: string;
10+
export default content;
11+
}

generated/metadata/modules.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"address": {
33
"forks": 27,
44
"link": "https://github.com/hapijs/address",
5+
"package": "@hapi/address",
56
"slogan": "Validate email address and domain.",
67
"stars": 33,
78
"updated": "2024-01-29T12:37:22Z",
@@ -13,12 +14,12 @@
1314
"node": ">= 14"
1415
}
1516
],
16-
"versionsArray": ["5.1.1"],
17-
"package": "@hapi/address"
17+
"versionsArray": ["5.1.1"]
1818
},
1919
"formula": {
2020
"forks": 20,
2121
"link": "https://github.com/hapijs/formula",
22+
"package": "@hapi/formula",
2223
"slogan": "Math and string formula parser.",
2324
"stars": 17,
2425
"updated": "2024-02-02T16:21:44Z",
@@ -30,12 +31,12 @@
3031
"node": ">= 14"
3132
}
3233
],
33-
"versionsArray": ["3.0.2"],
34-
"package": "@hapi/formula"
34+
"versionsArray": ["3.0.2"]
3535
},
3636
"joi": {
3737
"forks": 1509,
3838
"link": "https://github.com/hapijs/joi",
39+
"package": "joi",
3940
"slogan": "The most powerful schema description language and data validator for JavaScript.",
4041
"stars": 21199,
4142
"updated": "2026-03-23T17:51:24Z",
@@ -53,12 +54,12 @@
5354
"node": ">= 20"
5455
}
5556
],
56-
"versionsArray": ["18.1.1", "17.13.3"],
57-
"package": "joi"
57+
"versionsArray": ["18.1.1", "17.13.3"]
5858
},
5959
"joi-date": {
6060
"forks": 23,
6161
"link": "https://github.com/hapijs/joi-date",
62+
"package": "@joi/date",
6263
"slogan": "Extensions for advance date rules.",
6364
"stars": 81,
6465
"updated": "2024-04-22T09:05:34Z",
@@ -70,12 +71,12 @@
7071
"node": ">= 14"
7172
}
7273
],
73-
"versionsArray": ["2.1.1"],
74-
"package": "@joi/date"
74+
"versionsArray": ["2.1.1"]
7575
},
7676
"pinpoint": {
7777
"forks": 6,
7878
"link": "https://github.com/hapijs/pinpoint",
79+
"package": "@hapi/pinpoint",
7980
"slogan": "Return the filename and line number of the calling function.",
8081
"stars": 6,
8182
"updated": "2023-11-12T09:48:56Z",
@@ -87,12 +88,12 @@
8788
"node": ">= 14"
8889
}
8990
],
90-
"versionsArray": ["2.0.1"],
91-
"package": "@hapi/pinpoint"
91+
"versionsArray": ["2.0.1"]
9292
},
9393
"tlds": {
9494
"forks": 2,
9595
"link": "https://github.com/hapijs/tlds",
96+
"package": "@hapi/tlds",
9697
"slogan": "TLDS list for domain validation.",
9798
"stars": 2,
9899
"updated": "2026-02-17T14:09:37Z",
@@ -104,7 +105,6 @@
104105
"node": ">= 14"
105106
}
106107
],
107-
"versionsArray": ["1.1.6"],
108-
"package": "@hapi/tlds"
108+
"versionsArray": ["1.1.6"]
109109
}
110110
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,22 @@
1919
"license": "BSD-3-Clause",
2020
"dependencies": {
2121
"@babel/runtime": "^7.29.2",
22+
"@codemirror/autocomplete": "^6.20.1",
2223
"@codemirror/lang-javascript": "^6.2.5",
2324
"@codemirror/lang-json": "^6.0.2",
2425
"@codemirror/language": "^6.12.2",
2526
"@codemirror/state": "^6.6.0",
2627
"@codemirror/view": "^6.40.0",
2728
"@lezer/highlight": "^1.2.3",
29+
"@typescript/vfs": "^1.6.4",
2830
"@uiw/codemirror-theme-darcula": "^4.25.8",
2931
"@uiw/codemirror-theme-eclipse": "^4.25.8",
3032
"@vueuse/core": "^14.2.1",
3133
"codemirror": "^6.0.2",
3234
"es-toolkit": "^1.45.1",
3335
"joi-17": "npm:joi@17.13.3",
3436
"joi-18": "npm:joi@18.0.2",
37+
"@standard-schema/spec": "^1.1.0",
3538
"semver": "^7.7.4",
3639
"vitepress-plugin-group-icons": "^1.7.1",
3740
"vue": "^3.5.30"

0 commit comments

Comments
 (0)