UNPKG

@jinntec/jinn-codemirror

Version:

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

269 lines (245 loc) 10.2 kB
import { xml, completeFromSchema } from "@codemirror/lang-xml"; import { Extension } 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 } from "./xml-commands"; import { Completion, autocompletion, CompletionSource } from "@codemirror/autocomplete"; import { XMLAttributeAutocomplete } from "./autocomplete/xml-attribute-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 unwrap: boolean|null; private checkNamespace: boolean|null; private attributeAutocomplete: XMLAttributeAutocomplete[]; constructor(editor: JinnCodemirror, toolbar: HTMLElement[] = [], namespace: string|null = null, checkNamespace: boolean|null = false, unwrap: boolean|null = false, attributeAutocomplete: XMLAttributeAutocomplete[] = []) { super(editor, toolbar, commands); this.namespace = namespace; this.checkNamespace = checkNamespace; this.unwrap = unwrap; this.attributeAutocomplete = attributeAutocomplete; } private getDefaultExtensions (): Extension[] { return [ encloseWithPanel(), linter(teiFragmentLinter(this.editor, this.checkNamespace ? this.namespace : null), {delay, markerFilter}), lintGutter({markerFilter}) ]; } async getExtensions(): Promise<Extension[]> { const schemaUrl = (<JinnXMLEditor>this.editor).schema; const defaultExtensions = this.getDefaultExtensions(); // Build completion sources array - put our custom ones first so they take precedence const completionSources: CompletionSource[] = []; // Add attribute autocompletion sources this.attributeAutocomplete.forEach((autocomplete) => { const source = autocomplete.createCompletionSource(); if (source) { completionSources.push(source); } }); if (schemaUrl) { const schema = await this.loadSchema(schemaUrl); completionSources.push(completeFromSchema(schema.elements, [])); // Get XML language support (without its default autocompletion since we're overriding) const xmlSupport = xml(schema); // Override autocompletion to include both XML schema completion and our custom one return defaultExtensions.concat( xmlSupport, autocompletion({ override: completionSources }) ); } else { // No schema, just use basic XML support const xmlSupport = xml(); return defaultExtensions.concat( xmlSupport, autocompletion({ override: completionSources }) ); } } 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; } }