UNPKG

@jinntec/jinn-codemirror

Version:

Source code editor component based on codemirror with language support for XML and Leiden+

238 lines (217 loc) 8.8 kB
import { xml } from "@codemirror/lang-xml"; import { Extension, EditorSelection } from "@codemirror/state"; import { EditorConfig } from "./config"; import { Diagnostic, linter, lintGutter, Action } from "@codemirror/lint"; import { EditorView } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; import { TreeCursor } from "@lezer/common"; import { JinnCodemirror } from "./jinn-codemirror"; import { JinnXMLEditor } from "./xml-editor"; import { commands, encloseWithPanel, zoteroPanel } from "./xml-commands"; import { Completion } from "@codemirror/autocomplete"; const isNamespaceNode = (view:EditorView, node:TreeCursor): boolean => { return node.type.name === "AttributeName" && view.state.sliceDoc(node.from, node.to) === "xmlns" } const isErrorComment = (view:EditorView, node:TreeCursor): boolean => { return node.type.name === "Comment" && /\<\!\-\- Error\:([^ ])* \-\-\>/.test(view.state.sliceDoc(node.from, node.to)) } // linter config settings // how long to wait before running linter const delay = 300; // do not show info messages in gutter nor in content const markerFilter = (dias:readonly Diagnostic[]):Diagnostic[] => dias.filter(dia => dia.severity !== 'info'); const fixNamespaceAction = (namespace:string):Action => { return { name: "Fix", apply: (view:EditorView, from: number, to:number) => { const tx = view.state.update({ changes: {from, to, insert: namespace} }); view.dispatch(tx); } } }; /** * Highlights SyntaxErrors, missing TEI or wrong namespace. * * @fires valid - if no errors were found * @fires invalid - if errors found * * @returns {function} linter */ const teiFragmentLinter = (editor: JinnCodemirror, namespace: string|null) => (view: EditorView): Diagnostic[] => { function emitEvent(valid: boolean) { editor.valid = valid; editor.dispatchEvent(new CustomEvent(valid ? 'valid' : 'invalid', { detail: diagnostics, composed: true, bubbles: true })); } const diagnostics:Diagnostic[] = []; if (view.state.doc.length === 0) { emitEvent(true); return diagnostics; } const tree = syntaxTree(view.state); let hasNamespace = false; const stack:string[] = []; tree.iterate({ enter: (node:TreeCursor) => { if (node.type.isError) { diagnostics.push({ message: 'Syntax error', severity: 'error', from: node.from, to: node.to }); } if (node.type.name === 'StartTag') { node.nextSibling(); stack.push(view.state.sliceDoc(node.from, node.to)); } else if (node.type.name === 'StartCloseTag') { node.nextSibling(); const closeTag = view.state.sliceDoc(node.from, node.to); const openTag = stack.pop(); if (closeTag !== openTag) { diagnostics.push({ message: `Expected closing tag for ${openTag}`, severity: 'error', from: node.from, to: node.to }); } } else if (node.type.name === 'SelfCloseEndTag') { stack.pop(); } else if (isNamespaceNode(view, node)) { hasNamespace = true; node.nextSibling() node.nextSibling() const ns = view.state.sliceDoc(node.from+1, node.to-1) if (namespace && ns !== namespace) { diagnostics.push({ message: 'Wrong Namespace', severity: 'error', from: node.from+1, to: node.to-1, actions: [ fixNamespaceAction(namespace) ] }); } } else if (isErrorComment(view, node)) { diagnostics.push({ message: 'Syntax error in source input', severity: 'warning', from: node.from, to: node.to }); } }, leave: (node:TreeCursor) => { if (node.type.name === "Document" && namespace && !hasNamespace) { diagnostics.push({ message: 'Missing TEI namespace', severity: 'error', from: node.from, to: node.to }); } if (node.type.isError) { diagnostics.push({ message: 'Syntax error in input', severity: 'error', from: node.from, to: node.to }); } } }); emitEvent(diagnostics.length === 0); return diagnostics; } const completeAttribute = (view: EditorView, completion: Completion, from: number, to: number) => { const tx = view.state.update({ changes: { from: from, to: to, insert: `${completion.label}=""` }, selection: { anchor: from + completion.label.length + 2 } }); view.dispatch(tx); } export class XMLConfig extends EditorConfig { private namespace: string|null; private unwrap: boolean|null; private checkNamespace: boolean|null; constructor(editor: JinnCodemirror, toolbar: HTMLElement[] = [], namespace: string|null = null, checkNamespace: boolean|null = false, unwrap: boolean|null = false) { super(editor, toolbar, commands); this.namespace = namespace; this.checkNamespace = checkNamespace; this.unwrap = unwrap; } private getDefaultExtensions (): Extension[] { return [ encloseWithPanel(), zoteroPanel(), linter(teiFragmentLinter(this.editor, this.checkNamespace ? this.namespace : null), {delay, markerFilter}), lintGutter({markerFilter}) ]; } async getExtensions(): Promise<Extension[]> { const schemaUrl = (<JinnXMLEditor>this.editor).schema; if (schemaUrl) { const schema = await this.loadSchema(schemaUrl); return this.getDefaultExtensions().concat(xml(schema)); } return this.getDefaultExtensions().concat(xml()); } private async loadSchema(url: string) { const json = await fetch(url) .then((response) => response.json()); let elements = json.elements; const entryPoint = (<JinnXMLEditor>this.editor).schemaRoot; if (entryPoint) { const root = json.elements.find((elem) => elem.name === entryPoint); if (root) { const filtered = new Map<string, any>(); const elemMap = new Map<string, any>(); json.elements.forEach((elem) => elemMap.set(elem.name, elem)); this.filterSchema(root, elemMap, filtered); elements = Array.from(filtered.values()) } } this.extendSchema(elements); return { elements }; } private filterSchema(elem: any, elemList: Map<string, any>, result: Map<string, any>, level: number = 0) { elem.children.forEach((child) => { const existingEntry = result.get(child); if (existingEntry) { existingEntry.top = (level === 0); return; } const entry = elemList.get(child); if (entry) { entry.top = (level === 0); result.set(child, entry); this.filterSchema(entry, elemList, result, level + 1); } }); } private extendSchema(elements: any[]) { elements.forEach((elem) => { elem.completion.type = 'keyword'; elem.attributes.forEach((attr) => { attr.completion.type = 'property'; attr.completion.apply = completeAttribute; }); }) } serialize(): Element | NodeListOf<ChildNode> | string | null | undefined { const parser = new DOMParser(); const content = this.unwrap ? `<R xmlns="${this.namespace || ''}">${this.editor.content}</R>` : this.editor.content; const parsed = parser.parseFromString(content, "application/xml"); const errors = parsed.getElementsByTagName("parsererror") if (errors.length) { return null; // throw new TypeError("Invalid XML"); } return this.unwrap ? parsed.firstElementChild?.childNodes : parsed.firstElementChild; } }