From a9028d52ee4605d81de8f899026b197c8d6d5b3d Mon Sep 17 00:00:00 2001 From: Armano Date: Fri, 31 Mar 2023 18:26:50 +0200 Subject: [PATCH 1/7] chore(website): [playground] use tsvfs as for linting code --- .eslintrc.js | 2 +- .../src/components/editor/LoadedEditor.tsx | 426 ++++++++---------- .../website/src/components/editor/config.ts | 9 +- .../src/components/editor/loadSandbox.ts | 6 +- .../components/editor/useSandboxServices.ts | 75 +-- .../src/components/lib/createEventHelper.ts | 19 + .../src/components/linter/CompilerHost.ts | 38 -- .../src/components/linter/WebLinter.ts | 160 ------- .../website/src/components/linter/bridge.ts | 82 ++++ .../website/src/components/linter/config.ts | 19 +- .../src/components/linter/createLinter.ts | 122 +++++ .../src/components/linter/createParser.ts | 108 +++++ .../website/src/components/linter/types.ts | 48 ++ 13 files changed, 635 insertions(+), 479 deletions(-) create mode 100644 packages/website/src/components/lib/createEventHelper.ts delete mode 100644 packages/website/src/components/linter/CompilerHost.ts delete mode 100644 packages/website/src/components/linter/WebLinter.ts create mode 100644 packages/website/src/components/linter/bridge.ts create mode 100644 packages/website/src/components/linter/createLinter.ts create mode 100644 packages/website/src/components/linter/createParser.ts create mode 100644 packages/website/src/components/linter/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index 66097b17f060..395191ed87de 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -393,7 +393,7 @@ module.exports = { 'import/no-default-export': 'off', 'react/jsx-no-target-blank': 'off', 'react/no-unescaped-entities': 'off', - 'react-hooks/exhaustive-deps': 'off', // TODO: enable it later + 'react-hooks/exhaustive-deps': 'warn', // TODO: enable it later }, settings: { react: { diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index edfb49929c51..c16eefa49c20 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -3,16 +3,11 @@ import type Monaco from 'monaco-editor'; import type React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - parseESLintRC, - parseTSConfig, - tryParseEslintModule, -} from '../config/utils'; +import { parseTSConfig, tryParseEslintModule } from '../config/utils'; import { useResizeObserver } from '../hooks/useResizeObserver'; import { debounce } from '../lib/debounce'; import type { LintCodeAction } from '../linter/utils'; import { parseLintResults, parseMarkers } from '../linter/utils'; -import type { WebLinter } from '../linter/WebLinter'; import type { TabType } from '../types'; import { createCompilerOptions, @@ -21,11 +16,20 @@ import { } from './config'; 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,10 +44,11 @@ export const LoadedEditor: React.FC = ({ onMarkersChange, onChange, onSelect, - sandboxInstance, + sandboxInstance: { editor, monaco }, showAST, - sourceType, + system, webLinter, + sourceType, activeTab, }) => { const { colorMode } = useColorMode(); @@ -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,230 @@ 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(() => { - const newPath = jsx ? '/input.tsx' : '/input.ts'; + webLinter.updateParserOptions(jsx, sourceType); + }, [webLinter, jsx, sourceType]); + + useEffect(() => { + const newPath = `/input.${jsx ? 'tsx' : 'ts'}`; 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, 'typescript', - 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); } - }, [ - jsx, - sandboxInstance.editor, - sandboxInstance.monaco.Uri, - sandboxInstance.monaco.editor, - tabs, - ]); + }, [jsx, editor, system, monaco, tabs]); useEffect(() => { const config = createCompilerOptions( - jsx, parseTSConfig(tsconfig).compilerOptions, ); - webLinter.updateCompilerOptions(config); - sandboxInstance.setCompilerSettings(config); - }, [jsx, sandboxInstance, tsconfig, webLinter]); + // @ts-expect-error - monaco and ts compilerOptions are not the same + monaco.languages.typescript.typescriptDefaults.setCompilerOptions(config); + }, [monaco, tsconfig]); useEffect(() => { - webLinter.updateRules(parseESLintRC(eslintrc).rules); - }, [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(jsx, sourceType); - - try { - const messages = webLinter.lint(code); - - const markers = parseLintResults(messages, codeActions, ruleId => - sandboxInstance.monaco.Uri.parse( - webLinter.rulesUrl.get(ruleId) ?? '', - ), - ); - - 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); - onSelect(sandboxInstance.editor.getPosition()); - }, 500); - - lintEditor(); - }, [ - code, - jsx, - 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 - schema: getEslintSchema(webLinter.ruleNames), + uri: monaco.Uri.file('eslint-schema.json').toString(), // id of the first schema + fileMatch: ['/.eslintrc'], // associate with our model + schema: getEslintSchema(Array.from(webLinter.rules.values())), }, { - 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: getTsConfigSchema(), }, ], }); + }, [monaco, webLinter.rules]); - 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); } - }), - sandboxInstance.editor.onDidChangeCursorPosition( - debounce(() => { - if (tabs.code.isAttachedToEditor()) { - const position = sandboxInstance.editor.getPosition(); - if (position) { - console.info('[Editor] updating cursor', position); - onSelect(position); - } + } + }); + return () => disposable.dispose(); + }, [editor, tabs.eslintrc]); + + useEffect(() => { + const disposable = editor.onDidChangeCursorPosition( + debounce(() => { + if (tabs.code.isAttachedToEditor()) { + const position = editor.getPosition(); + if (position) { + console.info('[Editor] updating cursor', position); + onSelect(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()); - if (fixed.fixed) { - editorModel.pushEditOperations( - null, - [ - { - range: editorModel.getFullModelRange(), - text: fixed.output, - }, - ], - () => null, - ); - } + } + }, 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) }); }), - 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('/.eslintrc', filename => { + onChange({ eslintrc: system.readFile(filename) }); + }), + 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.ruleNames, - ]); + }, [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 +308,7 @@ export const LoadedEditor: React.FC = ({ decoration && showAST ? [ { - range: new sandboxInstance.monaco.Range( + range: new monaco.Range( decoration.start.line, decoration.start.column + 1, decoration.end.line, @@ -377,7 +323,13 @@ export const LoadedEditor: React.FC = ({ : [], ), ); - }, [decoration, sandboxInstance, showAST, tabs.code]); + }, [decoration, monaco, showAST, tabs.code]); + + useEffect(() => { + if (activeTab === 'code') { + webLinter.triggerLint(tabs.code.uri.path); + } + }, [webLinter, jsx, sourceType, activeTab, tabs.code]); return null; }; diff --git a/packages/website/src/components/editor/config.ts b/packages/website/src/components/editor/config.ts index aee01fb11f07..722a62611aba 100644 --- a/packages/website/src/components/editor/config.ts +++ b/packages/website/src/components/editor/config.ts @@ -1,19 +1,18 @@ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'; -import type Monaco from 'monaco-editor'; +import type * as ts from 'typescript'; import { getTypescriptOptions } from '../config/utils'; export function createCompilerOptions( - jsx = false, tsConfig: Record = {}, -): Monaco.languages.typescript.CompilerOptions { +): ts.CompilerOptions { const config = window.ts.convertCompilerOptionsFromJson( { // ts and monaco has different type as monaco types are not changing base on ts version target: 'esnext', module: 'esnext', ...tsConfig, - jsx: jsx ? 'preserve' : undefined, + jsx: 'preserve', lib: Array.isArray(tsConfig.lib) ? tsConfig.lib : undefined, moduleResolution: undefined, plugins: undefined, @@ -25,7 +24,7 @@ export function createCompilerOptions( '/tsconfig.json', ); - const options = config.options as Monaco.languages.typescript.CompilerOptions; + const options = config.options; if (!options.lib) { options.lib = [window.ts.getDefaultLibFileName(options)]; 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 3007856e54ac..880e5278dcda 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -1,13 +1,13 @@ import { useColorMode } from '@docusaurus/theme-common'; +import type * as MonacoEditor from 'monaco-editor'; import { useEffect, useState } from 'react'; -import type { - createTypeScriptSandbox, - SandboxConfig, -} from '../../vendor/sandbox'; -import { WebLinter } from '../linter/WebLinter'; +import type { createTypeScriptSandbox } from '../../vendor/sandbox'; +import { createCompilerOptions } from '../editor/config'; +import { createFileSystem } from '../linter/bridge'; +import { createLinter } from '../linter/createLinter'; +import type { PlaygroundSystem, WebLinter } from '../linter/types'; import type { RuleDetails } from '../types'; -import { createCompilerOptions } from './config'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox'; import type { CommonEditorProps } from './types'; @@ -25,6 +25,7 @@ export type SandboxInstance = ReturnType; export interface SandboxServices { sandboxInstance: SandboxInstance; + system: PlaygroundSystem; webLinter: WebLinter; } @@ -48,29 +49,28 @@ export const useSandboxServices = ( sandboxSingleton(props.ts) .then(async ({ main, sandboxFactory, lintUtils }) => { - const compilerOptions = createCompilerOptions(props.jsx); - - const sandboxConfig: Partial = { - text: props.code, - monacoSettings: { - minimap: { enabled: false }, - fontSize: 13, - wordWrap: 'off', - scrollBeyondLastLine: false, - smoothScrolling: true, - autoIndent: 'full', - formatOnPaste: true, - formatOnType: true, - wrappingIndent: 'same', - hover: { above: false }, - }, - acquireTypes: false, - compilerOptions: compilerOptions, - domID: editorEmbedId, - }; + const compilerOptions = + createCompilerOptions() as MonacoEditor.languages.typescript.CompilerOptions; sandboxInstance = sandboxFactory.createTypeScriptSandbox( - sandboxConfig, + { + text: props.code, + monacoSettings: { + minimap: { enabled: false }, + fontSize: 13, + wordWrap: 'off', + scrollBeyondLastLine: false, + smoothScrolling: true, + autoIndent: 'full', + formatOnPaste: true, + formatOnType: true, + wrappingIndent: 'same', + hover: { above: false }, + }, + acquireTypes: false, + compilerOptions: compilerOptions, + domID: editorEmbedId, + }, main, window.ts, ); @@ -78,22 +78,28 @@ 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); + // @ts-expect-error - we're adding these to the window for debugging purposes + window.system = system; window.esquery = lintUtils.esquery; - const webLinter = new WebLinter(system, compilerOptions, lintUtils); + const webLinter = createLinter( + system, + lintUtils, + sandboxInstance.tsvfs, + ); onLoaded( - webLinter.ruleNames, + Array.from(webLinter.rules.values()), Array.from( new Set([...sandboxInstance.supportedVersions, window.ts.version]), ) @@ -102,8 +108,9 @@ export const useSandboxServices = ( ); setServices({ - sandboxInstance, + system, webLinter, + sandboxInstance, }); }) .catch(setServices); @@ -128,7 +135,7 @@ export const useSandboxServices = ( }; // colorMode and jsx can't be reactive here because we don't want to force a recreation // updating of colorMode and jsx is handled in LoadedEditor - }, [props.ts, onLoaded]); + }, []); return services; }; diff --git a/packages/website/src/components/lib/createEventHelper.ts b/packages/website/src/components/lib/createEventHelper.ts new file mode 100644 index 000000000000..17e93028b0d1 --- /dev/null +++ b/packages/website/src/components/lib/createEventHelper.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createEventHelper 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/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 e8a92a1003c3..000000000000 --- a/packages/website/src/components/linter/WebLinter.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { createVirtualCompilerHost } from '@site/src/components/linter/CompilerHost'; -import { parseSettings } from '@site/src/components/linter/config'; -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 { - CompilerHost, - CompilerOptions, - SourceFile, - System, -} from 'typescript'; - -const PARSER_NAME = '@typescript-eslint/parser'; - -export interface LintUtils { - createLinter: () => TSESLint.Linter; - analyze: typeof analyze; - visitorKeys: TSESLint.SourceCode.VisitorKeys; - astConverter: typeof astConverter; - getScriptKind: typeof getScriptKind; - esquery: typeof esquery; -} - -export class WebLinter { - private readonly host: CompilerHost; - - public storedAST?: TSESTree.Program; - public storedTsAST?: SourceFile; - public storedScope?: Record; - - private compilerOptions: CompilerOptions; - private readonly parserOptions: ParserOptions = { - ecmaFeatures: { - jsx: false, - globalReturn: false, - }, - ecmaVersion: 'latest', - project: ['./tsconfig.json'], - sourceType: 'module', - }; - - private linter: TSESLint.Linter; - private lintUtils: LintUtils; - private rules: TSESLint.Linter.RulesRecord = {}; - - public readonly ruleNames: { name: string; description?: string }[] = []; - public readonly rulesUrl = new Map(); - - constructor( - system: System, - compilerOptions: 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.linter.getRules().forEach((item, name) => { - this.ruleNames.push({ - name: name, - description: item.meta?.docs?.description, - }); - this.rulesUrl.set(name, item.meta?.docs?.url); - }); - } - - get eslintConfig(): TSESLint.Linter.Config { - return { - parser: PARSER_NAME, - parserOptions: this.parserOptions, - rules: this.rules, - }; - } - - lint(code: string): TSESLint.Linter.LintMessage[] { - return this.linter.verify(code, this.eslintConfig); - } - - fix(code: string): TSESLint.Linter.FixReport { - return this.linter.verifyAndFix(code, this.eslintConfig, { fix: true }); - } - - updateRules(rules: TSESLint.Linter.RulesRecord): void { - this.rules = rules; - } - - updateParserOptions(jsx?: boolean, sourceType?: TSESLint.SourceType): void { - this.parserOptions.ecmaFeatures!.jsx = jsx ?? false; - this.parserOptions.sourceType = sourceType ?? 'module'; - } - - updateCompilerOptions(options: CompilerOptions = {}): void { - this.compilerOptions = options; - } - - eslintParse( - code: string, - eslintOptions: ParserOptions = {}, - ): TSESLint.Linter.ESLintParseResult { - const isJsx = eslintOptions?.ecmaFeatures?.jsx ?? false; - const fileName = isJsx ? '/demo.tsx' : '/demo.ts'; - - this.storedAST = undefined; - this.storedTsAST = undefined; - this.storedScope = undefined; - - this.host.writeFile(fileName, code, 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, jsx: isJsx }, - 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, - }; - } -} diff --git a/packages/website/src/components/linter/bridge.ts b/packages/website/src/components/linter/bridge.ts new file mode 100644 index 000000000000..ce04df99e59e --- /dev/null +++ b/packages/website/src/components/linter/bridge.ts @@ -0,0 +1,82 @@ +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.jsx ? 'tsx' : 'ts'}`, 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 => { + const handle = fileWatcherCallbacks.get(expPath); + if (handle) { + handle.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 93466f9451ab..021a4c91b749 100644 --- a/packages/website/src/components/linter/config.ts +++ b/packages/website/src/components/linter/config.ts @@ -1,6 +1,7 @@ import type { ParseSettings } from '@typescript-eslint/typescript-estree/use-at-your-own-risk'; +import type { TSESLint } from '@typescript-eslint/utils'; -export const parseSettings: ParseSettings = { +export const defaultParseSettings: ParseSettings = { allowInvalidAST: false, code: '', codeFullText: '', @@ -26,3 +27,19 @@ export const parseSettings: ParseSettings = { tsconfigMatchCache: new Map(), tsconfigRootDir: '/', }; + +export const PARSER_NAME = '@typescript-eslint/parser'; + +export const defaultEslintConfig: TSESLint.Linter.Config = { + parser: PARSER_NAME, + parserOptions: { + ecmaFeatures: { + jsx: false, + globalReturn: false, + }, + ecmaVersion: 'latest', + project: ['./tsconfig.json'], + sourceType: 'module', + }, + rules: {}, +}; diff --git a/packages/website/src/components/linter/createLinter.ts b/packages/website/src/components/linter/createLinter.ts new file mode 100644 index 000000000000..22ea5c9e2244 --- /dev/null +++ b/packages/website/src/components/linter/createLinter.ts @@ -0,0 +1,122 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type { TSESLint } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +import { parseESLintRC, parseTSConfig } from '../config/utils'; +import { createCompilerOptions } from '../editor/config'; +import { createEventHelper } from '../lib/createEventHelper'; +import { defaultEslintConfig, PARSER_NAME } from './config'; +import { createParser } from './createParser'; +import type { + LinterOnLint, + LinterOnParse, + PlaygroundSystem, + WebLinter, + WebLinterModule, +} from './types'; + +export function createLinter( + system: PlaygroundSystem, + webLinterModule: WebLinterModule, + vfs: typeof tsvfs, +): WebLinter { + const rules: WebLinter['rules'] = new Map(); + let compilerOptions: ts.CompilerOptions = {}; + const eslintConfig = { ...defaultEslintConfig }; + + const onLint = createEventHelper(); + const onParse = createEventHelper(); + + 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 = ( + jsx?: boolean, + sourceType?: TSESLint.SourceType, + ): void => { + eslintConfig.parserOptions ??= {}; + eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; + eslintConfig.parserOptions.ecmaFeatures ??= {}; + eslintConfig.parserOptions.ecmaFeatures.jsx = jsx ?? false; + }; + + const applyEslintConfig = (fileName: string): void => { + try { + const file = system.readFile(fileName) ?? '{}'; + const parsed = 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, + 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..88db4f2ec429 --- /dev/null +++ b/packages/website/src/components/linter/types.ts @@ -0,0 +1,48 @@ +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; +} + +export type PlaygroundSystem = ts.System & + Required> & { + removeFile: (fileName: string) => void; + }; + +export type RulesMap = Map< + string, + { name: string; description?: string; url?: string } +>; + +export type LinterOnLint = ( + fileName: string, + messages: TSESLint.Linter.LintMessage[], +) => void; + +export type LinterOnParse = (fileName: string, model: UpdateModel) => void; + +export interface WebLinter { + rules: RulesMap; + triggerFix(filename: string): TSESLint.Linter.FixReport | undefined; + triggerLint(filename: string): void; + onLint(cb: LinterOnLint): () => void; + onParse(cb: LinterOnParse): () => void; + updateParserOptions(jsx?: boolean, sourceType?: TSESLint.SourceType): void; +} From a655909c4798813c98f84b4bf825c87aae452560 Mon Sep 17 00:00:00 2001 From: Armano Date: Sun, 2 Apr 2023 22:19:20 +0200 Subject: [PATCH 2/7] fix: correct issue after merge do not trigger linting on tab change, --- packages/website/src/components/editor/LoadedEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index 75862ca38449..d6aa027537d0 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -323,7 +323,7 @@ export const LoadedEditor: React.FC = ({ useEffect(() => { webLinter.triggerLint(tabs.code.uri.path); - }, [webLinter, fileType, sourceType, activeTab, tabs.code]); + }, [webLinter, fileType, sourceType, tabs.code]); return null; }; From 0adaa655582592be6ee5729044a8a584ad221c28 Mon Sep 17 00:00:00 2001 From: Armano Date: Mon, 3 Apr 2023 13:11:10 +0200 Subject: [PATCH 3/7] fix: refactor linter back to class --- .../src/components/OptionsSelector.tsx | 2 +- .../website/src/components/Playground.tsx | 2 +- .../src/components/ast/PropertyName.tsx | 31 ++-- .../src/components/editor/LoadedEditor.tsx | 4 +- .../website/src/components/editor/config.ts | 2 +- .../components/editor/useSandboxServices.ts | 8 +- .../src/components/hooks/useHashState.ts | 2 +- .../src/components/linter/WebLinter.ts | 142 +++++++++++++++++ .../src/components/linter/createLinter.ts | 143 ------------------ .../website/src/components/linter/types.ts | 10 -- 10 files changed, 170 insertions(+), 176 deletions(-) create mode 100644 packages/website/src/components/linter/WebLinter.ts delete mode 100644 packages/website/src/components/linter/createLinter.ts diff --git a/packages/website/src/components/OptionsSelector.tsx b/packages/website/src/components/OptionsSelector.tsx index 0b83812cfcdb..24adc49093f2 100644 --- a/packages/website/src/components/OptionsSelector.tsx +++ b/packages/website/src/components/OptionsSelector.tsx @@ -7,13 +7,13 @@ import IconExternalLink from '@theme/Icon/ExternalLink'; import React, { useCallback } from 'react'; import { useClipboard } from '../hooks/useClipboard'; -import { fileTypes } from './options'; import Dropdown from './inputs/Dropdown'; import Tooltip from './inputs/Tooltip'; import ActionLabel from './layout/ActionLabel'; import Expander from './layout/Expander'; import InputLabel from './layout/InputLabel'; import { createMarkdown, createMarkdownParams } from './lib/markdown'; +import { fileTypes } from './options'; import type { ConfigModel } from './types'; export interface OptionsSelectorParams { diff --git a/packages/website/src/components/Playground.tsx b/packages/website/src/components/Playground.tsx index 3f1b5cf75cc2..50dbb080cf90 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -5,7 +5,6 @@ import React, { useCallback, useState } from 'react'; import type { SourceFile } from 'typescript'; import ASTViewer from './ast/ASTViewer'; -import { defaultEslintConfig, defaultTsConfig, detailTabs } from './options'; import ConfigEslint from './config/ConfigEslint'; import ConfigTypeScript from './config/ConfigTypeScript'; import { EditorEmbed } from './editor/EditorEmbed'; @@ -15,6 +14,7 @@ import { ESQueryFilter } from './ESQueryFilter'; import useHashState from './hooks/useHashState'; import EditorTabs from './layout/EditorTabs'; import Loader from './layout/Loader'; +import { defaultEslintConfig, defaultTsConfig, detailTabs } from './options'; import OptionsSelector from './OptionsSelector'; import styles from './Playground.module.css'; import ConditionalSplitPane from './SplitPane/ConditionalSplitPane'; diff --git a/packages/website/src/components/ast/PropertyName.tsx b/packages/website/src/components/ast/PropertyName.tsx index d89b71cd0056..71a606996a90 100644 --- a/packages/website/src/components/ast/PropertyName.tsx +++ b/packages/website/src/components/ast/PropertyName.tsx @@ -9,45 +9,50 @@ export interface PropertyNameProps { readonly className?: string; } -export default function PropertyName(props: PropertyNameProps): JSX.Element { +export default function PropertyName({ + onClick: onClickProp, + onHover: onHoverProp, + className, + value, +}: PropertyNameProps): JSX.Element { const onClick = useCallback( (e: MouseEvent) => { e.preventDefault(); - props.onClick?.(); + onClickProp?.(); }, - [props.onClick], + [onClickProp], ); const onMouseEnter = useCallback(() => { - props.onHover?.(true); - }, [props.onHover]); + onHoverProp?.(true); + }, [onHoverProp]); const onMouseLeave = useCallback(() => { - props.onHover?.(false); - }, [props.onHover]); + onHoverProp?.(false); + }, [onHoverProp]); const onKeyDown = useCallback( (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault(); - props.onClick?.(); + onClickProp?.(); } }, - [props.onClick], + [onClickProp], ); return ( - {props.value} + {value} ); } diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index 15f94a11cc24..501b54a748c9 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -122,7 +122,7 @@ export const LoadedEditor: React.FC = ({ }, [activeTab, editor, tabs, updateMarkers]); useEffect(() => { - const disposable = webLinter.onLint((uri, messages) => { + const disposable = webLinter.onLint.register((uri, messages) => { const diagnostics = parseLintResults(messages, codeActions, ruleId => monaco.Uri.parse(webLinter.rules.get(ruleId)?.url ?? ''), ); @@ -137,7 +137,7 @@ export const LoadedEditor: React.FC = ({ }, [webLinter, monaco, codeActions, updateMarkers]); useEffect(() => { - const disposable = webLinter.onParse((uri, model) => { + const disposable = webLinter.onParse.register((uri, model) => { onEsASTChange(model.storedAST); onScopeChange(model.storedScope as Record | undefined); onTsASTChange(model.storedTsAST); diff --git a/packages/website/src/components/editor/config.ts b/packages/website/src/components/editor/config.ts index 103f89653c55..0f0c20a32429 100644 --- a/packages/website/src/components/editor/config.ts +++ b/packages/website/src/components/editor/config.ts @@ -2,7 +2,7 @@ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'; import type * as ts from 'typescript'; import { getTypescriptOptions } from '../config/utils'; -import type { WebLinter } from '../linter/types'; +import type { WebLinter } from '../linter/WebLinter'; export function createCompilerOptions( tsConfig: Record = {}, diff --git a/packages/website/src/components/editor/useSandboxServices.ts b/packages/website/src/components/editor/useSandboxServices.ts index 5efe059a56f4..64c99f1fd99b 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -3,11 +3,11 @@ import type * as MonacoEditor from 'monaco-editor'; import { useEffect, useState } from 'react'; import type { createTypeScriptSandbox } from '../../vendor/sandbox'; -import { createCompilerOptions } from './config'; import { createFileSystem } from '../linter/bridge'; -import { createLinter } from '../linter/createLinter'; -import type { PlaygroundSystem, WebLinter } from '../linter/types'; +import type { PlaygroundSystem } from '../linter/types'; +import { WebLinter } from '../linter/WebLinter'; import type { RuleDetails } from '../types'; +import { createCompilerOptions } from './config'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox'; import type { CommonEditorProps } from './types'; @@ -83,7 +83,7 @@ export const useSandboxServices = ( window.system = system; window.esquery = lintUtils.esquery; - const webLinter = createLinter( + const webLinter = new WebLinter( system, lintUtils, sandboxInstance.tsvfs, diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index 26dc810030cf..bf3c83b767e4 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -2,9 +2,9 @@ import { useHistory } from '@docusaurus/router'; import * as lz from 'lz-string'; import { useCallback, useState } from 'react'; -import { fileTypes } from '../options'; import { toJson } from '../config/utils'; import { hasOwnProperty } from '../lib/has-own-property'; +import { fileTypes } from '../options'; import type { ConfigFileType, ConfigModel, ConfigShowAst } from '../types'; function writeQueryParam(value: string | null): string { diff --git a/packages/website/src/components/linter/WebLinter.ts b/packages/website/src/components/linter/WebLinter.ts new file mode 100644 index 000000000000..1c07098a6d86 --- /dev/null +++ b/packages/website/src/components/linter/WebLinter.ts @@ -0,0 +1,142 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type { TSESLint } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +import { parseESLintRC, parseTSConfig } from '../config/utils'; +import { createCompilerOptions } from '../editor/config'; +import { createEventHelper } from '../lib/createEventHelper'; +import { defaultEslintConfig, PARSER_NAME } from './config'; +import { createParser } from './createParser'; +import type { + LinterOnLint, + LinterOnParse, + PlaygroundSystem, + WebLinterModule, +} from './types'; + +export class WebLinter { + readonly onLint = createEventHelper(); + readonly onParse = createEventHelper(); + readonly rules = new Map< + string, + { name: string; description?: string; url?: string } + >(); + readonly configs: string[] = []; + + readonly #configMap = new Map(); + #compilerOptions: ts.CompilerOptions = {}; + #eslintConfig: TSESLint.Linter.Config = { ...defaultEslintConfig }; + readonly #linter: TSESLint.Linter; + readonly #parser: ReturnType; + readonly #system: PlaygroundSystem; + + constructor( + system: PlaygroundSystem, + webLinterModule: WebLinterModule, + vfs: typeof tsvfs, + ) { + this.#configMap = new Map(Object.entries(webLinterModule.configs)); + this.#linter = webLinterModule.createLinter(); + this.#system = system; + this.configs = Array.from(this.#configMap.keys()); + + this.#parser = createParser( + system, + this.#compilerOptions, + (filename, model): void => { + this.onParse.trigger(filename, model); + }, + webLinterModule, + vfs, + ); + + this.#linter.defineParser(PARSER_NAME, this.#parser); + + this.#linter.getRules().forEach((item, name) => { + this.rules.set(name, { + name: name, + description: item.meta?.docs?.description, + url: item.meta?.docs?.url, + }); + }); + + system.watchFile('/input.*', this.triggerLint); + system.watchFile('/.eslintrc', this.#applyEslintConfig); + system.watchFile('/tsconfig.json', this.#applyTSConfig); + + this.#applyEslintConfig('/.eslintrc'); + this.#applyTSConfig('/tsconfig.json'); + } + + triggerLint(filename: string): void { + console.info('[Editor] linting triggered for file', filename); + const code = this.#system.readFile(filename) ?? '\n'; + if (code != null) { + const messages = this.#linter.verify(code, this.#eslintConfig, filename); + this.onLint.trigger(filename, messages); + } + } + + triggerFix(filename: string): TSESLint.Linter.FixReport | undefined { + const code = this.#system.readFile(filename); + if (code) { + return this.#linter.verifyAndFix(code, this.#eslintConfig, { + filename: filename, + fix: true, + }); + } + return undefined; + } + + updateParserOptions(sourceType?: TSESLint.SourceType): void { + this.#eslintConfig.parserOptions ??= {}; + this.#eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; + } + + #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 = this.#configMap.get(extendsName); + if (maybeConfig) { + const resolved = this.#resolveEslintConfig(maybeConfig); + if (resolved.rules) { + Object.assign(config.rules, resolved.rules); + } + } + } + } + if (cfg.rules) { + Object.assign(config.rules, cfg.rules); + } + return config; + } + + #applyEslintConfig(fileName: string): void { + try { + const file = this.#system.readFile(fileName) ?? '{}'; + const parsed = this.#resolveEslintConfig(parseESLintRC(file)); + this.#eslintConfig.rules = parsed.rules; + console.log('[Editor] Updating', fileName, this.#eslintConfig); + } catch (e) { + console.error(e); + } + } + + #applyTSConfig(fileName: string): void { + try { + const file = this.#system.readFile(fileName) ?? '{}'; + const parsed = parseTSConfig(file).compilerOptions; + this.#compilerOptions = createCompilerOptions(parsed); + console.log('[Editor] Updating', fileName, this.#compilerOptions); + this.#parser.updateConfig(this.#compilerOptions); + } catch (e) { + console.error(e); + } + } +} diff --git a/packages/website/src/components/linter/createLinter.ts b/packages/website/src/components/linter/createLinter.ts deleted file mode 100644 index 05b019b9723e..000000000000 --- a/packages/website/src/components/linter/createLinter.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type * as tsvfs from '@site/src/vendor/typescript-vfs'; -import type { TSESLint } from '@typescript-eslint/utils'; -import type * as ts from 'typescript'; - -import { parseESLintRC, parseTSConfig } from '../config/utils'; -import { createCompilerOptions } from '../editor/config'; -import { createEventHelper } from '../lib/createEventHelper'; -import { defaultEslintConfig, PARSER_NAME } from './config'; -import { createParser } from './createParser'; -import type { - LinterOnLint, - LinterOnParse, - PlaygroundSystem, - WebLinter, - WebLinterModule, -} from './types'; - -export function createLinter( - system: PlaygroundSystem, - webLinterModule: WebLinterModule, - vfs: typeof tsvfs, -): WebLinter { - const rules: WebLinter['rules'] = new Map(); - const configs = new Map(Object.entries(webLinterModule.configs)); - let compilerOptions: ts.CompilerOptions = {}; - const eslintConfig = { ...defaultEslintConfig }; - - const onLint = createEventHelper(); - const onParse = createEventHelper(); - - 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/types.ts b/packages/website/src/components/linter/types.ts index bd72c1eaf6ab..3623eb649ff7 100644 --- a/packages/website/src/components/linter/types.ts +++ b/packages/website/src/components/linter/types.ts @@ -33,13 +33,3 @@ export type LinterOnLint = ( ) => void; export type LinterOnParse = (fileName: string, model: UpdateModel) => void; - -export interface WebLinter { - 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; -} From 684833ae1f054eca0d05a8beb9724c1ce87c3fec Mon Sep 17 00:00:00 2001 From: Armano Date: Mon, 3 Apr 2023 16:04:34 +0200 Subject: [PATCH 4/7] fix: apply changes from code review --- .../components/config/ConfigTypeScript.tsx | 3 +- .../website/src/components/config/utils.ts | 42 ------------ .../src/components/editor/LoadedEditor.tsx | 16 ++--- .../components/editor/useSandboxServices.ts | 3 +- .../components/lib/createCompilerOptions.ts | 32 +++++++++ ...teEventHelper.ts => createEventsBinder.ts} | 2 +- .../{editor/config.ts => lib/jsonSchema.ts} | 67 +++++++++---------- .../src/components/linter/WebLinter.ts | 8 +-- .../website/src/components/linter/bridge.ts | 5 +- packages/website/src/globals.d.ts | 1 + packages/website/typings/typescript.d.ts | 12 ++++ 11 files changed, 95 insertions(+), 96 deletions(-) create mode 100644 packages/website/src/components/lib/createCompilerOptions.ts rename packages/website/src/components/lib/{createEventHelper.ts => createEventsBinder.ts} (84%) rename packages/website/src/components/{editor/config.ts => lib/jsonSchema.ts} (69%) diff --git a/packages/website/src/components/config/ConfigTypeScript.tsx b/packages/website/src/components/config/ConfigTypeScript.tsx index 40cd634ffb17..b5892277d8f4 100644 --- a/packages/website/src/components/config/ConfigTypeScript.tsx +++ b/packages/website/src/components/config/ConfigTypeScript.tsx @@ -1,10 +1,11 @@ import React, { useCallback, useEffect, useState } from 'react'; +import { getTypescriptOptions } from '../lib/jsonSchema'; import { shallowEqual } from '../lib/shallowEqual'; import type { ConfigModel, TSConfig } from '../types'; import type { ConfigOptionsType } from './ConfigEditor'; import ConfigEditor from './ConfigEditor'; -import { getTypescriptOptions, parseTSConfig, toJson } from './utils'; +import { parseTSConfig, toJson } from './utils'; interface ConfigTypeScriptProps { readonly isOpen: boolean; diff --git a/packages/website/src/components/config/utils.ts b/packages/website/src/components/config/utils.ts index 8c4092164ade..936b7435eb0f 100644 --- a/packages/website/src/components/config/utils.ts +++ b/packages/website/src/components/config/utils.ts @@ -2,16 +2,6 @@ import { isRecord } from '@site/src/components/ast/utils'; import type { EslintRC, TSConfig } from '@site/src/components/types'; import json5 from 'json5'; -export interface OptionDeclarations { - name: string; - type?: unknown; - category?: { message: string }; - description?: { message: string }; - element?: { - type: unknown; - }; -} - export function parseESLintRC(code?: string): EslintRC { if (code) { try { @@ -78,35 +68,3 @@ export function tryParseEslintModule(value: string): string { export function toJson(cfg: unknown): string { return JSON.stringify(cfg, null, 2); } - -export function getTypescriptOptions(): OptionDeclarations[] { - const allowedCategories = [ - 'Command-line Options', - 'Projects', - 'Compiler Diagnostics', - 'Editor Support', - 'Output Formatting', - 'Watch and Build Modes', - 'Source Map Options', - ]; - - const filteredNames = [ - 'moduleResolution', - 'moduleDetection', - 'plugins', - 'typeRoots', - 'jsx', - ]; - - // @ts-expect-error: definition is not fully correct - return (window.ts.optionDeclarations as OptionDeclarations[]).filter( - item => - (item.type === 'boolean' || - item.type === 'list' || - item.type instanceof Map) && - item.description && - item.category && - !allowedCategories.includes(item.category.message) && - !filteredNames.includes(item.name), - ); -} diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index 501b54a748c9..6da67570fc1f 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -5,15 +5,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { parseTSConfig, tryParseEslintModule } from '../config/utils'; import { useResizeObserver } from '../hooks/useResizeObserver'; +import { createCompilerOptions } from '../lib/createCompilerOptions'; import { debounce } from '../lib/debounce'; +import { + getEslintJsonSchema, + getTypescriptJsonSchema, +} from '../lib/jsonSchema'; import type { LintCodeAction } from '../linter/utils'; import { parseLintResults, parseMarkers } from '../linter/utils'; import type { TabType } from '../types'; -import { - createCompilerOptions, - getEslintSchema, - getTsConfigSchema, -} from './config'; import { createProvideCodeActions } from './createProvideCodeActions'; import type { CommonEditorProps } from './types'; import type { SandboxServices } from './useSandboxServices'; @@ -155,16 +155,16 @@ export const LoadedEditor: React.FC = ({ { uri: monaco.Uri.file('eslint-schema.json').toString(), // id of the first schema fileMatch: ['/.eslintrc'], // associate with our model - schema: getEslintSchema(webLinter), + schema: getEslintJsonSchema(webLinter), }, { uri: monaco.Uri.file('ts-schema.json').toString(), // id of the first schema fileMatch: ['/tsconfig.json'], // associate with our model - schema: getTsConfigSchema(), + schema: getTypescriptJsonSchema(), }, ], }); - }, [monaco, webLinter.rules]); + }, [monaco, webLinter]); useEffect(() => { const disposable = monaco.languages.registerCodeActionProvider( diff --git a/packages/website/src/components/editor/useSandboxServices.ts b/packages/website/src/components/editor/useSandboxServices.ts index 64c99f1fd99b..6578eefe0928 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -3,11 +3,11 @@ import type * as MonacoEditor from 'monaco-editor'; import { useEffect, useState } from 'react'; import type { createTypeScriptSandbox } from '../../vendor/sandbox'; +import { createCompilerOptions } from '../lib/createCompilerOptions'; import { createFileSystem } from '../linter/bridge'; import type { PlaygroundSystem } from '../linter/types'; import { WebLinter } from '../linter/WebLinter'; import type { RuleDetails } from '../types'; -import { createCompilerOptions } from './config'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox'; import type { CommonEditorProps } from './types'; @@ -79,7 +79,6 @@ export const useSandboxServices = ( } } - // @ts-expect-error - we're adding these to the window for debugging purposes window.system = system; window.esquery = lintUtils.esquery; diff --git a/packages/website/src/components/lib/createCompilerOptions.ts b/packages/website/src/components/lib/createCompilerOptions.ts new file mode 100644 index 000000000000..9415a56bcd7b --- /dev/null +++ b/packages/website/src/components/lib/createCompilerOptions.ts @@ -0,0 +1,32 @@ +import type * as ts from 'typescript'; + +export function createCompilerOptions( + tsConfig: Record = {}, +): ts.CompilerOptions { + const config = window.ts.convertCompilerOptionsFromJson( + { + // ts and monaco has different type as monaco types are not changing base on ts version + target: 'esnext', + module: 'esnext', + jsx: 'preserve', + ...tsConfig, + allowJs: true, + lib: Array.isArray(tsConfig.lib) ? tsConfig.lib : undefined, + moduleResolution: undefined, + plugins: undefined, + typeRoots: undefined, + paths: undefined, + moduleDetection: undefined, + baseUrl: undefined, + }, + '/tsconfig.json', + ); + + const options = config.options; + + if (!options.lib) { + options.lib = [window.ts.getDefaultLibFileName(options)]; + } + + return options; +} diff --git a/packages/website/src/components/lib/createEventHelper.ts b/packages/website/src/components/lib/createEventsBinder.ts similarity index 84% rename from packages/website/src/components/lib/createEventHelper.ts rename to packages/website/src/components/lib/createEventsBinder.ts index 17e93028b0d1..6b5bfaecbee1 100644 --- a/packages/website/src/components/lib/createEventHelper.ts +++ b/packages/website/src/components/lib/createEventsBinder.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function createEventHelper void>(): { +export function createEventsBinder void>(): { trigger: (...args: Parameters) => void; register: (cb: T) => () => void; } { diff --git a/packages/website/src/components/editor/config.ts b/packages/website/src/components/lib/jsonSchema.ts similarity index 69% rename from packages/website/src/components/editor/config.ts rename to packages/website/src/components/lib/jsonSchema.ts index 0f0c20a32429..b369d42e5c2c 100644 --- a/packages/website/src/components/editor/config.ts +++ b/packages/website/src/components/lib/jsonSchema.ts @@ -1,41 +1,9 @@ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'; import type * as ts from 'typescript'; -import { getTypescriptOptions } from '../config/utils'; import type { WebLinter } from '../linter/WebLinter'; -export function createCompilerOptions( - tsConfig: Record = {}, -): ts.CompilerOptions { - const config = window.ts.convertCompilerOptionsFromJson( - { - // ts and monaco has different type as monaco types are not changing base on ts version - target: 'esnext', - module: 'esnext', - jsx: 'preserve', - ...tsConfig, - allowJs: true, - lib: Array.isArray(tsConfig.lib) ? tsConfig.lib : undefined, - moduleResolution: undefined, - plugins: undefined, - typeRoots: undefined, - paths: undefined, - moduleDetection: undefined, - baseUrl: undefined, - }, - '/tsconfig.json', - ); - - const options = config.options; - - if (!options.lib) { - options.lib = [window.ts.getDefaultLibFileName(options)]; - } - - return options; -} - -export function getEslintSchema(linter: WebLinter): JSONSchema4 { +export function getEslintJsonSchema(linter: WebLinter): JSONSchema4 { const properties: Record = {}; for (const [, item] of linter.rules) { @@ -83,7 +51,38 @@ export function getEslintSchema(linter: WebLinter): JSONSchema4 { }; } -export function getTsConfigSchema(): JSONSchema4 { +export function getTypescriptOptions(): ts.OptionDeclarations[] { + const allowedCategories = [ + 'Command-line Options', + 'Projects', + 'Compiler Diagnostics', + 'Editor Support', + 'Output Formatting', + 'Watch and Build Modes', + 'Source Map Options', + ]; + + const filteredNames = [ + 'moduleResolution', + 'moduleDetection', + 'plugins', + 'typeRoots', + 'jsx', + ]; + + return window.ts.optionDeclarations.filter( + item => + (item.type === 'boolean' || + item.type === 'list' || + item.type instanceof Map) && + item.description && + item.category && + !allowedCategories.includes(item.category.message) && + !filteredNames.includes(item.name), + ); +} + +export function getTypescriptJsonSchema(): JSONSchema4 { const properties = getTypescriptOptions().reduce((options, item) => { if (item.type === 'boolean') { options[item.name] = { diff --git a/packages/website/src/components/linter/WebLinter.ts b/packages/website/src/components/linter/WebLinter.ts index 1c07098a6d86..427710a0c0bb 100644 --- a/packages/website/src/components/linter/WebLinter.ts +++ b/packages/website/src/components/linter/WebLinter.ts @@ -3,8 +3,8 @@ import type { TSESLint } from '@typescript-eslint/utils'; import type * as ts from 'typescript'; import { parseESLintRC, parseTSConfig } from '../config/utils'; -import { createCompilerOptions } from '../editor/config'; -import { createEventHelper } from '../lib/createEventHelper'; +import { createCompilerOptions } from '../lib/createCompilerOptions'; +import { createEventsBinder } from '../lib/createEventsBinder'; import { defaultEslintConfig, PARSER_NAME } from './config'; import { createParser } from './createParser'; import type { @@ -15,8 +15,8 @@ import type { } from './types'; export class WebLinter { - readonly onLint = createEventHelper(); - readonly onParse = createEventHelper(); + readonly onLint = createEventsBinder(); + readonly onParse = createEventsBinder(); readonly rules = new Map< string, { name: string; description?: string; url?: string } diff --git a/packages/website/src/components/linter/bridge.ts b/packages/website/src/components/linter/bridge.ts index b17d516ca0e2..b0614fc4329d 100644 --- a/packages/website/src/components/linter/bridge.ts +++ b/packages/website/src/components/linter/bridge.ts @@ -37,10 +37,7 @@ export function createFileSystem( return { close: (): void => { - const handle = fileWatcherCallbacks.get(expPath); - if (handle) { - handle.delete(cb); - } + fileWatcherCallbacks.get(expPath)?.delete(cb); }, }; }; 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; } } diff --git a/packages/website/typings/typescript.d.ts b/packages/website/typings/typescript.d.ts index 7239e4ddcb5f..96ffc2a4d3cd 100644 --- a/packages/website/typings/typescript.d.ts +++ b/packages/website/typings/typescript.d.ts @@ -10,4 +10,16 @@ declare module 'typescript' { * The value is the file name */ const libMap: StringMap; + + interface OptionDeclarations { + name: string; + type?: unknown; + category?: { message: string }; + description?: { message: string }; + element?: { + type: unknown; + }; + } + + const optionDeclarations: OptionDeclarations[]; } From 6f897b5fe39427ea0499bfdc8aa689e0bd466b7c Mon Sep 17 00:00:00 2001 From: Armano Date: Mon, 3 Apr 2023 16:24:22 +0200 Subject: [PATCH 5/7] fix: revert conversion from createLinter to class --- .../src/components/editor/LoadedEditor.tsx | 4 +- .../components/editor/useSandboxServices.ts | 4 +- .../src/components/linter/WebLinter.ts | 172 +++++++++--------- 3 files changed, 95 insertions(+), 85 deletions(-) diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index 6da67570fc1f..f1c15e67471f 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -122,7 +122,7 @@ export const LoadedEditor: React.FC = ({ }, [activeTab, editor, tabs, updateMarkers]); useEffect(() => { - const disposable = webLinter.onLint.register((uri, messages) => { + const disposable = webLinter.onLint((uri, messages) => { const diagnostics = parseLintResults(messages, codeActions, ruleId => monaco.Uri.parse(webLinter.rules.get(ruleId)?.url ?? ''), ); @@ -137,7 +137,7 @@ export const LoadedEditor: React.FC = ({ }, [webLinter, monaco, codeActions, updateMarkers]); useEffect(() => { - const disposable = webLinter.onParse.register((uri, model) => { + const disposable = webLinter.onParse((uri, model) => { onEsASTChange(model.storedAST); onScopeChange(model.storedScope as Record | undefined); onTsASTChange(model.storedTsAST); diff --git a/packages/website/src/components/editor/useSandboxServices.ts b/packages/website/src/components/editor/useSandboxServices.ts index 6578eefe0928..9cb74ca1edcd 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -6,7 +6,7 @@ import type { createTypeScriptSandbox } from '../../vendor/sandbox'; import { createCompilerOptions } from '../lib/createCompilerOptions'; import { createFileSystem } from '../linter/bridge'; import type { PlaygroundSystem } from '../linter/types'; -import { WebLinter } from '../linter/WebLinter'; +import { createLinter, type WebLinter } from '../linter/WebLinter'; import type { RuleDetails } from '../types'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox'; @@ -82,7 +82,7 @@ export const useSandboxServices = ( window.system = system; window.esquery = lintUtils.esquery; - const webLinter = new WebLinter( + const webLinter = createLinter( system, lintUtils, sandboxInstance.tsvfs, diff --git a/packages/website/src/components/linter/WebLinter.ts b/packages/website/src/components/linter/WebLinter.ts index 427710a0c0bb..01f96e8301d6 100644 --- a/packages/website/src/components/linter/WebLinter.ts +++ b/packages/website/src/components/linter/WebLinter.ts @@ -14,97 +14,90 @@ import type { WebLinterModule, } from './types'; -export class WebLinter { - readonly onLint = createEventsBinder(); - readonly onParse = createEventsBinder(); - readonly rules = new Map< - string, - { name: string; description?: string; url?: string } - >(); - readonly configs: string[] = []; - - readonly #configMap = new Map(); - #compilerOptions: ts.CompilerOptions = {}; - #eslintConfig: TSESLint.Linter.Config = { ...defaultEslintConfig }; - readonly #linter: TSESLint.Linter; - readonly #parser: ReturnType; - readonly #system: PlaygroundSystem; - - constructor( - system: PlaygroundSystem, - webLinterModule: WebLinterModule, - vfs: typeof tsvfs, - ) { - this.#configMap = new Map(Object.entries(webLinterModule.configs)); - this.#linter = webLinterModule.createLinter(); - this.#system = system; - this.configs = Array.from(this.#configMap.keys()); - - this.#parser = createParser( - system, - this.#compilerOptions, - (filename, model): void => { - this.onParse.trigger(filename, model); - }, - webLinterModule, - vfs, - ); - - this.#linter.defineParser(PARSER_NAME, this.#parser); - - this.#linter.getRules().forEach((item, name) => { - this.rules.set(name, { - name: name, - description: item.meta?.docs?.description, - url: item.meta?.docs?.url, - }); - }); +export interface WebLinter { + 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, +): WebLinter { + const rules: WebLinter['rules'] = new Map(); + const configs = new Map(Object.entries(webLinterModule.configs)); + let compilerOptions: ts.CompilerOptions = {}; + const eslintConfig = { ...defaultEslintConfig }; + + const onLint = createEventsBinder(); + const onParse = createEventsBinder(); + + const linter = webLinterModule.createLinter(); - system.watchFile('/input.*', this.triggerLint); - system.watchFile('/.eslintrc', this.#applyEslintConfig); - system.watchFile('/tsconfig.json', this.#applyTSConfig); + const parser = createParser( + system, + compilerOptions, + (filename, model): void => { + onParse.trigger(filename, model); + }, + webLinterModule, + vfs, + ); - this.#applyEslintConfig('/.eslintrc'); - this.#applyTSConfig('/tsconfig.json'); - } + linter.defineParser(PARSER_NAME, parser); - triggerLint(filename: string): void { + 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 = this.#system.readFile(filename) ?? '\n'; + const code = system.readFile(filename) ?? '\n'; if (code != null) { - const messages = this.#linter.verify(code, this.#eslintConfig, filename); - this.onLint.trigger(filename, messages); + const messages = linter.verify(code, eslintConfig, filename); + onLint.trigger(filename, messages); } - } + }; - triggerFix(filename: string): TSESLint.Linter.FixReport | undefined { - const code = this.#system.readFile(filename); + const triggerFix = ( + filename: string, + ): TSESLint.Linter.FixReport | undefined => { + const code = system.readFile(filename); if (code) { - return this.#linter.verifyAndFix(code, this.#eslintConfig, { + return linter.verifyAndFix(code, eslintConfig, { filename: filename, fix: true, }); } return undefined; - } + }; - updateParserOptions(sourceType?: TSESLint.SourceType): void { - this.#eslintConfig.parserOptions ??= {}; - this.#eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; - } + const updateParserOptions = (sourceType?: TSESLint.SourceType): void => { + eslintConfig.parserOptions ??= {}; + eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; + }; - #resolveEslintConfig( + const resolveEslintConfig = ( cfg: Partial, - ): TSESLint.Linter.Config { + ): 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 = this.#configMap.get(extendsName); + const maybeConfig = configs.get(extendsName); if (maybeConfig) { - const resolved = this.#resolveEslintConfig(maybeConfig); + const resolved = resolveEslintConfig(maybeConfig); if (resolved.rules) { Object.assign(config.rules, resolved.rules); } @@ -115,28 +108,45 @@ export class WebLinter { Object.assign(config.rules, cfg.rules); } return config; - } + }; - #applyEslintConfig(fileName: string): void { + const applyEslintConfig = (fileName: string): void => { try { - const file = this.#system.readFile(fileName) ?? '{}'; - const parsed = this.#resolveEslintConfig(parseESLintRC(file)); - this.#eslintConfig.rules = parsed.rules; - console.log('[Editor] Updating', fileName, this.#eslintConfig); + 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); } - } + }; - #applyTSConfig(fileName: string): void { + const applyTSConfig = (fileName: string): void => { try { - const file = this.#system.readFile(fileName) ?? '{}'; + const file = system.readFile(fileName) ?? '{}'; const parsed = parseTSConfig(file).compilerOptions; - this.#compilerOptions = createCompilerOptions(parsed); - console.log('[Editor] Updating', fileName, this.#compilerOptions); - this.#parser.updateConfig(this.#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, + }; } From 223b9477738195f5ad341864592a2229c5123f62 Mon Sep 17 00:00:00 2001 From: Armano Date: Mon, 3 Apr 2023 16:31:40 +0200 Subject: [PATCH 6/7] fix: revert conversion from createLinter to class part 2 --- .../website/src/components/editor/useSandboxServices.ts | 4 ++-- packages/website/src/components/lib/jsonSchema.ts | 4 ++-- .../src/components/linter/{WebLinter.ts => createLinter.ts} | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) rename packages/website/src/components/linter/{WebLinter.ts => createLinter.ts} (97%) diff --git a/packages/website/src/components/editor/useSandboxServices.ts b/packages/website/src/components/editor/useSandboxServices.ts index 9cb74ca1edcd..f815ff5256cd 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -6,7 +6,7 @@ import type { createTypeScriptSandbox } from '../../vendor/sandbox'; import { createCompilerOptions } from '../lib/createCompilerOptions'; import { createFileSystem } from '../linter/bridge'; import type { PlaygroundSystem } from '../linter/types'; -import { createLinter, type WebLinter } from '../linter/WebLinter'; +import { createLinter, type CreateLinter } from '../linter/createLinter'; import type { RuleDetails } from '../types'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox'; @@ -25,7 +25,7 @@ export type SandboxInstance = ReturnType; export interface SandboxServices { sandboxInstance: SandboxInstance; system: PlaygroundSystem; - webLinter: WebLinter; + webLinter: CreateLinter; } export const useSandboxServices = ( diff --git a/packages/website/src/components/lib/jsonSchema.ts b/packages/website/src/components/lib/jsonSchema.ts index b369d42e5c2c..ee561c114954 100644 --- a/packages/website/src/components/lib/jsonSchema.ts +++ b/packages/website/src/components/lib/jsonSchema.ts @@ -1,9 +1,9 @@ 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'; -export function getEslintJsonSchema(linter: WebLinter): JSONSchema4 { +export function getEslintJsonSchema(linter: CreateLinter): JSONSchema4 { const properties: Record = {}; for (const [, item] of linter.rules) { diff --git a/packages/website/src/components/linter/WebLinter.ts b/packages/website/src/components/linter/createLinter.ts similarity index 97% rename from packages/website/src/components/linter/WebLinter.ts rename to packages/website/src/components/linter/createLinter.ts index 01f96e8301d6..f4dc5b492679 100644 --- a/packages/website/src/components/linter/WebLinter.ts +++ b/packages/website/src/components/linter/createLinter.ts @@ -14,7 +14,7 @@ import type { WebLinterModule, } from './types'; -export interface WebLinter { +export interface CreateLinter { rules: Map; configs: string[]; triggerFix(filename: string): TSESLint.Linter.FixReport | undefined; @@ -28,8 +28,8 @@ export function createLinter( system: PlaygroundSystem, webLinterModule: WebLinterModule, vfs: typeof tsvfs, -): WebLinter { - const rules: WebLinter['rules'] = new Map(); +): CreateLinter { + const rules: CreateLinter['rules'] = new Map(); const configs = new Map(Object.entries(webLinterModule.configs)); let compilerOptions: ts.CompilerOptions = {}; const eslintConfig = { ...defaultEslintConfig }; From 403d8a1301bd426b0935f4ac152bcd1307b21662 Mon Sep 17 00:00:00 2001 From: Armano Date: Mon, 3 Apr 2023 17:40:45 +0200 Subject: [PATCH 7/7] fix: correct linting issue --- packages/website/src/components/editor/useSandboxServices.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/components/editor/useSandboxServices.ts b/packages/website/src/components/editor/useSandboxServices.ts index f815ff5256cd..cbfcf0afba0b 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -5,8 +5,8 @@ import { useEffect, useState } from 'react'; import type { createTypeScriptSandbox } from '../../vendor/sandbox'; import { createCompilerOptions } from '../lib/createCompilerOptions'; import { createFileSystem } from '../linter/bridge'; +import { type CreateLinter, createLinter } from '../linter/createLinter'; import type { PlaygroundSystem } from '../linter/types'; -import { createLinter, type CreateLinter } from '../linter/createLinter'; import type { RuleDetails } from '../types'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox';