This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import {clippie} from 'clippie';
|
||||
import {createTippy} from '../tippy.ts';
|
||||
import {copyToClipboard} from '../clipboard.ts';
|
||||
import {keySymbols} from '../../utils.ts';
|
||||
import {goToDefinitionAt} from './utils.ts';
|
||||
import type {Instance} from 'tippy.js';
|
||||
@@ -95,13 +95,13 @@ function buildMenuItems(cm: CodemirrorModules, view: EditorView, togglePalette:
|
||||
'separator',
|
||||
{label: 'Cut', keys: 'Mod+X', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
if (await clippie(v.state.doc.sliceString(from, to))) {
|
||||
if (await copyToClipboard(v.state.doc.sliceString(from, to))) {
|
||||
v.dispatch({changes: {from, to}});
|
||||
}
|
||||
}},
|
||||
{label: 'Copy', keys: 'Mod+C', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
await clippie(v.state.doc.sliceString(from, to));
|
||||
await copyToClipboard(v.state.doc.sliceString(from, to));
|
||||
}},
|
||||
{label: 'Paste', keys: 'Mod+V', run: async (view) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
|
||||
// Linguist-supplied filenames + extensions
|
||||
expect(match('.bashrc')).toBe('Shell');
|
||||
expect(match('PKGBUILD')).toBe('Shell');
|
||||
expect(match('foo.zsh')).toBe('Shell');
|
||||
expect(match('Cargo.lock')).toBe('TOML');
|
||||
expect(match('Gemfile')).toBe('Ruby');
|
||||
expect(match('foo.gemspec')).toBe('Ruby');
|
||||
expect(match('foo.psgi')).toBe('Perl');
|
||||
expect(match('foo.pyi')).toBe('Python');
|
||||
expect(match('foo.webmanifest')).toBe('JSON');
|
||||
expect(match('foo.tcc')).toBe('C++');
|
||||
|
||||
// Script-side extras (extraFilenames / extraExtensions)
|
||||
expect(match('.editorconfig')).toBe('Properties files');
|
||||
expect(match('foo.conf')).toBe('Properties files');
|
||||
expect(match('Snakefile')).toBe('Python');
|
||||
|
||||
// Custom Gitea entries override language-data
|
||||
expect(match('Containerfile.test')).toBe('Dockerfile');
|
||||
expect(match('Dockerfile.dev')).toBe('Dockerfile');
|
||||
expect(match('Makefile.am')).toBe('Makefile');
|
||||
expect(match('foo.mk')).toBe('Makefile');
|
||||
expect(match('.env.local')).toBe('Dotenv');
|
||||
expect(match('foo.json5')).toBe('JSON5');
|
||||
expect(match('foo.mdown')).toBe('Markdown');
|
||||
|
||||
// Filename regex wins over extension match
|
||||
expect(match('nginx.conf')).toBe('Nginx');
|
||||
|
||||
// .spec routes to RPM Spec via excludeExt redirect
|
||||
expect(match('foo.spec')).toBe('RPM Spec');
|
||||
|
||||
// CM original ownership preserved against Linguist's broader claims (.sql is SQL,
|
||||
// not PLSQL, even though Linguist's PLSQL extension list includes it).
|
||||
expect(match('foo.sql')).toBe('SQL');
|
||||
expect(match('foo.h')).toBe('C');
|
||||
expect(match('foo.mm')).toBe('Objective-C++');
|
||||
|
||||
// Globally ambiguous extensions fall through to plain text
|
||||
expect(match('foo.cgi')).toBeUndefined();
|
||||
expect(match('foo.inc')).toBeUndefined();
|
||||
|
||||
// Smoke: existing language-data entries still resolve
|
||||
expect(match('foo.go')).toBe('Go');
|
||||
expect(match('foo.tsx')).toBe('TSX');
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type {PaletteCommand} from './command-palette.ts';
|
||||
import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts';
|
||||
import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts';
|
||||
import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts';
|
||||
import type {LanguageDescription} from '@codemirror/language';
|
||||
import type {LanguageDescription, LanguageSupport} from '@codemirror/language';
|
||||
import type {Compartment, Extension} from '@codemirror/state';
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
|
||||
@@ -41,10 +41,12 @@ export type CodemirrorEditor = {
|
||||
};
|
||||
};
|
||||
|
||||
type LinguistLanguage = {name: string; extensions: string[]; filenames: string[]};
|
||||
|
||||
export type CodemirrorModules = Awaited<ReturnType<typeof importCodemirror>>;
|
||||
|
||||
async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([
|
||||
export async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguist] = await Promise.all([
|
||||
import('@codemirror/autocomplete'),
|
||||
import('@codemirror/commands'),
|
||||
import('@codemirror/language'),
|
||||
@@ -56,8 +58,77 @@ async function importCodemirror() {
|
||||
import('@lezer/highlight'),
|
||||
import('@replit/codemirror-indentation-markers'),
|
||||
import('@replit/codemirror-vscode-keymap'),
|
||||
import('../../../../assets/codemirror-languages.json'),
|
||||
]);
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap};
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguistLanguages: linguist.default as LinguistLanguage[]};
|
||||
}
|
||||
|
||||
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 list: LanguageDescription[] = [
|
||||
...buildBaseLanguages(cm),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd', 'mdown', 'mdwn', 'mkdn', 'mkdown'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: list}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: '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;
|
||||
}
|
||||
|
||||
// Languages that the JSON omits because they're constructed manually above.
|
||||
const customNames = new Set(['Dockerfile', 'Markdown']);
|
||||
|
||||
let baseLanguagesCache: LanguageDescription[] | null = null;
|
||||
function buildBaseLanguages(cm: CodemirrorModules): LanguageDescription[] {
|
||||
if (baseLanguagesCache) return baseLanguagesCache;
|
||||
const loadByName = new Map<string, LanguageDescription['load']>(
|
||||
cm.languageData.languages.map((l: LanguageDescription) => [l.name, l.load.bind(l)]),
|
||||
);
|
||||
const overrides = cm.linguistLanguages
|
||||
.filter((l) => loadByName.has(l.name))
|
||||
.map((l) => cm.language.LanguageDescription.of({
|
||||
name: l.name,
|
||||
extensions: l.extensions,
|
||||
filename: filenameUnion(l.filenames),
|
||||
load: loadByName.get(l.name)!,
|
||||
}));
|
||||
const overrideNames = new Set(overrides.map((o) => o.name));
|
||||
const fallback = cm.languageData.languages.filter(
|
||||
(l: LanguageDescription) => !overrideNames.has(l.name) && !customNames.has(l.name),
|
||||
);
|
||||
return baseLanguagesCache = [...overrides, ...fallback];
|
||||
}
|
||||
|
||||
function togglePreviewDisplay(previewable: boolean): void {
|
||||
@@ -85,38 +156,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
const previewableExts = new Set(config.previewableExtensions || []);
|
||||
const lineWrapExts = config.lineWrapExtensions || [];
|
||||
const cm = await importCodemirror();
|
||||
|
||||
const languageDescriptions: LanguageDescription[] = [
|
||||
...cm.languageData.languages.filter((l: LanguageDescription) => l.name !== 'Markdown'),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}),
|
||||
}),
|
||||
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', filename: /^(GNUm|M|m)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');
|
||||
@@ -163,9 +203,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
},
|
||||
}),
|
||||
cm.language.foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13));
|
||||
},
|
||||
markerDOM: (open: boolean) => createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13)),
|
||||
}),
|
||||
cm.view.highlightActiveLineGutter(),
|
||||
cm.view.highlightSpecialChars(),
|
||||
@@ -295,16 +333,19 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
return editor;
|
||||
}
|
||||
|
||||
// files that are JSONC despite having a .json extension
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/;
|
||||
// files that the JSON parser is too strict for (comments, trailing commas)
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$|\.(jsonc|json5)$/i;
|
||||
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise<Extension> {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (ext === '.json' || ext === '.map') {
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: LanguageSupport | null): Promise<Extension> {
|
||||
if (!loadedLang) return [];
|
||||
const lang = loadedLang.language;
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (lang instanceof cm.language.StreamLanguage) return [];
|
||||
if (lang.name === 'json') {
|
||||
return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
|
||||
}
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (!loadedLang || loadedLang.language instanceof cm.language.StreamLanguage) return [];
|
||||
// markdown's parser emits no error nodes, and nested code-fence overlays aren't traversed
|
||||
if (lang.name === 'markdown') return [];
|
||||
return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,4 @@
|
||||
import {findUrlAtPosition, trimUrlPunctuation, urlRawRegex} from './utils.ts';
|
||||
|
||||
function matchUrls(text: string): string[] {
|
||||
return Array.from(text.matchAll(urlRawRegex), (m) => trimUrlPunctuation(m[0]));
|
||||
}
|
||||
|
||||
test('matchUrls', () => {
|
||||
expect(matchUrls('visit https://example.com for info')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com.')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com, and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com; and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('(https://example.com)')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('"https://example.com"')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash.')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://x.co')).toEqual(['https://x.co']);
|
||||
expect(matchUrls('https://example.com/path_(wiki)')).toEqual(['https://example.com/path_(wiki)']);
|
||||
expect(matchUrls('https://en.wikipedia.org/wiki/Rust_(programming_language)')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('(https://en.wikipedia.org/wiki/Rust_(programming_language))')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('http://example.com')).toEqual(['http://example.com']);
|
||||
expect(matchUrls('no url here')).toEqual([]);
|
||||
expect(matchUrls('https://a.com and https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
||||
expect(matchUrls('[](https://www.npmjs.org/package/pkg)')).toEqual(['https://img.shields.io/npm/v/pkg.svg?style=flat', 'https://www.npmjs.org/package/pkg']);
|
||||
});
|
||||
|
||||
test('trimUrlPunctuation', () => {
|
||||
expect(trimUrlPunctuation('https://example.com.')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com,')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com:')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation("https://example.com'")).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com"')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com.,;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com/path')).toEqual('https://example.com/path');
|
||||
expect(trimUrlPunctuation('https://example.com/path_(wiki)')).toEqual('https://example.com/path_(wiki)');
|
||||
expect(trimUrlPunctuation('https://example.com)')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://en.wikipedia.org/wiki/Rust_(lang))')).toEqual('https://en.wikipedia.org/wiki/Rust_(lang)');
|
||||
});
|
||||
import {findUrlAtPosition} from './utils.ts';
|
||||
|
||||
test('findUrlAtPosition', () => {
|
||||
const doc = 'visit https://example.com for info';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
import type {CodemirrorModules} from './main.ts';
|
||||
import {trimUrlPunctuation, urlRawRegex} from '../../utils/url.ts';
|
||||
|
||||
/** Remove trailing whitespace from all lines in the editor. */
|
||||
export function trimTrailingWhitespaceFromView(view: EditorView): void {
|
||||
@@ -15,22 +16,9 @@ export function trimTrailingWhitespaceFromView(view: EditorView): void {
|
||||
if (changes.length) view.dispatch({changes});
|
||||
}
|
||||
|
||||
/** Matches URLs, excluding characters that are never valid unencoded in URLs per RFC 3986. */
|
||||
export const urlRawRegex = /\bhttps?:\/\/[^\s<>[\]]+/gi;
|
||||
|
||||
/** Strip trailing punctuation that is likely not part of the URL. */
|
||||
export function trimUrlPunctuation(url: string): string {
|
||||
url = url.replace(/[.,;:'"]+$/, '');
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links)
|
||||
while (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Find the URL at the given character position in a document string, or null if none. */
|
||||
export function findUrlAtPosition(doc: string, pos: number): string | null {
|
||||
for (const match of doc.matchAll(urlRawRegex)) {
|
||||
for (const match of doc.matchAll(urlRawRegex())) {
|
||||
const url = trimUrlPunctuation(match[0]);
|
||||
if (match.index !== undefined && pos >= match.index && pos < match.index + url.length) {
|
||||
return url;
|
||||
@@ -67,7 +55,7 @@ export function goToDefinitionAt(cm: CodemirrorModules, view: EditorView, pos: n
|
||||
export function clickableUrls(cm: CodemirrorModules) {
|
||||
const urlMark = cm.view.Decoration.mark({class: 'cm-url'});
|
||||
const urlDecorator = new cm.view.MatchDecorator({
|
||||
regexp: urlRawRegex,
|
||||
regexp: urlRawRegex(),
|
||||
decorate: (add, from, _to, match) => {
|
||||
const trimmed = trimUrlPunctuation(match[0]);
|
||||
add(from, from + trimmed.length, urlMark);
|
||||
|
||||
Reference in New Issue
Block a user