Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
nag5000 committed Sep 28, 2024
1 parent 3afe582 commit e80dce8
Show file tree
Hide file tree
Showing 10 changed files with 545 additions and 44 deletions.
41 changes: 19 additions & 22 deletions packages/jsrepl-nuxt/composables/useCodeEditorTypescript.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useTypescript } from '@/composables/useTypescript'
import { getDtsMap } from '@/utils/dts'
import { getNpmPackageFromImportPath } from '@/utils/npm-packages'
import debounce from 'debounce'
import * as monaco from 'monaco-editor'
import type TS from 'typescript'
import { debugLog } from '~/utils/debug-log'
import { useTypescript } from '@/composables/useTypescript'
import { getDtsMap } from '@/utils/dts'
import { getNpmPackageFromImportPath } from '@/utils/npm-packages'

export function useCodeEditorTypescript(
getEditor: () => monaco.editor.IStandaloneCodeEditor | null,
Expand Down Expand Up @@ -40,25 +40,19 @@ export function useCodeEditorTypescript(
const [tsRef, loadTS] = useTypescript()

let tsModels: Array<InstanceType<typeof CodeEditorModel>> = []
const changedModels = new Set<monaco.editor.ITextModel>()
const cachedImports = new Map<monaco.editor.ITextModel, Set<string>>()

onMounted(async () => {
await Promise.all([loadTS()])
await loadTS()

tsModels = Array.from(models.values()).filter(
(model) => model.monacoModel.getLanguageId() === 'typescript'
)

tsModels.forEach((model) => {
changedModels.add(model.monacoModel)
})

updateDts()

tsModels.forEach((model) => {
model.monacoModel.onDidChangeContent(() => {
changedModels.add(model.monacoModel)
cachedImports.delete(model.monacoModel)
debouncedUpdateDts()
})
Expand All @@ -76,17 +70,22 @@ export function useCodeEditorTypescript(

try {
models.forEach((model) => {
// `modelContext.getValue()` is used here instead of `modelContext.getBabelTransformResult().code`
// to preserve unused imports. Babel transform with typescript plugin removes unused imports.
const sourceFile = ts.createSourceFile(
model.monacoModel.uri.path,
model.getValue(),
ts.ScriptTarget.ESNext,
true,
model.monacoModel.uri.path.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
let _imports = cachedImports.get(model.monacoModel)
if (!_imports) {
// `modelContext.getValue()` is used here instead of `modelContext.getBabelTransformResult().code`
// to preserve unused imports. Babel transform with typescript plugin removes unused imports.
const sourceFile = ts.createSourceFile(
model.monacoModel.uri.path,
model.getValue(),
ts.ScriptTarget.ESNext,
true,
model.monacoModel.uri.path.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)

_imports = findImports(sourceFile)
cachedImports.set(model.monacoModel, _imports)
}

const _imports = findImports(sourceFile)
_imports.forEach((importPath) => imports.add(importPath))
})
} catch (e) {
Expand Down Expand Up @@ -164,8 +163,6 @@ export function useCodeEditorTypescript(
paths,
})
}

changedModels.clear()
}

function findImports(sourceFile: TS.SourceFile) {
Expand Down
4 changes: 2 additions & 2 deletions packages/jsrepl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"shiki": "^1.11.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.9.0",
Expand Down Expand Up @@ -73,7 +74,6 @@
"postcss": "^8",
"storybook": "^8.3.2",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"unplugin-icons": "^0.19.3",
"vitest": "^2.0.5"
}
Expand Down
19 changes: 16 additions & 3 deletions packages/jsrepl/src/app/repl/components/code-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useTheme } from 'next-themes'
import * as monaco from 'monaco-editor'
import useCodeEditorTypescript from '@/hooks/useCodeEditorTypescript'
import { CodeEditorModel } from '@/lib/code-editor-model'
import { createCodeEditorModel } from '@/lib/code-editor-model-factory'
import { loadMonacoTheme } from '@/lib/monaco-themes'
Expand All @@ -18,9 +19,6 @@ type Props = {
onReplBodyMutation: () => void
}

setupMonaco()
setupTailwindCSS()

export default function CodeEditor({
className,
modelDefinitions,
Expand Down Expand Up @@ -50,6 +48,19 @@ export default function CodeEditor({
return map
}, [modelDefinitions])

useEffect(() => {
setupMonaco()
setupTailwindCSS()
}, [])

useEffect(() => {
return () => {
models.forEach((model) => {
model.monacoModel.dispose()
})
}
}, [models])

useEffect(() => {
const disposables = Array.from(models.values()).map((model) => {
return model.monacoModel.onDidChangeContent(() => {
Expand Down Expand Up @@ -113,6 +124,8 @@ export default function CodeEditor({
}
}, [])

useCodeEditorTypescript(editorRef, models)

return (
<>
<div className={cn(className, { 'opacity-0': !isThemeLoaded })} ref={containerRef} />
Expand Down
26 changes: 12 additions & 14 deletions packages/jsrepl/src/app/repl/components/repl-playground.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import React from 'react'
import React, { useCallback } from 'react'
import { useReplPreviewShown } from '@/hooks/useReplPreviewShown'
import { useReplPreviewSize } from '@/hooks/useReplPreviewSize'
import { useReplStoredState } from '@/hooks/useReplStoredState'
Expand All @@ -22,20 +22,18 @@ export default function ReplPlayground() {
setReplState,
})

function onModelChange(editorModel: InstanceType<typeof CodeEditorModel>) {
const uri = editorModel.monacoModel.uri.toString()
const modelDef = replState.models.get(uri)
if (modelDef) {
modelDef.content = editorModel.getValue()
} else {
replState.models.set(uri, {
uri,
content: editorModel.getValue(),
})
}
const onModelChange = useCallback(
(editorModel: InstanceType<typeof CodeEditorModel>) => {
const uri = editorModel.monacoModel.uri.toString()
const modelDef = replState.models.get(uri)
if (modelDef) {
modelDef.content = editorModel.getValue()
}

saveReplState()
}
saveReplState()
},
[replState.models, saveReplState]
)

return (
<>
Expand Down
206 changes: 206 additions & 0 deletions packages/jsrepl/src/hooks/useCodeEditorTypescript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { RefObject, useCallback, useEffect, useMemo } from 'react'
import debounce from 'debounce'
import * as monaco from 'monaco-editor'
import type TS from 'typescript'
import { CodeEditorModel } from '@/lib/code-editor-model'
import { DebugLog, debugLog } from '@/lib/debug-log'
import { getDtsMap } from '@/lib/dts'
import { getTypescript } from '@/lib/get-typescript'
import { getNpmPackageFromImportPath } from '@/lib/npm-packages'

export default function useCodeEditorTypescript(
editorRef: RefObject<monaco.editor.IStandaloneCodeEditor | null>,
models: Map<string, InstanceType<typeof CodeEditorModel>>
) {
useEffect(() => {
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
})

monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
// https://www.typescriptlang.org/tsconfig/#allowSyntheticDefaultImports
allowSyntheticDefaultImports: true,

jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
allowJs: true,
esModuleInterop: true,
target: monaco.languages.typescript.ScriptTarget.ESNext,
allowNonTsExtensions: true,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
module: monaco.languages.typescript.ModuleKind.ESNext,

// https://github.com/microsoft/monaco-editor/issues/2976
isolatedModules: true,

// https://github.com/microsoft/monaco-editor/issues/2976
moduleDetection: 3 /* Force (undocumented, https://raw.githubusercontent.com/microsoft/monaco-editor/93a0a2df32926aa86f7e11bc71a43afaea581a09/src/language/typescript/lib/typescriptServices.js, look for "moduleDetection") */,
})
}, [])

const modelPackages = useMemo(() => new Map<string, { abortController: AbortController }>(), [])
const [tsRef, loadTS] = useMemo(() => getTypescript(), [])
const cachedImports = useMemo(() => new Map<monaco.editor.ITextModel, Set<string>>(), [])

const tsModels: Array<InstanceType<typeof CodeEditorModel>> = useMemo(() => {
return Array.from(models.values()).filter(
(model) => model.monacoModel.getLanguageId() === 'typescript'
)
}, [models])

const updateDts = useCallback(() => {
const ts = tsRef.value!

const imports = new Set<string>()

try {
models.forEach((model) => {
let _imports = cachedImports.get(model.monacoModel)
if (!_imports) {
// `modelContext.getValue()` is used here instead of `modelContext.getBabelTransformResult().code`
// to preserve unused imports. Babel transform with typescript plugin removes unused imports.
const sourceFile = ts.createSourceFile(
model.monacoModel.uri.path,
model.getValue(),
ts.ScriptTarget.ESNext,
true,
model.monacoModel.uri.path.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)

_imports = findImports(ts, sourceFile)
cachedImports.set(model.monacoModel, _imports)
}

_imports.forEach((importPath) => imports.add(importPath))
})
} catch (e) {
console.error('jsrepl :: ts :: updateDts', e)
return
}

// `imports` is used here instead of `modelContext.getBabelTransformResult().metadata.importPaths`
// to preserve unused imports. Babel transform with typescript plugin removes unused imports.
// It's better to eagerly preload types even before import is actually used in the code.
const packages = new Set(
Array.from(imports)
.map((importPath) => getNpmPackageFromImportPath(importPath))
.filter((moduleName) => moduleName !== null)
)

// TODO: support packages with version specifier: `[email protected]`
if (
packages.has('react') ||
packages.has('react-dom/client') ||
packages.has('react-dom/client.development')
) {
packages.add('react/jsx-runtime')
}

const addedPackages = Array.from(packages).filter(
(packageName) => !modelPackages.has(packageName)
)
const removedPackages = Array.from(modelPackages.keys()).filter(
(packageName) => !packages.has(packageName)
)

removedPackages.forEach((packageName) => {
const { abortController } = modelPackages.get(packageName)!
abortController.abort()
modelPackages.delete(packageName)
})

addedPackages.forEach(async (packageName) => {
const abortController = new AbortController()
const { signal } = abortController
modelPackages.set(packageName, { abortController })

// TODO: await Promise.all of addedPackages?
const dtsMap = await getDtsMap(packageName, tsRef.value!, { signal })
debugLog(DebugLog.DTS, packageName, 'dtsMap', dtsMap)

for (const [fileUri, content] of dtsMap) {
if (signal.aborted) {
return
}

const disposable = monaco.languages.typescript.typescriptDefaults.addExtraLib(
content,
fileUri
)

signal.addEventListener('abort', () => {
disposable.dispose()
})
}
})

if (addedPackages.length > 0 || removedPackages.length > 0) {
const paths = Array.from(packages).reduce(
(acc, packageName) => {
acc[`https://esm.sh/${packageName}`] = [`file:///node_modules/${packageName}`]
return acc
},
{} as Record<string, string[]>
)

monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(),
paths,
})
}
}, [tsRef, cachedImports, models, modelPackages])

const debouncedUpdateDts = useMemo(() => debounce(updateDts, 500), [updateDts])

useEffect(() => {
return () => {
debouncedUpdateDts.clear()
}
}, [debouncedUpdateDts])

useEffect(() => {
loadTS().then(() => {
updateDts()
})
}, [loadTS, updateDts])

useEffect(() => {
const disposables = tsModels.map((model) => {
return model.monacoModel.onDidChangeContent(() => {
cachedImports.delete(model.monacoModel)
debouncedUpdateDts()
})
})

return () => {
disposables.forEach((disposable) => disposable.dispose())
}
}, [tsModels, debouncedUpdateDts, cachedImports])
}

function findImports(ts: typeof TS, sourceFile: TS.SourceFile) {
const imports = new Set<string>()

function findImportsAndExports(node: TS.Node) {
// if (ts.isImportDeclaration(node)) {
// console.log('Import:', node.moduleSpecifier.getText());
// } else if (ts.isExportDeclaration(node)) {
// console.log('Export:', node.moduleSpecifier ? node.moduleSpecifier.getText() : 'Export with no module');
// } else if (ts.isExportAssignment(node)) {
// console.log('Export assignment:', node.expression.getText());
// }

if (ts.isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier as TS.StringLiteral
if (moduleSpecifier.text) {
imports.add(moduleSpecifier.text)
}
}

ts.forEachChild(node, findImportsAndExports)
}

ts.forEachChild(sourceFile, findImportsAndExports)

return imports
}
Loading

0 comments on commit e80dce8

Please sign in to comment.