Skip to content

Commit 8092b41

Browse files
committed
Use Monaco for playground typechecking
1 parent 61465d2 commit 8092b41

10 files changed

Lines changed: 361 additions & 125 deletions

File tree

bun.lock

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

playgrounds/tskm/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
},
1111
"dependencies": {
1212
"@tskm/core": "workspace:*",
13+
"monaco-editor": "0.55.1",
1314
"react": "19.2.0",
1415
"react-dom": "19.2.0",
15-
"shiki": "4.2.0",
16-
"typescript": "6.0.3"
16+
"shiki": "4.2.0"
1717
},
1818
"devDependencies": {
1919
"@tskm/compiler": "workspace:*",

playgrounds/tskm/src/App.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useMemo, useState } from "react"
22
import { Button } from "./components/Button.tsx"
3-
import { CodeEditor } from "./components/CodeEditor.tsx"
3+
import { MonacoEditor } from "./components/MonacoEditor.tsx"
44
import { Pane } from "./components/Pane.tsx"
55
import { ResultView } from "./components/ResultView.tsx"
66
import { SegmentedControl } from "./components/SegmentedControl.tsx"
@@ -171,11 +171,10 @@ export function App() {
171171
</div>
172172
}
173173
>
174-
<CodeEditor
174+
<MonacoEditor
175175
label="tskm schema expression"
176176
value={schemaSource}
177177
language="typescript"
178-
highlighter={highlighter}
179178
onChange={setSchemaSource}
180179
minLines={19}
181180
/>
@@ -190,11 +189,10 @@ export function App() {
190189
</div>
191190
}
192191
>
193-
<CodeEditor
192+
<MonacoEditor
194193
label="JSON input"
195194
value={inputSource}
196195
language="json"
197-
highlighter={highlighter}
198196
diagnostics={inputTypecheck.diagnostics}
199197
onChange={setInputSource}
200198
minLines={19}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import * as monaco from "monaco-editor/esm/vs/editor/editor.main.js"
2+
import { useEffect, useId, useRef } from "react"
3+
import type { InputTypecheckDiagnostic } from "../lib/typecheck.ts"
4+
5+
interface MonacoEditorProps {
6+
readonly label: string
7+
readonly value: string
8+
readonly language: "typescript" | "json"
9+
readonly diagnostics?: readonly InputTypecheckDiagnostic[]
10+
readonly onChange: (value: string) => void
11+
readonly minLines?: number
12+
}
13+
14+
const markerOwner = "tskm-playground"
15+
16+
export function MonacoEditor({
17+
label,
18+
value,
19+
language,
20+
diagnostics = [],
21+
onChange,
22+
minLines = 16,
23+
}: MonacoEditorProps) {
24+
const id = useId().replaceAll(":", "-")
25+
const containerRef = useRef<HTMLDivElement>(null)
26+
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
27+
const modelRef = useRef<monaco.editor.ITextModel | null>(null)
28+
const initialValueRef = useRef(value)
29+
const onChangeRef = useRef(onChange)
30+
const lastValueRef = useRef(value)
31+
32+
useEffect(() => {
33+
onChangeRef.current = onChange
34+
}, [onChange])
35+
36+
useEffect(() => {
37+
const container = containerRef.current
38+
if (!container) return
39+
40+
const extension = language === "typescript" ? "ts" : "json"
41+
const uri = monaco.Uri.parse(`file:///tskm-playground/${id}.${extension}`)
42+
const model = monaco.editor.createModel(initialValueRef.current, language, uri)
43+
const editor = monaco.editor.create(container, {
44+
model,
45+
ariaLabel: label,
46+
automaticLayout: true,
47+
bracketPairColorization: { enabled: true },
48+
folding: false,
49+
fontFamily:
50+
'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Yu Gothic", "YuGothic", monospace',
51+
fontLigatures: false,
52+
fontSize: 13,
53+
lineDecorationsWidth: 10,
54+
lineHeight: 20,
55+
lineNumbers: "off",
56+
minimap: { enabled: false },
57+
overviewRulerBorder: false,
58+
padding: { top: 14, bottom: 14 },
59+
renderLineHighlight: "none",
60+
scrollbar: {
61+
alwaysConsumeMouseWheel: false,
62+
horizontalScrollbarSize: 10,
63+
verticalScrollbarSize: 10,
64+
},
65+
scrollBeyondLastLine: false,
66+
tabSize: 2,
67+
theme: "tskm-light",
68+
wordWrap: "off",
69+
})
70+
const subscription = editor.onDidChangeModelContent(() => {
71+
const nextValue = model.getValue()
72+
lastValueRef.current = nextValue
73+
onChangeRef.current(nextValue)
74+
})
75+
76+
modelRef.current = model
77+
editorRef.current = editor
78+
79+
return () => {
80+
subscription.dispose()
81+
editor.dispose()
82+
model.dispose()
83+
editorRef.current = null
84+
modelRef.current = null
85+
}
86+
}, [id, label, language])
87+
88+
useEffect(() => {
89+
const model = modelRef.current
90+
if (!model || value === lastValueRef.current) return
91+
lastValueRef.current = value
92+
model.setValue(value)
93+
}, [value])
94+
95+
useEffect(() => {
96+
const model = modelRef.current
97+
if (!model) return
98+
monaco.editor.setModelMarkers(model, markerOwner, diagnostics.map(toMarker))
99+
}, [diagnostics])
100+
101+
return (
102+
<div
103+
ref={containerRef}
104+
className="monaco-code-editor"
105+
style={{ minHeight: `${minLines * 1.55}rem` }}
106+
/>
107+
)
108+
}
109+
110+
export function defineTskmMonacoTheme() {
111+
monaco.editor.defineTheme("tskm-light", {
112+
base: "vs",
113+
inherit: true,
114+
rules: [
115+
{ token: "identifier", foreground: "202428" },
116+
{ token: "string", foreground: "1d6b4f" },
117+
{ token: "number", foreground: "7356a6" },
118+
{ token: "keyword", foreground: "7b3f64" },
119+
{ token: "delimiter", foreground: "68706a" },
120+
],
121+
colors: {
122+
"editor.background": "#fffefb",
123+
"editor.foreground": "#202428",
124+
"editor.lineHighlightBackground": "#00000000",
125+
"editorGutter.background": "#fffefb",
126+
"editorIndentGuide.background1": "#d6d9d1",
127+
"editorOverviewRuler.border": "#00000000",
128+
"editorWarning.foreground": "#c7891e",
129+
"editorError.foreground": "#c63c2f",
130+
focusBorder: "#2d6f6d",
131+
},
132+
})
133+
}
134+
135+
function toMarker(diagnostic: InputTypecheckDiagnostic): monaco.editor.IMarkerData {
136+
const endLineNumber = diagnostic.endLine + 1
137+
const endColumn =
138+
diagnostic.endLine === diagnostic.line
139+
? Math.max(diagnostic.endColumn + 1, diagnostic.column + 2)
140+
: diagnostic.endColumn + 1
141+
return {
142+
code: String(diagnostic.code),
143+
message: diagnostic.message,
144+
severity:
145+
diagnostic.category === "warning"
146+
? monaco.MarkerSeverity.Warning
147+
: monaco.MarkerSeverity.Error,
148+
startLineNumber: diagnostic.line + 1,
149+
startColumn: diagnostic.column + 1,
150+
endLineNumber,
151+
endColumn,
152+
}
153+
}

