diff --git a/eslint.config.ts b/eslint.config.ts index 29016ed808..91adc06e19 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -570,8 +570,6 @@ export default defineConfig([ 'no-redeclare': [0], // must be disabled for typescript overloads 'no-regex-spaces': [2], 'no-restricted-exports': [0], - 'no-restricted-globals': [2, ...restrictedGlobals], - 'no-restricted-properties': [2, ...restrictedProperties], 'no-restricted-imports': [2, {paths: [ {name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true}, ]}], @@ -1022,5 +1020,9 @@ export default defineConfig([ { files: ['web_src/**/*'], languageOptions: {globals: {...globals.browser, ...globals.jquery}}, + rules: { + 'no-restricted-globals': [2, ...restrictedGlobals], + 'no-restricted-properties': [2, ...restrictedProperties], + }, }, ]); diff --git a/tools/generate-codemirror-languages.ts b/tools/generate-codemirror-languages.ts index 9461580197..05891298f6 100755 --- a/tools/generate-codemirror-languages.ts +++ b/tools/generate-codemirror-languages.ts @@ -44,7 +44,7 @@ type CmLanguage = { }; async function main() { - const res = await fetch(linguistUrl); // eslint-disable-line no-restricted-globals -- node build script, not browser code + const res = await fetch(linguistUrl); if (!res.ok) throw new Error(`fetch ${linguistUrl} failed: ${res.status}`); const linguist = parseYaml(await res.text()) as Record; diff --git a/web_src/js/modules/codeeditor/main.test.ts b/web_src/js/modules/codeeditor/main.test.ts new file mode 100644 index 0000000000..40a3590d35 --- /dev/null +++ b/web_src/js/modules/codeeditor/main.test.ts @@ -0,0 +1,84 @@ +import {buildLanguageDescriptions, importCodemirror} from './main.ts'; + +test('matchFilename — language detection covers extended rules', async () => { + const cm = await importCodemirror(); + const list = buildLanguageDescriptions(cm); + const match = (filename: string) => + cm.language.LanguageDescription.matchFilename(list, filename)?.name; + + expect(match('.bashrc')).toBe('Shell'); + expect(match('.zshrc')).toBe('Shell'); + expect(match('.envrc')).toBe('Shell'); + expect(match('foo.zsh')).toBe('Shell'); + expect(match('foo.bats')).toBe('Shell'); + expect(match('PKGBUILD')).toBe('Shell'); + + expect(match('.gitconfig')).toBe('Properties files'); + expect(match('.editorconfig')).toBe('Properties files'); + expect(match('.npmrc')).toBe('Properties files'); + expect(match('foo.cfg')).toBe('Properties files'); + expect(match('foo.conf')).toBe('Properties files'); + expect(match('nginx.conf')).toBe('Nginx'); + + expect(match('Cargo.lock')).toBe('TOML'); + expect(match('Pipfile')).toBe('TOML'); + expect(match('poetry.lock')).toBe('TOML'); + + expect(match('Containerfile')).toBe('Dockerfile'); + expect(match('Containerfile.test')).toBe('Dockerfile'); + expect(match('Dockerfile')).toBe('Dockerfile'); + expect(match('Dockerfile.dev')).toBe('Dockerfile'); + + expect(match('Brewfile')).toBe('Ruby'); + expect(match('Vagrantfile')).toBe('Ruby'); + expect(match('Gemfile')).toBe('Ruby'); + expect(match('foo.gemspec')).toBe('Ruby'); + expect(match('foo.rake')).toBe('Ruby'); + expect(match('foo.ru')).toBe('Ruby'); + + expect(match('foo.psgi')).toBe('Perl'); + expect(match('foo.pyi')).toBe('Python'); + expect(match('Snakefile')).toBe('Python'); + + expect(match('foo.webmanifest')).toBe('JSON'); + expect(match('foo.geojson')).toBe('JSON'); + expect(match('composer.lock')).toBe('JSON'); + expect(match('bun.lock')).toBe('JSON'); + + expect(match('foo.tcc')).toBe('C++'); + expect(match('foo.tpp')).toBe('C++'); + expect(match('foo.cppm')).toBe('C++'); + expect(match('foo.ixx')).toBe('C++'); + + expect(match('foo.xhtml')).toBe('HTML'); + expect(match('foo.jsh')).toBe('Java'); + expect(match('.Rprofile')).toBe('R'); + + expect(match('Makefile')).toBe('Makefile'); + expect(match('Makefile.am')).toBe('Makefile'); + expect(match('BSDmakefile')).toBe('Makefile'); + expect(match('GNUmakefile')).toBe('Makefile'); + expect(match('foo.mk')).toBe('Makefile'); + + expect(match('.env')).toBe('Dotenv'); + expect(match('.env.local')).toBe('Dotenv'); + + expect(match('foo.md')).toBe('Markdown'); + expect(match('foo.mdown')).toBe('Markdown'); + expect(match('foo.json5')).toBe('JSON5'); + expect(match('tsconfig.json')).toBe('JSON'); + + // Smoke tests for languages that already worked, to guard against regressions. + expect(match('foo.go')).toBe('Go'); + expect(match('foo.rs')).toBe('Rust'); + expect(match('foo.ts')).toBe('TypeScript'); + expect(match('foo.py')).toBe('Python'); + expect(match('foo.html')).toBe('HTML'); + expect(match('foo.css')).toBe('CSS'); + expect(match('foo.lua')).toBe('Lua'); + + // Genuinely ambiguous extensions left unhighlighted on purpose. + expect(match('foo.cgi')).toBeUndefined(); + expect(match('foo.fcgi')).toBeUndefined(); + expect(match('foo.fish')).toBeUndefined(); +}); diff --git a/web_src/js/modules/codeeditor/main.ts b/web_src/js/modules/codeeditor/main.ts index e703203038..be8992ce1d 100644 --- a/web_src/js/modules/codeeditor/main.ts +++ b/web_src/js/modules/codeeditor/main.ts @@ -44,7 +44,7 @@ export type CodemirrorEditor = { export type CodemirrorModules = Awaited>; -async function importCodemirror() { +export async function importCodemirror() { const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([ import('@codemirror/autocomplete'), import('@codemirror/commands'), @@ -73,6 +73,48 @@ const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const filenameUnion = (filenames: string[]) => filenames.length ? new RegExp(`^(${filenames.map(escapeRegex).join('|')})$`) : undefined; +export function buildLanguageDescriptions(cm: CodemirrorModules): LanguageDescription[] { + const markdown = linguistLanguages.find((l) => l.name === 'Markdown'); + const dockerfile = linguistLanguages.find((l) => l.name === 'Dockerfile'); + const list: LanguageDescription[] = [ + ...buildBaseLanguages(cm), + cm.language.LanguageDescription.of({ + name: 'Markdown', extensions: markdown?.extensions ?? ['md', 'markdown', 'mkd'], + load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: list}), + }), + cm.language.LanguageDescription.of({ + name: 'Dockerfile', extensions: dockerfile?.extensions ?? ['dockerfile', 'containerfile'], + filename: /^(Containerfile|Dockerfile)(\..+)?$/i, + load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/dockerfile')).dockerFile)), + }), + cm.language.LanguageDescription.of({ + name: 'Elixir', extensions: ['ex', 'exs'], + load: async () => (await import('codemirror-lang-elixir')).elixir(), + }), + cm.language.LanguageDescription.of({ + name: 'Nix', extensions: ['nix'], + load: async () => (await import('@replit/codemirror-lang-nix')).nix(), + }), + cm.language.LanguageDescription.of({ + name: 'Svelte', extensions: ['svelte'], + load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(), + }), + cm.language.LanguageDescription.of({ + name: 'Makefile', extensions: ['mk', 'mak', 'make'], filename: /^(GNU|BSD)?[Mm]akefile(\..+)?$/, + load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), + }), + cm.language.LanguageDescription.of({ + name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/, + load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), + }), + cm.language.LanguageDescription.of({ + name: 'JSON5', extensions: ['json5', 'jsonc'], + load: async () => (await import('@codemirror/lang-json')).json(), + }), + ]; + return list; +} + let baseLanguagesCache: LanguageDescription[] | null = null; function buildBaseLanguages(cm: CodemirrorModules): LanguageDescription[] { if (baseLanguagesCache) return baseLanguagesCache; @@ -119,45 +161,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn const previewableExts = new Set(config.previewableExtensions || []); const lineWrapExts = config.lineWrapExtensions || []; const cm = await importCodemirror(); - const markdown = linguistLanguages.find((l) => l.name === 'Markdown'); - const dockerfile = linguistLanguages.find((l) => l.name === 'Dockerfile'); - - const languageDescriptions: LanguageDescription[] = [ - ...buildBaseLanguages(cm), - cm.language.LanguageDescription.of({ - name: 'Markdown', extensions: markdown?.extensions ?? ['md', 'markdown', 'mkd'], - load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}), - }), - cm.language.LanguageDescription.of({ - name: 'Dockerfile', extensions: dockerfile?.extensions ?? ['dockerfile', 'containerfile'], - filename: /^(Containerfile|Dockerfile)(\..+)?$/i, - load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/dockerfile')).dockerFile)), - }), - cm.language.LanguageDescription.of({ - name: 'Elixir', extensions: ['ex', 'exs'], - load: async () => (await import('codemirror-lang-elixir')).elixir(), - }), - cm.language.LanguageDescription.of({ - name: 'Nix', extensions: ['nix'], - load: async () => (await import('@replit/codemirror-lang-nix')).nix(), - }), - cm.language.LanguageDescription.of({ - name: 'Svelte', extensions: ['svelte'], - load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(), - }), - cm.language.LanguageDescription.of({ - name: 'Makefile', extensions: ['mk', 'mak', 'make'], filename: /^(GNU|BSD)?[Mm]akefile(\..+)?$/, - load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), - }), - cm.language.LanguageDescription.of({ - name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/, - load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), - }), - cm.language.LanguageDescription.of({ - name: 'JSON5', extensions: ['json5', 'jsonc'], - load: async () => (await import('@codemirror/lang-json')).json(), - }), - ]; + const languageDescriptions = buildLanguageDescriptions(cm); const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename); const container = document.createElement('div');