| 1 | <template> |
| 2 | <codemirror |
| 3 | v-model="code" |
| 4 | placeholder="Code goes here..." |
| 5 | autofocus |
| 6 | indent-with-tab |
| 7 | :tab-size="4" |
| 8 | :extensions |
| 9 | :style="{ height: '100%' }" |
| 10 | @ready="handleReady" |
| 11 | /> |
| 12 | </template> |
| 13 | |
| 14 | <script setup lang="ts"> |
| 15 | // EVALUATE: https://www.npmjs.com/package/vue3-ace-editor |
| 16 | // EVALUATE: https://github.surmon.me/vue-codemirror |
| 17 | // EVALUATE: https://www.npmjs.com/package/@guolao/vue-monaco-editor |
| 18 | |
| 19 | import type { Diagnostic } from "@codemirror/lint" |
| 20 | import type { Extension } from "@codemirror/state" |
| 21 | import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands" |
| 22 | import { xml } from "@codemirror/lang-xml" |
| 23 | import { linter } from "@codemirror/lint" |
| 24 | import { oneDark } from "@codemirror/theme-one-dark" |
| 25 | import { EditorView } from "@codemirror/view" |
| 26 | import { XMLValidator } from "fast-xml-parser" |
| 27 | import _isEqual from "lodash/isEqual" |
| 28 | import _trim from "lodash/trim" |
| 29 | import _uniqWith from "lodash/uniqWith" |
| 30 | import { tomorrow } from "thememirror" |
| 31 | import { computed, onMounted, ref, shallowRef, watch } from "vue" |
| 32 | import { Codemirror } from "vue-codemirror" |
| 33 | import * as xmllint from "xmllint-wasm" |
| 34 | import { useThemeStore } from "@/stores/theme" |
| 35 | |
| 36 | export interface XMLEditorCtx { |
| 37 | undo: () => void |
| 38 | redo: () => void |
| 39 | scrollToLine: (line: number) => void |
| 40 | canUndo: () => boolean |
| 41 | canRedo: () => boolean |
| 42 | } |
| 43 | |
| 44 | export interface XMLError { |
| 45 | line: number |
| 46 | column: number |
| 47 | message: string |
| 48 | level: "error" |
| 49 | } |
| 50 | |
| 51 | const emit = defineEmits<{ |
| 52 | (e: "mounted", value: XMLEditorCtx): void |
| 53 | (e: "errors", value: XMLError[]): void |
| 54 | }>() |
| 55 | |
| 56 | const code = defineModel<string>("code", { default: "" }) |
| 57 | |
| 58 | const themeStore = useThemeStore() |
| 59 | const isDark = computed<boolean>(() => themeStore.isThemeDark) |
| 60 | |
| 61 | function convertXMLErrorsToDiagnostics(errors: XMLError[], text: string): Diagnostic[] { |
| 62 | const diagnostics: Diagnostic[] = [] |
| 63 | const lines = text.split("\n") |
| 64 | |
| 65 | emit( |
| 66 | "errors", |
| 67 | errors |
| 68 | .map(o => ({ ...o, message: _trim(o.message) })) |
| 69 | .filter(o => o.message !== "^") |
| 70 | .sort((a, b) => a.line - b.line) |
| 71 | ) |
| 72 | |
| 73 | errors.forEach(error => { |
| 74 | // Calculate position in text |
| 75 | let from = 0 |
| 76 | for (let i = 0; i < error.line - 1; i++) { |
| 77 | from += (lines[i]?.length ?? 0) + 1 // +1 for newline |
| 78 | } |
| 79 | from += error.column - 1 |
| 80 | |
| 81 | // Find the end of the error (end of line or end of message) |
| 82 | const lineText = lines[error.line - 1] || "" |
| 83 | const to = from + Math.min(lineText.length - (error.column - 1), 50) // Limit to 50 characters |
| 84 | |
| 85 | diagnostics.push({ |
| 86 | from, |
| 87 | to, |
| 88 | severity: error.level, |
| 89 | message: error.message |
| 90 | }) |
| 91 | }) |
| 92 | |
| 93 | return diagnostics |
| 94 | } |
| 95 | |
| 96 | async function strategyXMLLint(text: string): Promise<XMLError[]> { |
| 97 | try { |
| 98 | const result = await xmllint.validateXML({ |
| 99 | xml: text, |
| 100 | // Optional: Initial memory capacity in Web Assembly memory pages (1 = 6.4KiB) - 256 |
| 101 | // is minimum and default here (16MiB). |
| 102 | initialMemoryPages: 256, |
| 103 | // Optional: Maximum memory capacity, in Web Assembly memory pages. If not |
| 104 | // set, this will also default to 256 pages. Max is 65536 (4GiB). |
| 105 | // Use this to raise the memory limit if your XML to validate are large enough to |
| 106 | // cause out of memory errors. |
| 107 | // The following example would set the max memory to 2GiB. |
| 108 | maxMemoryPages: 2 * xmllint.memoryPages.GiB, |
| 109 | normalization: "format" |
| 110 | }) |
| 111 | |
| 112 | if (result.valid) { |
| 113 | return [] |
| 114 | } |
| 115 | |
| 116 | const errors: XMLError[] = result.errors.map(error => ({ |
| 117 | line: error.loc?.lineNumber || 1, |
| 118 | column: 1, |
| 119 | message: error.message, |
| 120 | level: "error" |
| 121 | })) |
| 122 | |
| 123 | return errors |
| 124 | } catch (err) { |
| 125 | console.error(err) |
| 126 | return [] |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | async function strategyFastXMLParser(text: string): Promise<XMLError[]> { |
| 131 | try { |
| 132 | const errors: XMLError[] = [] |
| 133 | |
| 134 | const validation = XMLValidator.validate(text) |
| 135 | |
| 136 | if (validation === true) { |
| 137 | return [] |
| 138 | } |
| 139 | |
| 140 | if (typeof validation === "object" && validation.err) { |
| 141 | const error = validation.err |
| 142 | |
| 143 | errors.push({ |
| 144 | line: error.line || 1, |
| 145 | column: error.col || 1, |
| 146 | message: error.msg, |
| 147 | level: "error" |
| 148 | }) |
| 149 | } |
| 150 | |
| 151 | return errors |
| 152 | } catch { |
| 153 | return [] |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | async function validateXML(text: string): Promise<Diagnostic[]> { |
| 158 | let errors: XMLError[] = [] |
| 159 | |
| 160 | try { |
| 161 | errors = await strategyXMLLint(text) |
| 162 | } catch (err) { |
| 163 | console.error(err) |
| 164 | |
| 165 | try { |
| 166 | errors = await strategyFastXMLParser(text) |
| 167 | } catch (err) { |
| 168 | console.error(err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | return convertXMLErrorsToDiagnostics(_uniqWith(errors, _isEqual), text) |
| 173 | } |
| 174 | |
| 175 | const extensions = computed(() => { |
| 176 | const list: Extension[] = [xml()] |
| 177 | |
| 178 | if (isDark.value) { |
| 179 | list.push(oneDark) |
| 180 | } else { |
| 181 | list.push(tomorrow) |
| 182 | } |
| 183 | |
| 184 | list.push( |
| 185 | linter(async view => { |
| 186 | const text = view.state.doc.toString() |
| 187 | |
| 188 | if (!text.trim()) { |
| 189 | return [] |
| 190 | } |
| 191 | |
| 192 | return await validateXML(text) |
| 193 | }) |
| 194 | ) |
| 195 | |
| 196 | return list |
| 197 | }) |
| 198 | |
| 199 | const cmView = shallowRef<EditorView | null>(null) |
| 200 | const canUndo = ref<boolean>(false) |
| 201 | const canRedo = ref<boolean>(false) |
| 202 | |
| 203 | function updateHistoryState() { |
| 204 | canUndo.value = cmView.value ? !!undoDepth(cmView.value.state) : false |
| 205 | canRedo.value = cmView.value ? !!redoDepth(cmView.value.state) : false |
| 206 | } |
| 207 | |
| 208 | function handleReady({ view }: { view: EditorView }) { |
| 209 | cmView.value = view |
| 210 | } |
| 211 | |
| 212 | function handleUndo() { |
| 213 | if (cmView.value) { |
| 214 | undo({ |
| 215 | state: cmView.value.state, |
| 216 | dispatch: cmView.value.dispatch |
| 217 | }) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | function handleRedo() { |
| 222 | if (cmView.value) { |
| 223 | redo({ |
| 224 | state: cmView.value.state, |
| 225 | dispatch: cmView.value.dispatch |
| 226 | }) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | function scrollToLine(line: number) { |
| 231 | if (cmView.value) { |
| 232 | const view = cmView.value |
| 233 | const lineInfo = view.state.doc.line(line) |
| 234 | view.dispatch({ |
| 235 | effects: EditorView.scrollIntoView(lineInfo.from, { y: "center" }) |
| 236 | }) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | watch(code, () => { |
| 241 | updateHistoryState() |
| 242 | }) |
| 243 | |
| 244 | onMounted(() => { |
| 245 | emit("mounted", { |
| 246 | undo: handleUndo, |
| 247 | redo: handleRedo, |
| 248 | scrollToLine, |
| 249 | canRedo: () => canRedo.value, |
| 250 | canUndo: () => canUndo.value |
| 251 | }) |
| 252 | }) |
| 253 | </script> |