playgrounds/tskm/src/env.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
declare module "monaco-editor/esm/vs/editor/editor.worker?worker" {
2+
const worker: new () => Worker
3+
export default worker
4+
}
5+
6+
declare module "monaco-editor/esm/vs/language/json/json.worker?worker" {
7+
const worker: new () => Worker
8+
export default worker
9+
}
10+
11+
declare module "monaco-editor/esm/vs/language/typescript/ts.worker?worker" {
12+
const worker: new () => Worker
13+
export default worker
14+
}
Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
import { expect, test } from "bun:test"
2-
import { typecheckInputInBrowser } from "./typecheck.ts"
2+
import { createTypecheckInputText, toEditorDiagnostic } from "./typecheck.ts"
33

4-
test("typechecks playground input with the browser fallback compiler", async () => {
4+
test("maps Monaco TypeScript diagnostics back to playground input", () => {
55
const inputSource = `{
66
"role": "guest",
77
"count": "1"
88
}`
9-
const result = await typecheckInputInBrowser(
10-
`object({
11-
role: picklist(["owner", "viewer"]),
12-
count: number(),
13-
})`,
9+
const inputText = createTypecheckInputText(inputSource)
10+
11+
const diagnostic = toEditorDiagnostic(
12+
{
13+
start: inputText.indexOf('"guest"'),
14+
length: '"guest"'.length,
15+
messageText: 'Type \'"guest"\' is not assignable to type \'"owner" | "viewer"\'.',
16+
category: 1,
17+
code: 2322,
18+
},
1419
inputSource,
1520
)
1621

17-
expect(result.status).toBe("ready")
18-
expect(result.diagnostics).toHaveLength(2)
19-
expect(result.diagnostics[0]?.message).toContain('"guest"')
20-
expect(result.diagnostics[0]?.startOffset).toBe(inputSource.indexOf('"guest"'))
21-
expect(result.diagnostics[1]?.message).toContain("string")
22-
expect(result.diagnostics[1]?.startOffset).toBe(inputSource.indexOf('"1"'))
22+
expect(inputText).toContain(inputSource)
23+
expect(diagnostic.category).toBe("error")
24+
expect(diagnostic.code).toBe(2322)
25+
expect(diagnostic.message).toContain('"guest"')
26+
expect(diagnostic.startOffset).toBe(inputSource.indexOf('"guest"'))
27+
expect(diagnostic.endOffset).toBe(inputSource.indexOf('"guest"') + '"guest"'.length)
2328
})

0 commit comments

Comments
 (0)