diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index e62316e1cb8a..994561e7c318 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -10,22 +10,26 @@ import { getEslintJsonSchema, getTypescriptJsonSchema, } from '../lib/jsonSchema'; -import { - parseESLintRC, - parseTSConfig, - tryParseEslintModule, -} from '../lib/parseConfig'; +import { parseTSConfig, tryParseEslintModule } from '../lib/parseConfig'; import type { LintCodeAction } from '../linter/utils'; import { parseLintResults, parseMarkers } from '../linter/utils'; -import type { WebLinter } from '../linter/WebLinter'; import type { TabType } from '../types'; import { createProvideCodeActions } from './createProvideCodeActions'; import type { CommonEditorProps } from './types'; -import type { SandboxInstance } from './useSandboxServices'; +import type { SandboxServices } from './useSandboxServices'; + +export type LoadedEditorProps = CommonEditorProps & SandboxServices; -export interface LoadedEditorProps extends CommonEditorProps { - readonly sandboxInstance: SandboxInstance; - readonly webLinter: WebLinter; +function applyEdit( + model: Monaco.editor.ITextModel, + editor: Monaco.editor.ICodeEditor, + edit: Monaco.editor.IIdentifiedSingleEditOperation, +): void { + if (model.isAttachedToEditor()) { + editor.executeEdits('eslint', [edit]); + } else { + model.pushEditOperations([], [edit], () => null); + } } export const LoadedEditor: React.FC = ({ @@ -40,8 +44,9 @@ export const LoadedEditor: React.FC = ({ onMarkersChange, onChange, onSelect, - sandboxInstance, + sandboxInstance: { editor, monaco }, showAST, + system, sourceType, webLinter, activeTab, @@ -52,16 +57,16 @@ export const LoadedEditor: React.FC = ({ const codeActions = useRef(new Map()).current; const [tabs] = useState>(() => { const tabsDefault = { - code: sandboxInstance.editor.getModel()!, - tsconfig: sandboxInstance.monaco.editor.createModel( + code: editor.getModel()!, + tsconfig: monaco.editor.createModel( tsconfig, 'json', - sandboxInstance.monaco.Uri.file('/tsconfig.json'), + monaco.Uri.file('/tsconfig.json'), ), - eslintrc: sandboxInstance.monaco.editor.createModel( + eslintrc: monaco.editor.createModel( eslintrc, 'json', - sandboxInstance.monaco.Uri.file('/.eslintrc'), + monaco.Uri.file('/.eslintrc'), ), }; tabsDefault.code.updateOptions({ tabSize: 2, insertSpaces: true }); @@ -71,289 +76,229 @@ export const LoadedEditor: React.FC = ({ }); const updateMarkers = useCallback(() => { - const model = sandboxInstance.editor.getModel()!; - const markers = sandboxInstance.monaco.editor.getModelMarkers({ + const model = editor.getModel()!; + const markers = monaco.editor.getModelMarkers({ resource: model.uri, }); - onMarkersChange(parseMarkers(markers, codeActions, sandboxInstance.editor)); - }, [ - codeActions, - onMarkersChange, - sandboxInstance.editor, - sandboxInstance.monaco.editor, - ]); + onMarkersChange(parseMarkers(markers, codeActions, editor)); + }, [codeActions, onMarkersChange, editor, monaco.editor]); + + useEffect(() => { + webLinter.updateParserOptions(sourceType); + }, [webLinter, sourceType]); useEffect(() => { const newPath = `/input${fileType}`; if (tabs.code.uri.path !== newPath) { - const newModel = sandboxInstance.monaco.editor.createModel( - tabs.code.getValue(), + const code = tabs.code.getValue(); + const newModel = monaco.editor.createModel( + code, undefined, - sandboxInstance.monaco.Uri.file(newPath), + monaco.Uri.file(newPath), ); newModel.updateOptions({ tabSize: 2, insertSpaces: true }); if (tabs.code.isAttachedToEditor()) { - sandboxInstance.editor.setModel(newModel); + editor.setModel(newModel); } tabs.code.dispose(); tabs.code = newModel; + system.writeFile(newPath, code); } - }, [fileType, sandboxInstance.editor, sandboxInstance.monaco, tabs]); + }, [fileType, editor, system, monaco, tabs]); useEffect(() => { const config = createCompilerOptions( parseTSConfig(tsconfig).compilerOptions, ); - webLinter.updateCompilerOptions(config); - sandboxInstance.setCompilerSettings( + monaco.languages.typescript.typescriptDefaults.setCompilerOptions( config as Monaco.languages.typescript.CompilerOptions, ); - }, [sandboxInstance, tsconfig, webLinter]); + }, [monaco, tsconfig]); useEffect(() => { - webLinter.updateEslintConfig(parseESLintRC(eslintrc)); - }, [eslintrc, webLinter]); + if (editor.getModel()?.uri.path !== tabs[activeTab].uri.path) { + editor.setModel(tabs[activeTab]); + updateMarkers(); + } + }, [activeTab, editor, tabs, updateMarkers]); useEffect(() => { - sandboxInstance.editor.setModel(tabs[activeTab]); - updateMarkers(); - }, [activeTab, sandboxInstance.editor, tabs, updateMarkers]); + const disposable = webLinter.onLint((uri, messages) => { + const diagnostics = parseLintResults(messages, codeActions, ruleId => + monaco.Uri.parse(webLinter.rules.get(ruleId)?.url ?? ''), + ); + monaco.editor.setModelMarkers( + monaco.editor.getModel(monaco.Uri.file(uri))!, + 'eslint', + diagnostics, + ); + updateMarkers(); + }); + return () => disposable(); + }, [webLinter, monaco, codeActions, updateMarkers]); useEffect(() => { - const lintEditor = debounce(() => { - console.info('[Editor] linting triggered'); - - webLinter.updateParserOptions(sourceType); - - try { - const messages = webLinter.lint(code, tabs.code.uri.path); - - const markers = parseLintResults(messages, codeActions, ruleId => - sandboxInstance.monaco.Uri.parse( - webLinter.rulesMap.get(ruleId)?.url ?? '', - ), - ); - - sandboxInstance.monaco.editor.setModelMarkers( - tabs.code, - 'eslint', - markers, - ); - - // fallback when event is not preset, ts < 4.0.5 - if (!sandboxInstance.monaco.editor.onDidChangeMarkers) { - updateMarkers(); - } - } catch (e) { - onMarkersChange(e as Error); - } - - onEsASTChange(webLinter.storedAST); - onTsASTChange(webLinter.storedTsAST); - onScopeChange(webLinter.storedScope); - - const position = sandboxInstance.editor.getPosition(); - onSelect(position ? tabs.code.getOffsetAt(position) : undefined); - }, 500); - - lintEditor(); - }, [ - code, - fileType, - tsconfig, - eslintrc, - sourceType, - webLinter, - onEsASTChange, - onTsASTChange, - onScopeChange, - onSelect, - sandboxInstance.editor, - sandboxInstance.monaco.editor, - sandboxInstance.monaco.Uri, - codeActions, - tabs.code, - updateMarkers, - onMarkersChange, - ]); + const disposable = webLinter.onParse((uri, model) => { + onEsASTChange(model.storedAST); + onScopeChange(model.storedScope as Record | undefined); + onTsASTChange(model.storedTsAST); + }); + return () => disposable(); + }, [webLinter, onEsASTChange, onScopeChange, onTsASTChange]); useEffect(() => { // configure the JSON language support with schemas and schema associations - sandboxInstance.monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ validate: true, enableSchemaRequest: false, allowComments: true, schemas: [ { - uri: sandboxInstance.monaco.Uri.file('eslint-schema.json').toString(), // id of the first schema - fileMatch: [tabs.eslintrc.uri.toString()], // associate with our model + uri: monaco.Uri.file('eslint-schema.json').toString(), // id of the first schema + fileMatch: ['/.eslintrc'], // associate with our model schema: getEslintJsonSchema(webLinter), }, { - uri: sandboxInstance.monaco.Uri.file('ts-schema.json').toString(), // id of the first schema - fileMatch: [tabs.tsconfig.uri.toString()], // associate with our model + uri: monaco.Uri.file('ts-schema.json').toString(), // id of the first schema + fileMatch: ['/tsconfig.json'], // associate with our model schema: getTypescriptJsonSchema(), }, ], }); + }, [monaco, webLinter]); - const subscriptions = [ - sandboxInstance.monaco.languages.registerCodeActionProvider( - 'typescript', - createProvideCodeActions(codeActions), - ), - sandboxInstance.editor.onDidPaste(() => { - if (tabs.eslintrc.isAttachedToEditor()) { - const value = tabs.eslintrc.getValue(); - const newValue = tryParseEslintModule(value); - if (newValue !== value) { - tabs.eslintrc.setValue(newValue); + useEffect(() => { + const disposable = monaco.languages.registerCodeActionProvider( + 'typescript', + createProvideCodeActions(codeActions), + ); + return () => disposable.dispose(); + }, [codeActions, monaco]); + + useEffect(() => { + const disposable = editor.onDidPaste(() => { + if (tabs.eslintrc.isAttachedToEditor()) { + const value = tabs.eslintrc.getValue(); + const newValue = tryParseEslintModule(value); + if (newValue !== value) { + tabs.eslintrc.setValue(newValue); + } + } + }); + return () => disposable.dispose(); + }, [editor, tabs.eslintrc]); + + useEffect(() => { + const disposable = editor.onDidChangeCursorPosition( + debounce(e => { + if (tabs.code.isAttachedToEditor()) { + const position = tabs.code.getOffsetAt(e.position); + console.info('[Editor] updating cursor', position); + onSelect(position); + } + }, 150), + ); + return () => disposable.dispose(); + }, [onSelect, editor, tabs.code]); + + useEffect(() => { + const disposable = editor.addAction({ + id: 'fix-eslint-problems', + label: 'Fix eslint problems', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + contextMenuGroupId: 'snippets', + contextMenuOrder: 1.5, + run(editor) { + const editorModel = editor.getModel(); + if (editorModel) { + const fixed = webLinter.triggerFix(editor.getValue()); + if (fixed?.fixed) { + applyEdit(editorModel, editor, { + range: editorModel.getFullModelRange(), + text: fixed.output, + }); } } + }, + }); + return () => disposable.dispose(); + }, [editor, monaco, webLinter]); + + useEffect(() => { + const closable = [ + system.watchFile('/tsconfig.json', filename => { + onChange({ tsconfig: system.readFile(filename) }); }), - sandboxInstance.editor.onDidChangeCursorPosition( - debounce(() => { - if (tabs.code.isAttachedToEditor()) { - const position = sandboxInstance.editor.getPosition(); - if (position) { - console.info('[Editor] updating cursor', position); - onSelect(tabs.code.getOffsetAt(position)); - } - } - }, 150), - ), - sandboxInstance.editor.addAction({ - id: 'fix-eslint-problems', - label: 'Fix eslint problems', - keybindings: [ - sandboxInstance.monaco.KeyMod.CtrlCmd | - sandboxInstance.monaco.KeyCode.KeyS, - ], - contextMenuGroupId: 'snippets', - contextMenuOrder: 1.5, - run(editor) { - const editorModel = editor.getModel(); - if (editorModel) { - const fixed = webLinter.fix( - editor.getValue(), - editorModel.uri.path, - ); - if (fixed.fixed) { - editorModel.pushEditOperations( - null, - [ - { - range: editorModel.getFullModelRange(), - text: fixed.output, - }, - ], - () => null, - ); - } - } - }, + system.watchFile('/.eslintrc', filename => { + onChange({ eslintrc: system.readFile(filename) }); }), - tabs.eslintrc.onDidChangeContent( - debounce(() => { - onChange({ eslintrc: tabs.eslintrc.getValue() }); - }, 500), - ), - tabs.tsconfig.onDidChangeContent( - debounce(() => { - onChange({ tsconfig: tabs.tsconfig.getValue() }); - }, 500), - ), - tabs.code.onDidChangeContent( - debounce(() => { - onChange({ code: tabs.code.getValue() }); - }, 500), - ), - // may not be defined in ts < 4.0.5 - sandboxInstance.monaco.editor.onDidChangeMarkers?.(() => { - updateMarkers(); + system.watchFile('/input.*', filename => { + onChange({ code: system.readFile(filename) }); }), ]; - return (): void => { - for (const subscription of subscriptions) { - if (subscription) { - subscription.dispose(); - } - } + return () => { + closable.forEach(c => c.close()); }; - }, [ - codeActions, - onChange, - onSelect, - sandboxInstance.editor, - sandboxInstance.monaco.editor, - sandboxInstance.monaco.languages.json.jsonDefaults, - tabs.code, - tabs.eslintrc, - tabs.tsconfig, - updateMarkers, - webLinter.rulesMap, - ]); + }, [system, onChange]); + + useEffect(() => { + const disposable = editor.onDidChangeModelContent(() => { + const model = editor.getModel(); + if (model) { + system.writeFile(model.uri.path, model.getValue()); + } + }); + return () => disposable.dispose(); + }, [editor, system]); + + useEffect(() => { + const disposable = monaco.editor.onDidChangeMarkers(() => { + updateMarkers(); + }); + return () => disposable.dispose(); + }, [monaco.editor, updateMarkers]); const resize = useMemo(() => { - return debounce(() => sandboxInstance.editor.layout(), 1); - }, [sandboxInstance]); + return debounce(() => editor.layout(), 1); + }, [editor]); - const container = - sandboxInstance.editor.getContainerDomNode?.() ?? - sandboxInstance.editor.getDomNode(); + const container = editor.getContainerDomNode?.() ?? editor.getDomNode(); useResizeObserver(container, () => { resize(); }); useEffect(() => { - if ( - !sandboxInstance.editor.hasTextFocus() && - code !== tabs.code.getValue() - ) { - tabs.code.applyEdits([ - { - range: tabs.code.getFullModelRange(), - text: code, - }, - ]); + if (!editor.hasTextFocus() && code !== tabs.code.getValue()) { + applyEdit(tabs.code, editor, { + range: tabs.code.getFullModelRange(), + text: code, + }); } - }, [sandboxInstance, code, tabs.code]); + }, [code, editor, tabs.code]); useEffect(() => { - if ( - !sandboxInstance.editor.hasTextFocus() && - tsconfig !== tabs.tsconfig.getValue() - ) { - tabs.tsconfig.applyEdits([ - { - range: tabs.tsconfig.getFullModelRange(), - text: tsconfig, - }, - ]); + if (!editor.hasTextFocus() && tsconfig !== tabs.tsconfig.getValue()) { + applyEdit(tabs.tsconfig, editor, { + range: tabs.tsconfig.getFullModelRange(), + text: tsconfig, + }); } - }, [sandboxInstance, tabs.tsconfig, tsconfig]); + }, [editor, tabs.tsconfig, tsconfig]); useEffect(() => { - if ( - !sandboxInstance.editor.hasTextFocus() && - eslintrc !== tabs.eslintrc.getValue() - ) { - tabs.eslintrc.applyEdits([ - { - range: tabs.eslintrc.getFullModelRange(), - text: eslintrc, - }, - ]); + if (!editor.hasTextFocus() && eslintrc !== tabs.eslintrc.getValue()) { + applyEdit(tabs.eslintrc, editor, { + range: tabs.eslintrc.getFullModelRange(), + text: eslintrc, + }); } - }, [sandboxInstance, eslintrc, tabs.eslintrc]); + }, [eslintrc, editor, tabs.eslintrc]); useEffect(() => { - sandboxInstance.monaco.editor.setTheme( - colorMode === 'dark' ? 'vs-dark' : 'vs-light', - ); - }, [colorMode, sandboxInstance]); + monaco.editor.setTheme(colorMode === 'dark' ? 'vs-dark' : 'vs-light'); + }, [colorMode, monaco]); useEffect(() => { setDecorations(prevDecorations => @@ -362,7 +307,7 @@ export const LoadedEditor: React.FC = ({ selectedRange && showAST ? [ { - range: sandboxInstance.monaco.Range.fromPositions( + range: monaco.Range.fromPositions( tabs.code.getPositionAt(selectedRange[0]), tabs.code.getPositionAt(selectedRange[1]), ), @@ -375,7 +320,11 @@ export const LoadedEditor: React.FC = ({ : [], ), ); - }, [selectedRange, sandboxInstance, showAST, tabs.code]); + }, [selectedRange, monaco, showAST, tabs.code]); + + useEffect(() => { + webLinter.triggerLint(tabs.code.uri.path); + }, [webLinter, fileType, sourceType, tabs.code]); return null; }; diff --git a/packages/website/src/components/editor/loadSandbox.ts b/packages/website/src/components/editor/loadSandbox.ts index 51b8b64295a0..f7798a4093eb 100644 --- a/packages/website/src/components/editor/loadSandbox.ts +++ b/packages/website/src/components/editor/loadSandbox.ts @@ -1,7 +1,7 @@ import type MonacoEditor from 'monaco-editor'; import type * as SandboxFactory from '../../vendor/sandbox'; -import type { LintUtils } from '../linter/WebLinter'; +import type { WebLinterModule } from '../linter/types'; type Monaco = typeof MonacoEditor; type Sandbox = typeof SandboxFactory; @@ -9,7 +9,7 @@ type Sandbox = typeof SandboxFactory; export interface SandboxModel { main: Monaco; sandboxFactory: Sandbox; - lintUtils: LintUtils; + lintUtils: WebLinterModule; } function loadSandbox(tsVersion: string): Promise { @@ -32,7 +32,7 @@ function loadSandbox(tsVersion: string): Promise { }); // Grab a copy of monaco, TypeScript and the sandbox - window.require<[Monaco, Sandbox, LintUtils]>( + window.require<[Monaco, Sandbox, WebLinterModule]>( ['vs/editor/editor.main', 'sandbox/index', 'linter/index'], (main, sandboxFactory, lintUtils) => { resolve({ main, sandboxFactory, lintUtils }); diff --git a/packages/website/src/components/editor/useSandboxServices.ts b/packages/website/src/components/editor/useSandboxServices.ts index 77ca6b82b3c8..21a7f51ea471 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -4,14 +4,15 @@ import { useEffect, useState } from 'react'; import type { createTypeScriptSandbox } from '../../vendor/sandbox'; import { createCompilerOptions } from '../lib/createCompilerOptions'; -import { WebLinter } from '../linter/WebLinter'; +import { createFileSystem } from '../linter/bridge'; +import { type CreateLinter, createLinter } from '../linter/createLinter'; +import type { PlaygroundSystem } from '../linter/types'; import type { RuleDetails } from '../types'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox'; import type { CommonEditorProps } from './types'; export interface SandboxServicesProps { - readonly jsx?: boolean; readonly onLoaded: ( ruleDetails: RuleDetails[], tsVersions: readonly string[], @@ -23,7 +24,8 @@ export type SandboxInstance = ReturnType; export interface SandboxServices { sandboxInstance: SandboxInstance; - webLinter: WebLinter; + system: PlaygroundSystem; + webLinter: CreateLinter; } export const useSandboxServices = ( @@ -67,22 +69,27 @@ export const useSandboxServices = ( colorMode === 'dark' ? 'vs-dark' : 'vs-light', ); - const libEntries = new Map(); + const system = createFileSystem(props, sandboxInstance.tsvfs); + const worker = await sandboxInstance.getWorkerProcess(); if (worker.getLibFiles) { const libs = await worker.getLibFiles(); for (const [key, value] of Object.entries(libs)) { - libEntries.set('/' + key, value); + system.writeFile('/' + key, value); } } - const system = sandboxInstance.tsvfs.createSystem(libEntries); + window.system = system; window.esquery = lintUtils.esquery; - const webLinter = new WebLinter(system, compilerOptions, lintUtils); + const webLinter = createLinter( + system, + lintUtils, + sandboxInstance.tsvfs, + ); onLoaded( - Array.from(webLinter.rulesMap.values()), + Array.from(webLinter.rules.values()), Array.from( new Set([...sandboxInstance.supportedVersions, window.ts.version]), ) @@ -91,8 +98,9 @@ export const useSandboxServices = ( ); setServices({ - sandboxInstance, + system, webLinter, + sandboxInstance, }); }) .catch(setServices); diff --git a/packages/website/src/components/lib/createEventsBinder.ts b/packages/website/src/components/lib/createEventsBinder.ts new file mode 100644 index 000000000000..6b5bfaecbee1 --- /dev/null +++ b/packages/website/src/components/lib/createEventsBinder.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createEventsBinder void>(): { + trigger: (...args: Parameters) => void; + register: (cb: T) => () => void; +} { + const events = new Set(); + + return { + trigger(...args: Parameters): void { + events.forEach(cb => cb(...args)); + }, + register(cb: T): () => void { + events.add(cb); + return (): void => { + events.delete(cb); + }; + }, + }; +} diff --git a/packages/website/src/components/lib/jsonSchema.ts b/packages/website/src/components/lib/jsonSchema.ts index 0b49632631be..b43b6ad72cb9 100644 --- a/packages/website/src/components/lib/jsonSchema.ts +++ b/packages/website/src/components/lib/jsonSchema.ts @@ -1,16 +1,16 @@ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'; import type * as ts from 'typescript'; -import type { WebLinter } from '../linter/WebLinter'; +import type { CreateLinter } from '../linter/createLinter'; /** * Get the JSON schema for the eslint config * Currently we only support the rules and extends */ -export function getEslintJsonSchema(linter: WebLinter): JSONSchema4 { +export function getEslintJsonSchema(linter: CreateLinter): JSONSchema4 { const properties: Record = {}; - for (const [, item] of linter.rulesMap) { + for (const [, item] of linter.rules) { properties[item.name] = { description: `${item.description}\n ${item.url}`, title: item.name.startsWith('@typescript') ? 'Rules' : 'Core rules', diff --git a/packages/website/src/components/linter/CompilerHost.ts b/packages/website/src/components/linter/CompilerHost.ts deleted file mode 100644 index 22fd9fa83c67..000000000000 --- a/packages/website/src/components/linter/CompilerHost.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CompilerHost, SourceFile, System } from 'typescript'; - -import type { LintUtils } from './WebLinter'; - -/** - * Creates an in-memory CompilerHost -which is essentially an extra wrapper to System - * which works with TypeScript objects - returns both a compiler host, and a way to add new SourceFile - * instances to the in-memory file system. - * - * based on typescript-vfs - * @see https://github.com/microsoft/TypeScript-Website/blob/d2613c0e57ae1be2f3a76e94b006819a1fc73d5e/packages/typescript-vfs/src/index.ts#L480 - */ -export function createVirtualCompilerHost( - sys: System, - lintUtils: LintUtils, -): CompilerHost { - return { - ...sys, - getCanonicalFileName: (fileName: string) => fileName, - getDefaultLibFileName: options => - '/' + window.ts.getDefaultLibFileName(options), - getNewLine: () => sys.newLine, - getSourceFile(fileName, languageVersionOrOptions): SourceFile | undefined { - if (this.fileExists(fileName)) { - const file = this.readFile(fileName) ?? ''; - return window.ts.createSourceFile( - fileName, - file, - languageVersionOrOptions, - true, - lintUtils.getScriptKind(fileName, false), - ); - } - return undefined; - }, - useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames, - }; -} diff --git a/packages/website/src/components/linter/WebLinter.ts b/packages/website/src/components/linter/WebLinter.ts deleted file mode 100644 index f789a7417173..000000000000 --- a/packages/website/src/components/linter/WebLinter.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { analyze } from '@typescript-eslint/scope-manager'; -import type { ParserOptions } from '@typescript-eslint/types'; -import type { - astConverter, - getScriptKind, -} from '@typescript-eslint/typescript-estree/use-at-your-own-risk'; -import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; -import type esquery from 'esquery'; -import type * as ts from 'typescript'; - -import type { EslintRC, RuleDetails } from '../types'; -import { createVirtualCompilerHost } from './CompilerHost'; -import { eslintConfig, PARSER_NAME, parseSettings } from './config'; - -export interface LintUtils { - createLinter: () => TSESLint.Linter; - analyze: typeof analyze; - visitorKeys: TSESLint.SourceCode.VisitorKeys; - astConverter: typeof astConverter; - getScriptKind: typeof getScriptKind; - esquery: typeof esquery; - configs: Record; -} - -export class WebLinter { - private readonly host: ts.CompilerHost; - - public storedAST?: TSESTree.Program; - public storedTsAST?: ts.SourceFile; - public storedScope?: Record; - - private compilerOptions: ts.CompilerOptions; - private eslintConfig = eslintConfig; - - private linter: TSESLint.Linter; - private lintUtils: LintUtils; - - public readonly rulesMap = new Map(); - public readonly configs: Record = {}; - - constructor( - system: ts.System, - compilerOptions: ts.CompilerOptions, - lintUtils: LintUtils, - ) { - this.compilerOptions = compilerOptions; - this.lintUtils = lintUtils; - this.linter = lintUtils.createLinter(); - - this.host = createVirtualCompilerHost(system, lintUtils); - - this.linter.defineParser(PARSER_NAME, { - parseForESLint: (text, options?: ParserOptions) => { - return this.eslintParse(text, options); - }, - }); - - this.configs = lintUtils.configs; - - this.linter.getRules().forEach((item, name) => { - this.rulesMap.set(name, { - name: name, - description: item.meta?.docs?.description, - url: item.meta?.docs?.url, - }); - }); - } - - lint(code: string, filename: string): TSESLint.Linter.LintMessage[] { - return this.linter.verify(code, this.eslintConfig, { - filename: filename, - }); - } - - fix(code: string, filename: string): TSESLint.Linter.FixReport { - return this.linter.verifyAndFix(code, this.eslintConfig, { - filename: filename, - fix: true, - }); - } - - updateEslintConfig(config: EslintRC): void { - const resolvedConfig = this.resolveEslintConfig(config); - this.eslintConfig.rules = resolvedConfig.rules; - } - - updateParserOptions(sourceType?: TSESLint.SourceType): void { - this.eslintConfig.parserOptions ??= {}; - this.eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; - } - - updateCompilerOptions(options: ts.CompilerOptions = {}): void { - this.compilerOptions = options; - } - - eslintParse( - code: string, - eslintOptions: ParserOptions = {}, - ): TSESLint.Linter.ESLintParseResult { - const fileName = eslintOptions.filePath ?? '/input.ts'; - - this.storedAST = undefined; - this.storedTsAST = undefined; - this.storedScope = undefined; - - this.host.writeFile(fileName, code || '\n', false); - - const program = window.ts.createProgram({ - rootNames: [fileName], - options: this.compilerOptions, - host: this.host, - }); - const tsAst = program.getSourceFile(fileName)!; - const checker = program.getTypeChecker(); - - const { estree: ast, astMaps } = this.lintUtils.astConverter( - tsAst, - { ...parseSettings, code, codeFullText: code }, - true, - ); - - const scopeManager = this.lintUtils.analyze(ast, { - globalReturn: eslintOptions.ecmaFeatures?.globalReturn ?? false, - sourceType: eslintOptions.sourceType ?? 'script', - }); - - this.storedAST = ast; - this.storedTsAST = tsAst; - this.storedScope = scopeManager as unknown as Record; - - return { - ast, - services: { - program, - esTreeNodeToTSNodeMap: astMaps.esTreeNodeToTSNodeMap, - tsNodeToESTreeNodeMap: astMaps.tsNodeToESTreeNodeMap, - getSymbolAtLocation: node => - checker.getSymbolAtLocation(astMaps.esTreeNodeToTSNodeMap.get(node)), - getTypeAtLocation: node => - checker.getTypeAtLocation(astMaps.esTreeNodeToTSNodeMap.get(node)), - }, - scopeManager, - visitorKeys: this.lintUtils.visitorKeys, - }; - } - - private resolveEslintConfig( - cfg: Partial, - ): TSESLint.Linter.Config { - const config = { - rules: {}, - overrides: [], - }; - if (cfg.extends) { - const cfgExtends = Array.isArray(cfg.extends) - ? cfg.extends - : [cfg.extends]; - for (const extendsName of cfgExtends) { - if (typeof extendsName === 'string' && extendsName in this.configs) { - const resolved = this.resolveEslintConfig(this.configs[extendsName]); - if (resolved.rules) { - Object.assign(config.rules, resolved.rules); - } - } - } - } - if (cfg.rules) { - Object.assign(config.rules, cfg.rules); - } - return config; - } -} diff --git a/packages/website/src/components/linter/bridge.ts b/packages/website/src/components/linter/bridge.ts new file mode 100644 index 000000000000..b0614fc4329d --- /dev/null +++ b/packages/website/src/components/linter/bridge.ts @@ -0,0 +1,79 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type * as ts from 'typescript'; + +import { debounce } from '../lib/debounce'; +import type { ConfigModel } from '../types'; +import type { PlaygroundSystem } from './types'; + +export function createFileSystem( + config: Pick, + vfs: typeof tsvfs, +): PlaygroundSystem { + const files = new Map(); + files.set(`/.eslintrc`, config.eslintrc); + files.set(`/tsconfig.json`, config.tsconfig); + files.set(`/input${config.fileType}`, config.code); + + const fileWatcherCallbacks = new Map>(); + + const system = vfs.createSystem(files) as PlaygroundSystem; + + system.watchFile = ( + path, + callback, + pollingInterval = 500, + ): ts.FileWatcher => { + const cb = pollingInterval ? debounce(callback, pollingInterval) : callback; + + const escapedPath = path.replace(/\./g, '\\.').replace(/\*/g, '[^/]+'); + const expPath = new RegExp(`^${escapedPath}$`, ''); + + let handle = fileWatcherCallbacks.get(expPath); + if (!handle) { + handle = new Set(); + fileWatcherCallbacks.set(expPath, handle); + } + handle.add(cb); + + return { + close: (): void => { + fileWatcherCallbacks.get(expPath)?.delete(cb); + }, + }; + }; + + const triggerCallbacks = ( + path: string, + type: ts.FileWatcherEventKind, + ): void => { + fileWatcherCallbacks.forEach((callbacks, key) => { + if (key.test(path)) { + callbacks.forEach(cb => cb(path, type)); + } + }); + }; + + system.deleteFile = (fileName): void => { + files.delete(fileName); + triggerCallbacks(fileName, 1); + }; + + system.writeFile = (fileName, contents): void => { + if (!contents) { + contents = ''; + } + const file = files.get(fileName); + if (file === contents) { + // do not trigger callbacks if the file has not changed + return; + } + files.set(fileName, contents); + triggerCallbacks(fileName, file ? 2 : 0); + }; + + system.removeFile = (fileName): void => { + files.delete(fileName); + }; + + return system; +} diff --git a/packages/website/src/components/linter/config.ts b/packages/website/src/components/linter/config.ts index 641adc873c1b..f728dc1a6355 100644 --- a/packages/website/src/components/linter/config.ts +++ b/packages/website/src/components/linter/config.ts @@ -3,7 +3,7 @@ import type { TSESLint } from '@typescript-eslint/utils'; export const PARSER_NAME = '@typescript-eslint/parser'; -export const parseSettings: ParseSettings = { +export const defaultParseSettings: ParseSettings = { allowInvalidAST: false, code: '', codeFullText: '', @@ -30,11 +30,11 @@ export const parseSettings: ParseSettings = { tsconfigRootDir: '/', }; -export const eslintConfig: TSESLint.Linter.Config = { +export const defaultEslintConfig: TSESLint.Linter.Config = { parser: PARSER_NAME, parserOptions: { ecmaFeatures: { - jsx: false, + jsx: true, globalReturn: false, }, ecmaVersion: 'latest', diff --git a/packages/website/src/components/linter/createLinter.ts b/packages/website/src/components/linter/createLinter.ts new file mode 100644 index 000000000000..813fd10eaf24 --- /dev/null +++ b/packages/website/src/components/linter/createLinter.ts @@ -0,0 +1,152 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type { TSESLint } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +import { createCompilerOptions } from '../lib/createCompilerOptions'; +import { createEventsBinder } from '../lib/createEventsBinder'; +import { parseESLintRC, parseTSConfig } from '../lib/parseConfig'; +import { defaultEslintConfig, PARSER_NAME } from './config'; +import { createParser } from './createParser'; +import type { + LinterOnLint, + LinterOnParse, + PlaygroundSystem, + WebLinterModule, +} from './types'; + +export interface CreateLinter { + rules: Map; + configs: string[]; + triggerFix(filename: string): TSESLint.Linter.FixReport | undefined; + triggerLint(filename: string): void; + onLint(cb: LinterOnLint): () => void; + onParse(cb: LinterOnParse): () => void; + updateParserOptions(sourceType?: TSESLint.SourceType): void; +} + +export function createLinter( + system: PlaygroundSystem, + webLinterModule: WebLinterModule, + vfs: typeof tsvfs, +): CreateLinter { + const rules: CreateLinter['rules'] = new Map(); + const configs = new Map(Object.entries(webLinterModule.configs)); + let compilerOptions: ts.CompilerOptions = {}; + const eslintConfig: TSESLint.Linter.Config = { ...defaultEslintConfig }; + + const onLint = createEventsBinder(); + const onParse = createEventsBinder(); + + const linter = webLinterModule.createLinter(); + + const parser = createParser( + system, + compilerOptions, + (filename, model): void => { + onParse.trigger(filename, model); + }, + webLinterModule, + vfs, + ); + + linter.defineParser(PARSER_NAME, parser); + + linter.getRules().forEach((item, name) => { + rules.set(name, { + name: name, + description: item.meta?.docs?.description, + url: item.meta?.docs?.url, + }); + }); + + const triggerLint = (filename: string): void => { + console.info('[Editor] linting triggered for file', filename); + const code = system.readFile(filename) ?? '\n'; + if (code != null) { + const messages = linter.verify(code, eslintConfig, filename); + onLint.trigger(filename, messages); + } + }; + + const triggerFix = ( + filename: string, + ): TSESLint.Linter.FixReport | undefined => { + const code = system.readFile(filename); + if (code) { + return linter.verifyAndFix(code, eslintConfig, { + filename: filename, + fix: true, + }); + } + return undefined; + }; + + const updateParserOptions = (sourceType?: TSESLint.SourceType): void => { + eslintConfig.parserOptions ??= {}; + eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; + }; + + const resolveEslintConfig = ( + cfg: Partial, + ): TSESLint.Linter.Config => { + const config = { rules: {} }; + if (cfg.extends) { + const cfgExtends = Array.isArray(cfg.extends) + ? cfg.extends + : [cfg.extends]; + for (const extendsName of cfgExtends) { + const maybeConfig = configs.get(extendsName); + if (maybeConfig) { + const resolved = resolveEslintConfig(maybeConfig); + if (resolved.rules) { + Object.assign(config.rules, resolved.rules); + } + } + } + } + if (cfg.rules) { + Object.assign(config.rules, cfg.rules); + } + return config; + }; + + const applyEslintConfig = (fileName: string): void => { + try { + const file = system.readFile(fileName) ?? '{}'; + const parsed = resolveEslintConfig(parseESLintRC(file)); + eslintConfig.rules = parsed.rules; + console.log('[Editor] Updating', fileName, eslintConfig); + } catch (e) { + console.error(e); + } + }; + + const applyTSConfig = (fileName: string): void => { + try { + const file = system.readFile(fileName) ?? '{}'; + const parsed = parseTSConfig(file).compilerOptions; + compilerOptions = createCompilerOptions(parsed); + console.log('[Editor] Updating', fileName, compilerOptions); + parser.updateConfig(compilerOptions); + } catch (e) { + console.error(e); + } + }; + + system.watchFile('/input.*', triggerLint); + system.watchFile('/.eslintrc', applyEslintConfig); + system.watchFile('/tsconfig.json', applyTSConfig); + + applyEslintConfig('/.eslintrc'); + applyTSConfig('/tsconfig.json'); + + return { + rules, + configs: Array.from(configs.keys()), + triggerFix, + triggerLint, + updateParserOptions, + onParse: onParse.register, + onLint: onLint.register, + }; +} diff --git a/packages/website/src/components/linter/createParser.ts b/packages/website/src/components/linter/createParser.ts new file mode 100644 index 000000000000..1c0d9258a8f2 --- /dev/null +++ b/packages/website/src/components/linter/createParser.ts @@ -0,0 +1,108 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type { ParserOptions } from '@typescript-eslint/types'; +import type { TSESLint } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +import { defaultParseSettings } from './config'; +import type { + ParseSettings, + PlaygroundSystem, + UpdateModel, + WebLinterModule, +} from './types'; + +export function createParser( + system: PlaygroundSystem, + compilerOptions: ts.CompilerOptions, + onUpdate: (filename: string, model: UpdateModel) => void, + utils: WebLinterModule, + vfs: typeof tsvfs, +): TSESLint.Linter.ParserModule & { + updateConfig: (compilerOptions: ts.CompilerOptions) => void; +} { + const registeredFiles = new Set(); + + const createEnv = ( + compilerOptions: ts.CompilerOptions, + ): tsvfs.VirtualTypeScriptEnvironment => { + return vfs.createVirtualTypeScriptEnvironment( + system, + Array.from(registeredFiles), + window.ts, + compilerOptions, + ); + }; + + let compilerHost = createEnv(compilerOptions); + + return { + updateConfig(compilerOptions): void { + compilerHost = createEnv(compilerOptions); + }, + parseForESLint: ( + text: string, + options: ParserOptions = {}, + ): TSESLint.Linter.ESLintParseResult => { + const filePath = options.filePath ?? '/input.ts'; + + // if text is empty use empty line to avoid error + const code = text || '\n'; + + if (registeredFiles.has(filePath)) { + compilerHost.updateFile(filePath, code); + } else { + registeredFiles.add(filePath); + compilerHost.createFile(filePath, code); + } + + const parseSettings: ParseSettings = { + ...defaultParseSettings, + code: code, + codeFullText: code, + filePath: filePath, + }; + + const program = compilerHost.languageService.getProgram(); + if (!program) { + throw new Error('Failed to get program'); + } + + const tsAst = program.getSourceFile(filePath)!; + + const converted = utils.astConverter(tsAst, parseSettings, true); + + const scopeManager = utils.analyze(converted.estree, { + globalReturn: options.ecmaFeatures?.globalReturn ?? false, + sourceType: options.sourceType ?? 'module', + }); + + const checker = program.getTypeChecker(); + + onUpdate(filePath, { + storedAST: converted.estree, + storedTsAST: tsAst, + storedScope: scopeManager, + typeChecker: checker, + }); + + return { + ast: converted.estree, + services: { + program, + esTreeNodeToTSNodeMap: converted.astMaps.esTreeNodeToTSNodeMap, + tsNodeToESTreeNodeMap: converted.astMaps.tsNodeToESTreeNodeMap, + getSymbolAtLocation: node => + checker.getSymbolAtLocation( + converted.astMaps.esTreeNodeToTSNodeMap.get(node), + ), + getTypeAtLocation: node => + checker.getTypeAtLocation( + converted.astMaps.esTreeNodeToTSNodeMap.get(node), + ), + }, + scopeManager, + visitorKeys: utils.visitorKeys, + }; + }, + }; +} diff --git a/packages/website/src/components/linter/types.ts b/packages/website/src/components/linter/types.ts new file mode 100644 index 000000000000..3623eb649ff7 --- /dev/null +++ b/packages/website/src/components/linter/types.ts @@ -0,0 +1,35 @@ +import type { analyze, ScopeManager } from '@typescript-eslint/scope-manager'; +import type { astConverter } from '@typescript-eslint/typescript-estree/use-at-your-own-risk'; +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import type esquery from 'esquery'; +import type * as ts from 'typescript'; + +export type { ParseSettings } from '@typescript-eslint/typescript-estree/use-at-your-own-risk'; + +export interface UpdateModel { + storedAST?: TSESTree.Program; + storedTsAST?: ts.SourceFile; + storedScope?: ScopeManager; + typeChecker?: ts.TypeChecker; +} + +export interface WebLinterModule { + createLinter: () => TSESLint.Linter; + analyze: typeof analyze; + visitorKeys: TSESLint.SourceCode.VisitorKeys; + astConverter: typeof astConverter; + esquery: typeof esquery; + configs: Record; +} + +export type PlaygroundSystem = ts.System & + Required> & { + removeFile: (fileName: string) => void; + }; + +export type LinterOnLint = ( + fileName: string, + messages: TSESLint.Linter.LintMessage[], +) => void; + +export type LinterOnParse = (fileName: string, model: UpdateModel) => void; diff --git a/packages/website/src/globals.d.ts b/packages/website/src/globals.d.ts index 898d3649bee2..0f4c2463b060 100644 --- a/packages/website/src/globals.d.ts +++ b/packages/website/src/globals.d.ts @@ -18,5 +18,6 @@ declare global { ts: typeof ts; require: WindowRequire; esquery: typeof esquery; + system: unknown; } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy