Skip to content

Commit d0d3d4d

Browse files
feat: beta type-checking impl.
1 parent e19f3ca commit d0d3d4d

4 files changed

Lines changed: 347 additions & 8 deletions

File tree

src/app.js

Lines changed: 290 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]')
88
const appThemeButtons = document.querySelectorAll('[data-app-theme]')
99
const renderMode = document.getElementById('render-mode')
1010
const autoRenderToggle = document.getElementById('auto-render')
11+
const typecheckToggle = document.getElementById('typecheck-enabled')
12+
const typecheckButton = document.getElementById('typecheck-button')
1113
const renderButton = document.getElementById('render-button')
1214
const copyComponentButton = document.getElementById('copy-component')
1315
const clearComponentButton = document.getElementById('clear-component')
@@ -18,6 +20,7 @@ const shadowToggle = document.getElementById('shadow-toggle')
1820
const jsxEditor = document.getElementById('jsx-editor')
1921
const cssEditor = document.getElementById('css-editor')
2022
const styleWarning = document.getElementById('style-warning')
23+
const typeDiagnostics = document.getElementById('type-diagnostics')
2124
const cdnLoading = document.getElementById('cdn-loading')
2225
const previewBgColorInput = document.getElementById('preview-bg-color')
2326
const clearConfirmDialog = document.getElementById('clear-confirm-dialog')
@@ -39,6 +42,7 @@ let sassCompiler = null
3942
let lessCompiler = null
4043
let lightningCssWasm = null
4144
let coreRuntime = null
45+
let typeScriptCompiler = null
4246
let compiledStylesCache = {
4347
key: null,
4448
value: null,
@@ -47,6 +51,8 @@ let pendingClearAction = null
4751
let hasCompletedInitialRender = false
4852
let previewBackgroundColor = null
4953
let previewBackgroundCustomized = false
54+
let typeCheckRunId = 0
55+
let lastTypeErrorCount = 0
5056
const clipboardSupported = Boolean(navigator.clipboard?.writeText)
5157
const appGridLayoutStorageKey = 'knighted-develop:app-grid-layout'
5258
const appThemeStorageKey = 'knighted-develop:theme'
@@ -81,7 +87,10 @@ const initializeCodeEditors = async () => {
8187
parent: jsxHost,
8288
value: defaultJsx,
8389
language: 'javascript-jsx',
84-
onChange: maybeRender,
90+
onChange: () => {
91+
maybeRender()
92+
markTypeDiagnosticsStale()
93+
},
8594
}),
8695
createCodeMirrorEditor({
8796
parent: cssHost,
@@ -148,6 +157,246 @@ const setStatus = text => {
148157
statusNode.textContent = text
149158
}
150159

160+
const updateTypecheckButtonState = () => {
161+
if (!typecheckButton) {
162+
return
163+
}
164+
165+
const enabled = Boolean(typecheckToggle?.checked)
166+
typecheckButton.hidden = !enabled
167+
typecheckButton.disabled = !enabled
168+
}
169+
170+
const setTypeDiagnostics = (text, level = 'muted') => {
171+
if (!typeDiagnostics) {
172+
return
173+
}
174+
175+
typeDiagnostics.classList.remove(
176+
'panel-footer--muted',
177+
'panel-footer--ok',
178+
'panel-footer--error',
179+
)
180+
181+
if (!text) {
182+
typeDiagnostics.hidden = true
183+
typeDiagnostics.textContent = ''
184+
return
185+
}
186+
187+
typeDiagnostics.hidden = false
188+
typeDiagnostics.textContent = text
189+
190+
if (level === 'ok') {
191+
typeDiagnostics.classList.add('panel-footer--ok')
192+
return
193+
}
194+
195+
if (level === 'error') {
196+
typeDiagnostics.classList.add('panel-footer--error')
197+
return
198+
}
199+
200+
typeDiagnostics.classList.add('panel-footer--muted')
201+
}
202+
203+
const setRenderedStatus = () => {
204+
if (lastTypeErrorCount > 0) {
205+
setStatus(`Rendered (Type errors: ${lastTypeErrorCount})`)
206+
return
207+
}
208+
209+
if (statusNode.textContent.startsWith('Rendered (Type errors:')) {
210+
setStatus('Rendered')
211+
}
212+
}
213+
214+
const flattenTypeDiagnosticMessage = (compiler, messageText) => {
215+
if (typeof compiler.flattenDiagnosticMessageText === 'function') {
216+
return compiler.flattenDiagnosticMessageText(messageText, '\n')
217+
}
218+
219+
if (typeof messageText === 'string') {
220+
return messageText
221+
}
222+
223+
if (messageText && typeof messageText.messageText === 'string') {
224+
return messageText.messageText
225+
}
226+
227+
return 'Unknown TypeScript diagnostic'
228+
}
229+
230+
const formatTypeDiagnostic = (compiler, diagnostic) => {
231+
const message = flattenTypeDiagnosticMessage(compiler, diagnostic.messageText)
232+
233+
if (!diagnostic.file || typeof diagnostic.start !== 'number') {
234+
return message
235+
}
236+
237+
const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
238+
return `L${position.line + 1}:${position.character + 1} ${message}`
239+
}
240+
241+
const ensureTypeScriptCompiler = async () => {
242+
if (typeScriptCompiler) {
243+
return typeScriptCompiler
244+
}
245+
246+
try {
247+
const loaded = await importFromCdnWithFallback(cdnImports.typescript)
248+
typeScriptCompiler = loaded.module.default ?? loaded.module
249+
250+
if (typeof typeScriptCompiler.transpileModule !== 'function') {
251+
throw new Error(`transpileModule export was not found from ${loaded.url}`)
252+
}
253+
254+
return typeScriptCompiler
255+
} catch (error) {
256+
const message =
257+
error instanceof Error ? error.message : 'Unknown TypeScript module loading failure'
258+
throw new Error(
259+
`Unable to load TypeScript diagnostics runtime from CDN: ${message}`,
260+
{
261+
cause: error,
262+
},
263+
)
264+
}
265+
}
266+
267+
const shouldIgnoreTypeDiagnostic = diagnostic => {
268+
const ignoredCodes = new Set([2318, 6053])
269+
return ignoredCodes.has(diagnostic.code)
270+
}
271+
272+
const collectTypeDiagnostics = (compiler, sourceText) => {
273+
const sourceFileName = 'component.tsx'
274+
const jsxTypesFileName = 'knighted-jsx-runtime.d.ts'
275+
const jsxTypes =
276+
'declare namespace JSX {\n' +
277+
' interface Element {}\n' +
278+
' interface IntrinsicElements { [elemName: string]: any }\n' +
279+
'}\n'
280+
281+
const files = new Map([
282+
[sourceFileName, sourceText],
283+
[jsxTypesFileName, jsxTypes],
284+
])
285+
286+
const options = {
287+
jsx: compiler.JsxEmit?.Preserve,
288+
target: compiler.ScriptTarget?.ES2022,
289+
module: compiler.ModuleKind?.ESNext,
290+
strict: true,
291+
noEmit: true,
292+
noLib: true,
293+
skipLibCheck: true,
294+
}
295+
296+
const host = {
297+
fileExists: fileName => files.has(fileName),
298+
readFile: fileName => files.get(fileName),
299+
getSourceFile: (fileName, languageVersion) => {
300+
const text = files.get(fileName)
301+
if (typeof text !== 'string') {
302+
return undefined
303+
}
304+
305+
const scriptKind = fileName.endsWith('.tsx')
306+
? compiler.ScriptKind?.TSX
307+
: compiler.ScriptKind?.TS
308+
309+
return compiler.createSourceFile(fileName, text, languageVersion, true, scriptKind)
310+
},
311+
getDefaultLibFileName: () => 'lib.d.ts',
312+
writeFile: () => {},
313+
getCurrentDirectory: () => '/',
314+
getDirectories: () => [],
315+
getCanonicalFileName: fileName => fileName,
316+
useCaseSensitiveFileNames: () => true,
317+
getNewLine: () => '\n',
318+
}
319+
320+
const program = compiler.createProgram({
321+
rootNames: [sourceFileName, jsxTypesFileName],
322+
options,
323+
host,
324+
})
325+
326+
return compiler
327+
.getPreEmitDiagnostics(program)
328+
.filter(diagnostic => !shouldIgnoreTypeDiagnostic(diagnostic))
329+
}
330+
331+
const runTypeDiagnostics = async runId => {
332+
if (!typecheckToggle?.checked) {
333+
return
334+
}
335+
336+
setTypeDiagnostics('Type checking…', 'muted')
337+
338+
try {
339+
const compiler = await ensureTypeScriptCompiler()
340+
if (runId !== typeCheckRunId || !typecheckToggle.checked) {
341+
return
342+
}
343+
344+
const diagnostics = collectTypeDiagnostics(compiler, getJsxSource())
345+
const errorCategory = compiler.DiagnosticCategory?.Error
346+
const errors = diagnostics.filter(diagnostic => diagnostic.category === errorCategory)
347+
lastTypeErrorCount = errors.length
348+
349+
if (errors.length === 0) {
350+
setTypeDiagnostics('Type checking enabled. No TypeScript errors found.', 'ok')
351+
} else {
352+
const preview = errors
353+
.slice(0, 3)
354+
.map(diagnostic => formatTypeDiagnostic(compiler, diagnostic))
355+
const remainder =
356+
errors.length > preview.length
357+
? `\n...and ${errors.length - preview.length} more`
358+
: ''
359+
360+
setTypeDiagnostics(
361+
`TypeScript found ${errors.length} error${errors.length === 1 ? '' : 's'}:\n${preview.join('\n')}${remainder}`,
362+
'error',
363+
)
364+
}
365+
366+
if (
367+
statusNode.textContent === 'Rendered' ||
368+
statusNode.textContent.startsWith('Rendered (Type errors:')
369+
) {
370+
setRenderedStatus()
371+
}
372+
} catch (error) {
373+
if (runId !== typeCheckRunId || !typecheckToggle?.checked) {
374+
return
375+
}
376+
377+
lastTypeErrorCount = 0
378+
const message = error instanceof Error ? error.message : String(error)
379+
setTypeDiagnostics(`Type diagnostics unavailable: ${message}`, 'error')
380+
381+
if (statusNode.textContent.startsWith('Rendered (Type errors:')) {
382+
setStatus('Rendered')
383+
}
384+
}
385+
}
386+
387+
const markTypeDiagnosticsStale = () => {
388+
if (!typecheckToggle?.checked) {
389+
return
390+
}
391+
392+
lastTypeErrorCount = 0
393+
setTypeDiagnostics('Source changed. Click Check types to update diagnostics.', 'muted')
394+
395+
if (statusNode.textContent.startsWith('Rendered (Type errors:')) {
396+
setStatus('Rendered')
397+
}
398+
}
399+
151400
const appGridLayouts = ['default', 'preview-right', 'preview-left']
152401

153402
const applyAppGridLayout = (layout, { persist = true } = {}) => {
@@ -253,6 +502,7 @@ const setCssSource = value => {
253502
const clearComponentSource = () => {
254503
setJsxSource('')
255504
setStatus('Component cleared')
505+
markTypeDiagnosticsStale()
256506
if (!jsxCodeEditor) {
257507
maybeRender()
258508
}
@@ -927,6 +1177,9 @@ const renderPreview = async () => {
9271177
await renderDom()
9281178
}
9291179
setStatus('Rendered')
1180+
if (typecheckToggle?.checked) {
1181+
setRenderedStatus()
1182+
}
9301183
} catch (error) {
9311184
setStatus('Error')
9321185
const target = getRenderTarget()
@@ -967,6 +1220,35 @@ autoRenderToggle.addEventListener('change', () => {
9671220
renderPreview()
9681221
}
9691222
})
1223+
if (typecheckToggle) {
1224+
typecheckToggle.addEventListener('change', () => {
1225+
updateTypecheckButtonState()
1226+
1227+
if (typecheckToggle.checked) {
1228+
setTypeDiagnostics(
1229+
'Type checking enabled. Click Check types to run diagnostics.',
1230+
'muted',
1231+
)
1232+
return
1233+
}
1234+
1235+
lastTypeErrorCount = 0
1236+
setTypeDiagnostics('')
1237+
if (statusNode.textContent.startsWith('Rendered (Type errors:')) {
1238+
setStatus('Rendered')
1239+
}
1240+
})
1241+
}
1242+
if (typecheckButton) {
1243+
typecheckButton.addEventListener('click', () => {
1244+
if (!typecheckToggle?.checked) {
1245+
return
1246+
}
1247+
1248+
typeCheckRunId += 1
1249+
void runTypeDiagnostics(typeCheckRunId)
1250+
})
1251+
}
9701252
renderButton.addEventListener('click', renderPreview)
9711253
if (clipboardSupported) {
9721254
copyComponentButton.addEventListener('click', () => {
@@ -1002,6 +1284,7 @@ clearStylesButton.addEventListener('click', () => {
10021284
})
10031285
})
10041286
jsxEditor.addEventListener('input', maybeRender)
1287+
jsxEditor.addEventListener('input', markTypeDiagnosticsStale)
10051288
cssEditor.addEventListener('input', maybeRender)
10061289

10071290
for (const button of appGridLayoutButtons) {
@@ -1027,7 +1310,13 @@ for (const button of appThemeButtons) {
10271310
applyAppGridLayout(getInitialAppGridLayout(), { persist: false })
10281311
applyTheme(getInitialTheme(), { persist: false })
10291312

1313+
if (typecheckToggle) {
1314+
typecheckToggle.checked = false
1315+
}
1316+
10301317
updateRenderButtonVisibility()
1318+
updateTypecheckButtonState()
1319+
setTypeDiagnostics('')
10311320
setStyleCompiling(false)
10321321
setCdnLoading(true)
10331322
initializePreviewBackgroundPicker()

src/cdn.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ export const cdnImportSpecs = {
9393
esm: '@parcel/css-wasm',
9494
jspmGa: 'npm:@parcel/css-wasm',
9595
},
96+
typescript: {
97+
importMap: 'typescript',
98+
esm: 'typescript@5.9.3/lib/typescript.js',
99+
jspmGa: 'npm:typescript@5.9.3/lib/typescript.js',
100+
},
96101
codemirrorState: {
97102
importMap: '@codemirror/state',
98103
esm: '@codemirror/state',

0 commit comments

Comments
 (0)