UNPKG

@bhsd/codemirror-mediawiki

Version:

Modified CodeMirror mode based on wikimedia/mediawiki-extensions-CodeMirror

514 lines (513 loc) 23.2 kB
/** * @author MusikAnimal, Bhsd and others * @license GPL-2.0-or-later * @see https://gerrit.wikimedia.org/g/mediawiki/extensions/CodeMirror */ import { HighlightStyle, LanguageSupport, StreamLanguage, syntaxHighlighting, syntaxTree, } from '@codemirror/language'; import { insertCompletionText, pickedCompletion } from '@codemirror/autocomplete'; import { wmf } from '@bhsd/common'; import { commonHtmlAttrs, htmlAttrs, extAttrs } from 'wikiparser-node/dist/util/sharable.mjs'; import { MediaWiki } from './token'; import { htmlTags, tokens } from './config'; import { braceStackUpdate } from './fold'; import { EditorView } from '@codemirror/view'; export const re = /* @__PURE__ */ (() => new RegExp(String.raw `\.(?:${wmf})\.org$`, 'u'))(); /** * 检查首字母大小写并插入正确的自动填充内容 * @param view * @param completion 自动填充内容 * @param from 起始位置 * @param to 结束位置 */ const apply = (view, completion, from, to) => { let { label } = completion; const initial = label.charAt(0).toLowerCase(); if (view.state.sliceDoc(from, from + 1) === initial) { label = initial + label.slice(1); } view.dispatch({ ...insertCompletionText(view.state, label, from, to), annotations: pickedCompletion.of(completion), }); }; /** * 判断节点是否包含指定类型 * @param types 节点类型 * @param names 指定类型 */ export const hasTag = (types, names) => (Array.isArray(names) ? names : [names]).some(name => types.has(name in tokens ? tokens[name] : name)); export class FullMediaWiki extends MediaWiki { constructor(config) { super(config); const { urlProtocols, nsid, functionSynonyms, doubleUnderscore, } = config; this.nsRegex = new RegExp(String.raw `^(${Object.keys(nsid).filter(Boolean).join('|').replace(/_/gu, ' ')})\s*:\s*`, 'iu'); this.functionSynonyms = functionSynonyms.flatMap((obj, i) => Object.keys(obj).map((label) => ({ type: i ? 'constant' : 'function', label, }))); this.doubleUnderscore = doubleUnderscore.flatMap(Object.keys).map((label) => ({ type: 'constant', label, })); this.extTags = this.tags.map((label) => ({ type: 'type', label })); this.htmlTags = htmlTags.filter(tag => !this.tags.includes(tag)).map((label) => ({ type: 'type', label, })); this.protocols = urlProtocols.split('|').map((label) => ({ type: 'namespace', label: label.replace(/\\\//gu, '/'), })); this.imgKeys = this.img.map((label) => label.endsWith('$1') ? { type: 'property', label: label.slice(0, -2), detail: '$1' } : { type: 'keyword', label }); this.htmlAttrs = [ ...[...commonHtmlAttrs].map((label) => ({ type: 'property', label })), { type: 'variable', label: 'data-', detail: '*' }, { type: 'namespace', label: 'xmlns:', detail: '*' }, ]; this.elementAttrs = new Map(Object.entries(htmlAttrs).map(([key, value]) => [ key, [...value].map((label) => ({ type: 'property', label })), ])); this.extAttrs = new Map(Object.entries(extAttrs).map(([key, value]) => [ key, [...value].map((label) => ({ type: 'property', label })), ])); } /** * This defines the actual CSS class assigned to each tag/token. * * @see https://codemirror.net/docs/ref/#language.TagStyle */ getTagStyles() { return Object.keys(this.tokenTable).map((className) => ({ tag: this.tokenTable[className], class: `cm-${className}`, })); } mediawiki(tags) { const parser = super.mediawiki(tags); parser.languageData = { closeBrackets: { brackets: ['(', '[', '{', '"'], before: ')]}>' }, autocomplete: this.completionSource, }; return parser; } /** * 提供链接建议 * @param str 搜索字符串,开头不包含` `,但可能包含`_` * @param ns 命名空间 */ async #linkSuggest(str, ns = 0) { const { config: { linkSuggest, nsid }, nsRegex } = this; if (typeof linkSuggest !== 'function' || /[|{}<>[\]#]/u.test(str)) { return undefined; } let subpage = false, search = str, offset = 0; /* eslint-disable no-param-reassign */ if (search.startsWith('/')) { ns = 0; subpage = true; } else { search = search.replace(/_/gu, ' '); const mt = /^\s*/u.exec(search); [{ length: offset }] = mt; search = search.slice(offset); if (search.startsWith(':')) { const [{ length }] = /^:\s*/u.exec(search); offset += length; search = search.slice(length); ns = 0; } if (!search) { return undefined; } const mt2 = nsRegex.exec(search); if (mt2) { const [{ length }, prefix] = mt2; ns = nsid[prefix.replace(/ /gu, '_').toLowerCase()] || 1; offset += length; search = `${ns === -2 ? 'File' : prefix}:${search.slice(length)}`; } } /* eslint-enable no-param-reassign */ const underscore = str.slice(offset).includes('_'); return { offset, options: (await linkSuggest(search, ns, subpage)).map(([label]) => ({ type: 'text', label: underscore ? label.replace(/ /gu, '_') : label, })), }; } /** * 提供模板参数建议 * @param search 搜索字符串 * @param page 模板名,可包含`_`、`:`等 * @param equal 是否有等号 */ async #paramSuggest(search, page, equal) { const { config: { paramSuggest } } = this; return page && typeof paramSuggest === 'function' && !/[|{}<>[\]]/u.test(page) ? { offset: /^\s*/u.exec(search)[0].length, options: (await paramSuggest(page)) .map(([key, detail]) => ({ type: 'variable', label: key + equal, detail })), } : undefined; } /** 自动补全魔术字和标签名 */ get completionSource() { return async (context) => { const { state, pos, explicit } = context, node = syntaxTree(state).resolve(pos, -1), types = new Set(node.name.split('_')), isParserFunction = hasTag(types, 'parserFunctionName'), /** 开头不包含` `,但可能包含`_` */ search = state.sliceDoc(node.from, pos).trimStart(), start = pos - search.length, isWMF = re.test(location.hostname); let { prevSibling } = node; if (explicit || isParserFunction && search.includes('#') || isWMF) { const validFor = isWMF ? null : { validFor: /^[^|{}<>[\]#]*$/u }; if (isParserFunction || hasTag(types, 'templateName')) { const options = search.includes(':') ? [] : [...this.functionSynonyms], suggestions = await this.#linkSuggest(search, 10) ?? { offset: 0, options: [] }; options.push(...suggestions.options); return options.length === 0 ? null : { from: start + suggestions.offset, options, ...validFor, }; } else if (explicit && hasTag(types, 'templateBracket') && context.matchBefore(/\{\{$/u)) { return { from: pos, options: this.functionSynonyms, ...validFor, }; } const isPage = hasTag(types, 'pageName') && hasTag(types, 'parserFunction') || 0; if (isPage && search.trim() || hasTag(types, 'linkPageName')) { let prefix = ''; if (isPage) { prefix = this.autocompleteNamespaces[[...types].find(t => t.startsWith('mw-function-')) .slice(12)]; } const suggestions = await this.#linkSuggest(prefix + search); if (!suggestions) { return null; } else if (!isPage) { suggestions.options = suggestions.options.map((option) => ({ ...option, apply })); } else if (prefix === 'Module:') { suggestions.options = suggestions.options .filter(({ label }) => !label.endsWith('/doc')); } return { // eslint-disable-next-line unicorn/explicit-length-check from: start + suggestions.offset - (isPage && prefix.length), options: suggestions.options, ...validFor, }; } const isArgument = hasTag(types, 'templateArgumentName'), prevIsDelimiter = prevSibling?.name.includes(tokens.templateDelimiter), isDelimiter = hasTag(types, 'templateDelimiter') || hasTag(types, 'templateBracket') && prevIsDelimiter; if (this.tags.includes('templatedata') && (isDelimiter || isArgument && !search.includes('=') || hasTag(types, 'template') && prevIsDelimiter)) { let stack = -1, /** 可包含`_`、`:`等 */ page = ''; while (prevSibling) { const { name, from, to } = prevSibling; if (name.includes(tokens.templateBracket)) { const [lbrace, rbrace] = braceStackUpdate(state, prevSibling); stack += lbrace; if (stack >= 0) { break; } stack += rbrace; } else if (stack === -1 && name.includes(tokens.templateName)) { page = state.sliceDoc(from, to) + page; } else if (page && !name.includes(tokens.comment)) { prevSibling = null; break; } ({ prevSibling } = prevSibling); } if (prevSibling && page) { const equal = isArgument && state.sliceDoc(pos, node.to).trim() === '=' ? '' : '=', suggestions = await this.#paramSuggest(isDelimiter ? '' : search, page, equal); if (suggestions && suggestions.options.length > 0) { return { from: isDelimiter ? pos : start + suggestions.offset, options: suggestions.options, validFor: /^[^|{}=]*$/u, }; } } } } const isTagName = hasTag(types, ['htmlTagName', 'extTagName']), explicitMatch = explicit && context.matchBefore(/\s$/u), validForAttr = /^[a-z]*$/iu; if (isTagName && explicitMatch || hasTag(types, ['htmlTagAttribute', 'extTagAttribute', 'tableDefinition'])) { const tagName = isTagName ? search.trim() : /mw-(?:ext|html)-([a-z]+)/u.exec(node.name)[1], mt = explicitMatch || context.matchBefore(hasTag(types, 'tableDefinition') ? /[\s|-][a-z]+$/iu : /\s[a-z]+$/iu); return mt && (mt.from < start || /^\s/u.test(mt.text)) ? { from: mt.from + 1, options: [ ...tagName === 'meta' || tagName === 'link' || tagName in this.config.tags && !this.elementAttrs.has(tagName) ? [] : this.htmlAttrs, ...this.elementAttrs.get(tagName) ?? [], ...this.extAttrs.get(tagName) ?? [], ], validFor: validForAttr, } : null; } else if (explicit && hasTag(types, ['tableTd', 'tableTh', 'tableCaption'])) { const [, tagName] = /mw-table-([a-z]+)/u.exec(node.name), mt = context.matchBefore(/[\s|!+][a-z]*$/iu); if (mt && (mt.from < start || /^\s/u.test(mt.text))) { return { from: mt.from + 1, options: [ ...this.htmlAttrs, ...this.elementAttrs.get(tagName) ?? [], ], validFor: validForAttr, }; } } else if (hasTag(types, [ 'comment', 'templateVariableName', 'templateName', 'linkPageName', 'linkToSection', 'extLink', ])) { return null; } let mt = context.matchBefore(/__(?:(?!__)[\p{L}\p{N}_])*$/u); if (mt) { return { from: mt.from, options: this.doubleUnderscore, validFor: /^[\p{L}\p{N}]*$/u, }; } mt = context.matchBefore(/<\/?[a-z\d]*$/iu); const extTags = [...types].filter(t => t.startsWith('mw-tag-')) .map(s => s.slice(7)); if (mt && (explicit || mt.to - mt.from > 1)) { const validFor = /^[a-z\d]*$/iu; if (mt.text[1] === '/') { const mt2 = context .matchBefore(/<[a-z\d]+(?:\s[^<>]*)?>(?:(?!<\/?[a-z]).)*<\/[a-z\d]*$/iu), target = /^<([a-z\d]+)/iu.exec(mt2?.text ?? '')?.[1].toLowerCase(), extTag = extTags[extTags.length - 1], closed = /^\s*>/u.test(state.sliceDoc(pos)), options = [ ...this.htmlTags.filter(({ label }) => !this.voidHtmlTags.has(label)), ...extTag ? [{ type: 'type', label: extTag, boost: 50 }] : [], ], i = this.permittedHtmlTags.has(target) && options.findIndex(({ label }) => label === target); if (i !== false && i !== -1) { options.splice(i, 1, { type: 'type', label: target, boost: 99 }); } return { from: mt.from + 2, options: closed ? options : options.map((option) => ({ ...option, apply: `${option.label}>` })), validFor, }; } return { from: mt.from + 1, options: [ ...this.htmlTags, ...this.extTags.filter(({ label }) => !extTags.includes(label)), ], validFor, }; } const isDelimiter = explicit && hasTag(types, 'fileDelimiter'); if (isDelimiter || hasTag(types, 'fileText') && prevSibling?.name.includes(tokens.fileDelimiter) && !search.includes('[')) { const equal = state.sliceDoc(pos, pos + 1) === '='; return { from: isDelimiter ? pos : prevSibling.to, options: equal ? this.imgKeys.map((option) => ({ ...option, apply: option.label.replace(/=$/u, ''), })) : this.imgKeys, validFor: /^[^|{}<>[\]$]*$/u, }; } else if (!hasTag(types, ['linkText', 'extLinkText'])) { mt = context.matchBefore(/(?:^|[^[])\[[a-z:/]*$/iu); if (mt && (explicit || !mt.text.endsWith('['))) { return { from: mt.from + (mt.text[1] === '[' ? 2 : 1), options: this.protocols, validFor: /^[a-z:/]*$/iu, }; } } return null; }; } } const getSelector = (cls, prefix = '') => typeof prefix === 'string' ? cls.map(c => `.cm-mw-${prefix}${c}`).join() : prefix.map(p => getSelector(cls, p)).join(); const getGround = (type, ground) => ground ? `${type}${ground === 1 ? '' : ground}-` : ''; const getGrounds = (grounds, r, g, b, a) => ({ [grounds.map(([template, ext, link]) => `.cm-mw-${getGround('template', template)}${getGround('exttag', ext)}${getGround('link', link)}ground`).join()]: { backgroundColor: `rgb(${r},${g},${b},${a})`, }, }); /** * @author pastakhov and others * @license GPL-2.0-or-later * @see https://gerrit.wikimedia.org/g/mediawiki/extensions/CodeMirror */ const theme = /* @__PURE__ */ EditorView.theme({ [getSelector(['', '~*'], 'section--1')]: { fontSize: '1.8em', lineHeight: '1.2em', }, [getSelector(['', '~*'], 'section--2')]: { fontSize: '1.5em', lineHeight: '1.2em', }, [getSelector(['3~*', '4~*', '5~*', '6~*'], 'section--')]: { fontWeight: 'bold', }, [`${getSelector(['section-header', 'template', 'parserfunction', 'file-delimiter', 'magic-link'])},${getSelector(['pagename', 'bracket', 'delimiter'], 'link-')},${getSelector(['extlink'], ['', 'free-'])},${getSelector(['bracket', 'attribute'], ['exttag-', 'htmltag-'])},${getSelector(['delimiter2', 'definition'], 'table-')}`]: { fontWeight: 'normal', }, [`${getSelector(['redirect', 'list', 'free-extlink-protocol', 'strong'])},${getSelector(['protocol', 'bracket'], 'extlink-')},${getSelector(['tag-name'], ['ext', 'html'])},${getSelector(['bracket', 'delimiter', 'th', 'caption'], 'table-')},${getSelector(['bracket', 'delimiter'], 'convert-')}`]: { fontWeight: 'bold', }, [`${getSelector(['pagename', 'link-tosection', 'magic-link'])},${getSelector(['extlink', 'extlink-protocol'], ['', 'free-'])}`]: { textDecoration: 'underline', }, '.cm-mw-em': { fontStyle: 'italic', }, [getSelector(['section-header', 'redirect', 'list', 'apostrophes'])]: { color: 'var(--cm-hr)', }, '.cm-mw-error': { color: 'var(--cm-error)', }, '.cm-mw-skipformatting': { backgroundColor: 'var(--cm-sp)', }, [getSelector(['double-underscore', 'signature', 'hr'])]: { color: 'var(--cm-hr)', fontWeight: 'bold', backgroundColor: 'var(--cm-hr-bg)', }, [getSelector(['comment', 'ignored'])]: { color: 'var(--cm-comment)', fontWeight: 'normal', }, [getSelector(['name', 'delimiter', 'bracket'], 'template-')]: { color: 'var(--cm-tpl)', fontWeight: 'bold', }, '.cm-mw-template-argument-name': { color: 'var(--cm-arg)', fontWeight: 'normal', }, '.cm-mw-templatevariable': { color: 'var(--cm-var)', fontWeight: 'normal', }, [getSelector(['name', 'bracket', 'delimiter'], 'templatevariable-')]: { color: 'var(--cm-var-name)', fontWeight: 'bold', }, [getSelector(['name', 'bracket', 'delimiter'], 'parserfunction-')]: { color: 'var(--cm-func)', fontWeight: 'bold', }, [`${getSelector(['pagename', 'bracket', 'delimiter'], 'link-')},${getSelector(['file-delimiter', 'magic-link'])},${getSelector(['', '-protocol', '-bracket'], 'extlink')},${getSelector(['', '-protocol'], 'free-extlink')}`]: { color: 'var(--cm-link)', }, [getSelector(['image-parameter', 'link-tosection'])]: { color: 'var(--cm-sect)', fontWeight: 'normal', }, [getSelector(['name', 'bracket', 'attribute'], ['exttag-', 'htmltag-'])]: { color: 'var(--cm-tag)', }, [getSelector(['tag-attribute-value'], ['ext', 'html'])]: { color: 'var(--cm-attr)', fontWeight: 'normal', }, [getSelector(['bracket', 'delimiter', 'delimiter2', 'definition'], 'table-')]: { color: 'var(--cm-table)', }, '.cm-mw-table-definition-value': { color: 'var(--cm-table-attr)', fontWeight: 'normal', }, [getSelector(['bracket', 'delimiter', 'flag', 'lang'], 'convert-')]: { color: 'var(--cm-convert)', }, '.cm-mw-entity': { color: 'var(--cm-entity)', }, '.cm-mw-exttag': { backgroundColor: 'rgb(119,0,170,.04)', }, /* eslint-disable no-sparse-arrays */ ...getGrounds([[1]], 170, 17, 17, 0.04), ...getGrounds([[2]], 170, 17, 17, 0.08), ...getGrounds([[3]], 170, 17, 17, 0.12), ...getGrounds([[1, 1], [, 1]], 119, 0, 170, 0.04), ...getGrounds([[1, 2], [, 2]], 119, 0, 170, 0.08), ...getGrounds([[1, 3], [, 3]], 119, 0, 170, 0.12), ...getGrounds([[1, , 1], [, 1, 1], [, , 1]], 34, 17, 153, 0.04), ...getGrounds([[1, 1, 1], [, 2, 1]], 77, 9, 162, 0.08), ...getGrounds([[1, 2, 1], [, 3, 1]], 91, 6, 164, 0.12), ...getGrounds([[1, 3, 1]], 98, 4, 166, 0.16), ...getGrounds([[2, 1]], 145, 9, 94, 0.08), ...getGrounds([[2, 2]], 136, 6, 119, 0.12), ...getGrounds([[2, 3]], 132, 4, 132, 0.16), ...getGrounds([[2, , 1]], 102, 17, 85, 0.08), ...getGrounds([[2, 1, 1]], 108, 11, 113, 0.12), ...getGrounds([[2, 2, 1]], 111, 9, 128, 0.16), ...getGrounds([[2, 3, 1]], 112, 7, 136, 0.2), ...getGrounds([[3, 1]], 153, 11, 68, 0.12), ...getGrounds([[3, 2]], 145, 9, 94, 0.16), ...getGrounds([[3, 3]], 139, 7, 109, 0.2), ...getGrounds([[3, , 1]], 125, 17, 62, 0.12), ...getGrounds([[3, 1, 1]], 123, 13, 89, 0.16), ...getGrounds([[3, 2, 1]], 122, 10, 105, 0.2), ...getGrounds([[3, 3, 1]], 122, 9, 116, 0.24), /* eslint-enable no-sparse-arrays */ [getSelector(['pre', 'nowiki'], 'tag-')]: { backgroundColor: 'rgb(0,0,0,.04)', }, '.cm-bidi-isolate, &[dir="rtl"] .cm-mw-template-name': { unicodeBidi: 'isolate', }, '.cm-bidi-ltr': { direction: 'ltr', display: 'inline-block', }, }); /** * Gets a LanguageSupport instance for the MediaWiki mode. * @param config Configuration for the MediaWiki mode */ export const mediawiki = (config) => { const mode = new FullMediaWiki(config), lang = StreamLanguage.define(mode.mediawiki()), highlighter = syntaxHighlighting(HighlightStyle.define(mode.getTagStyles())); return new LanguageSupport(lang, [highlighter, theme]); };