From 5097fb632d0fd4602aed0e09bbf2c7fc7b450a09 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 2 May 2026 09:02:39 -0500 Subject: [PATCH] feat(micron-wasm): integrate Micron-Parser-Go WASM support and configuration --- meshchatx/meshchat.py | 153 +++++++++----- meshchatx/src/backend/config_manager.py | 5 + .../nomadnetwork/NomadNetworkPage.vue | 195 +++++++++++++++++- .../components/settings/SettingsPage.vue | 44 ++++ meshchatx/src/frontend/js/GlobalState.js | 1 + meshchatx/src/frontend/js/MicronParser.js | 107 ++++++++-- meshchatx/src/frontend/js/MicronWasmLoader.js | 94 +++++++++ .../src/frontend/js/NomadPageRenderer.js | 5 +- package.json | 1 + scripts/fetch-micron-wasm.mjs | 72 +++++++ scripts/micron-parser-go-version.mjs | 5 + scripts/micron-wasm-resolve-bundled.mjs | 39 ++++ tests/frontend/MicronParser.test.js | 39 ++++ tests/frontend/MicronWasmLoader.test.js | 167 +++++++++++++++ .../frontend/fixtures/settingsPageTestApi.js | 1 + vite.config.js | 19 ++ vitest.config.js | 20 ++ 17 files changed, 893 insertions(+), 74 deletions(-) create mode 100644 meshchatx/src/frontend/js/MicronWasmLoader.js create mode 100644 scripts/fetch-micron-wasm.mjs create mode 100644 scripts/micron-parser-go-version.mjs create mode 100644 scripts/micron-wasm-resolve-bundled.mjs create mode 100644 tests/frontend/MicronWasmLoader.test.js diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 218e946..71fa008 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -95,6 +95,7 @@ from meshchatx.src.backend.lxmf_utils import ( is_user_facing_lxmf_payload, lxmf_fields_are_columba_reaction, lxmf_sidebar_preview_for_conversation_latest_row, + validate_app_extensions_for_lxmf_http_send, ) from meshchatx.src.backend.map_manager import MAX_EXPORT_TILES, TRANSPARENT_TILE from meshchatx.src.backend.markdown_renderer import MarkdownRenderer @@ -2334,6 +2335,7 @@ class ReticulumMeshChat: on_page_download_failure=on_failure, on_progress_update=on_progress, timeout=120, + reticulum=getattr(self, "reticulum", None), ) try: @@ -8044,7 +8046,6 @@ class ReticulumMeshChat: or "Anonymous Peer" ) - # get current hops away hops = RNS.Transport.hops_to( bytes.fromhex(announce["destination_hash"]), ) @@ -9618,10 +9619,11 @@ class ReticulumMeshChat: ) try: - bot_id = self.bot_handler.start_bot( + bot_id = await asyncio.to_thread( + self.bot_handler.start_bot, template_id, - name=name, - bot_id=bot_id, + name, + bot_id, ) return web.json_response({"bot_id": bot_id, "success": True}) except Exception as e: @@ -9642,7 +9644,7 @@ class ReticulumMeshChat: ) try: - success = self.bot_handler.stop_bot(bot_id) + success = await asyncio.to_thread(self.bot_handler.stop_bot, bot_id) return web.json_response({"success": success}) except Exception as e: return web.json_response( @@ -9662,7 +9664,10 @@ class ReticulumMeshChat: ) try: - new_id = self.bot_handler.restart_bot(bot_id) + new_id = await asyncio.to_thread( + self.bot_handler.restart_bot, + bot_id, + ) return web.json_response({"bot_id": new_id, "success": True}) except Exception as e: return web.json_response( @@ -9682,7 +9687,7 @@ class ReticulumMeshChat: ) try: - success = self.bot_handler.delete_bot(bot_id) + success = await asyncio.to_thread(self.bot_handler.delete_bot, bot_id) return web.json_response({"success": success}) except Exception as e: return web.json_response( @@ -9701,7 +9706,10 @@ class ReticulumMeshChat: ) try: - result = self.bot_handler.read_subprocess_log(bot_id) + result = await asyncio.to_thread( + self.bot_handler.read_subprocess_log, + bot_id, + ) return web.json_response(result) except ValueError as e: return web.json_response( @@ -9727,7 +9735,11 @@ class ReticulumMeshChat: ) try: - self.bot_handler.update_bot_name(bot_id, name) + await asyncio.to_thread( + self.bot_handler.update_bot_name, + bot_id, + name, + ) return web.json_response({"success": True}) except ValueError as e: return web.json_response( @@ -9752,7 +9764,7 @@ class ReticulumMeshChat: ) try: - self.bot_handler.request_announce(bot_id) + await asyncio.to_thread(self.bot_handler.request_announce, bot_id) return web.json_response({"success": True}) except ValueError as e: return web.json_response( @@ -10026,7 +10038,27 @@ class ReticulumMeshChat: status=400, ) - fields = lm.get("fields") if isinstance(lm.get("fields"), dict) else {} + raw_fields = lm.get("fields") + fields = dict(raw_fields) if isinstance(raw_fields, dict) else {} + app_extensions_payload = fields.pop("app_extensions", None) + validated_app_extensions = None + if app_extensions_payload is not None: + if not isinstance(app_extensions_payload, dict): + return web.json_response( + {"message": "Invalid app_extensions"}, + status=400, + ) + try: + validated_app_extensions = ( + validate_app_extensions_for_lxmf_http_send( + app_extensions_payload, + ) + ) + except ValueError: + return web.json_response( + {"message": "Invalid app_extensions"}, + status=400, + ) image_field = None audio_field = None @@ -10107,6 +10139,7 @@ class ReticulumMeshChat: delivery_method=delivery_method, reply_to_hash=reply_to_hash, reply_quoted_content=reply_quoted_content, + app_extensions=validated_app_extensions, ) return web.json_response( @@ -10341,9 +10374,28 @@ class ReticulumMeshChat: @routes.get("/api/v1/lxmf-messages/{message_hash}/uri") async def lxmf_message_uri(request): """Build a reticulum:// URI; prefer the router cache over DB-only state.""" - message_hash = request.match_info.get("message_hash") + from meshchatx.src.backend.meshchat_utils import ( + find_lxm_by_content_hash_for_paper_uri, + hex_identifier_to_bytes, + lxmf_message_try_paper_uri_string, + normalized_meshchat_lxmf_message_hash_hex, + ) - lxm = self.message_router.get_message(bytes.fromhex(message_hash)) + raw_hash = request.match_info.get("message_hash") + nh = normalized_meshchat_lxmf_message_hash_hex(raw_hash) + if not nh: + return web.json_response( + {"message": "Invalid message hash"}, + status=400, + ) + hb = hex_identifier_to_bytes(nh) + if hb is None: + return web.json_response( + {"message": "Invalid message hash"}, + status=400, + ) + + lxm = find_lxm_by_content_hash_for_paper_uri(self.message_router, hb) if not lxm: return web.json_response( @@ -10353,16 +10405,16 @@ class ReticulumMeshChat: status=404, ) - try: - # change delivery method to paper so as_uri works - original_method = lxm.method - lxm.method = LXMF.LXMessage.PAPER - uri = lxm.as_uri() - lxm.method = original_method # restore + uri, err_detail = lxmf_message_try_paper_uri_string(lxm) + if not uri: + body = { + "message": "Could not serialize this LXMF payload as a Paper URI" + } + if err_detail: + body["detail"] = err_detail + return web.json_response(body, status=422) - return web.json_response({"uri": uri}) - except Exception as e: - return web.json_response({"message": str(e)}, status=500) + return web.json_response({"uri": uri}) # delete lxmf messages for conversation @routes.delete("/api/v1/lxmf-messages/conversation/{destination_hash}") @@ -12920,6 +12972,11 @@ class ReticulumMeshChat: self._parse_bool(data["nomad_render_plaintext_enabled"]), ) + if "nomad_micron_wasm_enabled" in data: + self.config.nomad_micron_wasm_enabled.set( + self._parse_bool(data["nomad_micron_wasm_enabled"]), + ) + if "nomad_default_page_path" in data: from meshchatx.src.backend.page_node import is_allowed_page_filename @@ -13601,24 +13658,23 @@ class ReticulumMeshChat: on_file_download_failure, on_file_download_progress, on_phase=on_file_download_phase, + reticulum=getattr(self, "reticulum", None), ) downloader.start_time = time.time() self.active_downloads[download_id] = downloader - # notify client download started - AsyncUtils.run_async( - client.send_str( - json.dumps( - { - "type": "nomadnet.file.download", - "download_id": download_id, - "nomadnet_file_download": { - "status": "started", - "destination_hash": destination_hash.hex(), - "file_path": file_path, - }, + # notify client download started (await so phase updates cannot reorder ahead of started) + await client.send_str( + json.dumps( + { + "type": "nomadnet.file.download", + "download_id": download_id, + "nomadnet_file_download": { + "status": "started", + "destination_hash": destination_hash.hex(), + "file_path": file_path, }, - ), + }, ), ) @@ -13795,23 +13851,22 @@ class ReticulumMeshChat: on_page_download_failure, on_page_download_progress, on_phase=on_page_download_phase, + reticulum=getattr(self, "reticulum", None), ) self.active_downloads[download_id] = downloader - # notify client download started - AsyncUtils.run_async( - client.send_str( - json.dumps( - { - "type": "nomadnet.page.download", - "download_id": download_id, - "nomadnet_page_download": { - "status": "started", - "destination_hash": destination_hash.hex(), - "page_path": page_path, - }, + # notify client download started (await so phase updates cannot reorder ahead of started) + await client.send_str( + json.dumps( + { + "type": "nomadnet.page.download", + "download_id": download_id, + "nomadnet_page_download": { + "status": "started", + "destination_hash": destination_hash.hex(), + "page_path": page_path, }, - ), + }, ), ) @@ -14402,6 +14457,7 @@ class ReticulumMeshChat: "nomad_render_markdown_enabled": ctx.config.nomad_render_markdown_enabled.get(), "nomad_render_html_enabled": ctx.config.nomad_render_html_enabled.get(), "nomad_render_plaintext_enabled": ctx.config.nomad_render_plaintext_enabled.get(), + "nomad_micron_wasm_enabled": ctx.config.nomad_micron_wasm_enabled.get(), "nomad_default_page_path": ctx.config.nomad_default_page_path.get(), "local_message_auto_delete_enabled": ctx.config.local_message_auto_delete_enabled.get(), "local_message_auto_delete_value": ctx.config.local_message_auto_delete_value.get(), @@ -15762,7 +15818,6 @@ class ReticulumMeshChat: # We cannot replay "old" paths from the app layer — Transport.request_path refreshes discovery. path_outcome = await self._await_transport_path(destination_hash_bytes) - # find destination identity from hash destination_identity = RNS.Identity.recall(destination_hash_bytes) if destination_identity is None: # we have to bail out of sending, since we don't have the identity/path yet diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index a17e423..b3dcd7f 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -460,6 +460,11 @@ class ConfigManager: "nomad_render_plaintext_enabled", True, ) + self.nomad_micron_wasm_enabled = self.BoolConfig( + self, + "nomad_micron_wasm_enabled", + True, + ) self.nomad_default_page_path = self.StringConfig( self, "nomad_default_page_path", diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index bce79d1..89d9558 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -95,6 +95,62 @@ + + +
+ + + +
+
@@ -460,6 +516,12 @@ import IconButton from "../IconButton.vue"; import DropDownMenu from "../DropDownMenu.vue"; import DropDownMenuItem from "../DropDownMenuItem.vue"; import GlobalState from "../../js/GlobalState"; +import { + preloadNomadMicronWasm, + invalidateNomadMicronWasmPreload, + isMicronWasmBundled, +} from "../../js/MicronWasmLoader"; +import { VTooltip } from "vuetify/components/VTooltip"; export default { name: "NomadNetworkPage", @@ -469,6 +531,7 @@ export default { IconButton, DropDownMenu, DropDownMenuItem, + VTooltip, }, props: { destinationHash: { @@ -510,6 +573,7 @@ export default { nodePagePathHistory: [], nodePageCache: {}, currentPageDownloadId: null, + pendingNomadPageCancelWithoutId: false, isDownloadingNodeFile: false, nodeFilePath: null, @@ -540,6 +604,8 @@ export default { pathfinderInProgress: false, pendingLoadLatestArchive: false, + + nomadMicronWasmReady: false, }; }, computed: { @@ -547,6 +613,19 @@ export default { const p = GlobalState.config?.nomad_default_page_path; return typeof p === "string" && p.startsWith("/page/") ? p : "/page/index.mu"; }, + nomadMicronWasmFeatureEffective() { + return isMicronWasmBundled() && (GlobalState.config || {}).nomad_micron_wasm_enabled === true; + }, + micronParserGoRepoUrl() { + return "https://git.quad4.io/Go-Libs/micron-parser-go"; + }, + nomadMicronWasmActive() { + return ( + this.nomadMicronWasmFeatureEffective && + this.nomadMicronWasmReady === true && + typeof globalThis.micronConvert === "function" + ); + }, nomadRenderOptions() { const c = GlobalState.config || {}; return { @@ -554,8 +633,63 @@ export default { renderHtml: c.nomad_render_html_enabled !== false, renderPlaintext: c.nomad_render_plaintext_enabled !== false, nomadDestinationHash: this.selectedNode?.destination_hash || null, + nomad_micron_wasm_use: this.nomadMicronWasmFeatureEffective && this.nomadMicronWasmReady === true, }; }, + /** + * Active page renderer label for the toolbar chip (.mu uses Micron JS vs WASM). + */ + nomadBrowserRendererChip() { + if (!this.selectedNode || !this.nodePagePath) { + return null; + } + if (this.isShowingNodePageSource) { + return null; + } + const [p] = this.nodePagePath.split("`"); + const pathLower = (p || "").toLowerCase(); + const micronGoRelease = + typeof import.meta.env.VITE_MICRON_PARSER_GO_RELEASE === "string" && + import.meta.env.VITE_MICRON_PARSER_GO_RELEASE.trim() !== "" + ? import.meta.env.VITE_MICRON_PARSER_GO_RELEASE.trim() + : "\u2014"; + const plainChip = (labelKey, detailKey, detailParams) => { + const detail = detailKey ? this.$t(detailKey, detailParams ?? {}) : ""; + return { + label: this.$t(labelKey), + popoverVariant: null, + tooltipBody: detail, + }; + }; + if (pathLower.endsWith(".mu")) { + if (this.nomadMicronWasmActive) { + return { + label: this.$t("nomadnet.renderer_chip_micron_wasm"), + popoverVariant: "wasm_active", + micronGoRelease, + }; + } + const wasmPreferred = this.nomadMicronWasmFeatureEffective; + if (wasmPreferred && !this.nomadMicronWasmReady) { + return { + label: this.$t("nomadnet.renderer_chip_micron_js"), + popoverVariant: "wasm_pending", + micronGoRelease, + }; + } + return plainChip("nomadnet.renderer_chip_micron_js", "nomadnet.renderer_hint_micron_js"); + } + if (pathLower.endsWith(".md")) { + return plainChip("nomadnet.renderer_chip_markdown", "nomadnet.renderer_hint_markdown"); + } + if (pathLower.endsWith(".html")) { + return plainChip("nomadnet.renderer_chip_html", "nomadnet.renderer_hint_html"); + } + if (pathLower.endsWith(".txt")) { + return plainChip("nomadnet.renderer_chip_plaintext", "nomadnet.renderer_hint_plaintext"); + } + return null; + }, blockedDestinations() { return GlobalState.blockedDestinations; }, @@ -689,6 +823,28 @@ export default { // listen for websocket messages WebSocketConnection.on("message", this.onWebsocketMessage); + this.$watch( + () => GlobalState.config?.nomad_micron_wasm_enabled, + async (enabled) => { + if (!isMicronWasmBundled()) { + this.nomadMicronWasmReady = false; + return; + } + if (!enabled) { + this.nomadMicronWasmReady = false; + return; + } + invalidateNomadMicronWasmPreload(); + this.nomadMicronWasmReady = await preloadNomadMicronWasm(); + } + ); + + if (isMicronWasmBundled() && GlobalState.config?.nomad_micron_wasm_enabled === true) { + preloadNomadMicronWasm().then((ok) => { + this.nomadMicronWasmReady = ok === true; + }); + } + // load nomadnetwork node if a destination hash was provided on page load if (this.destinationHash) { (async () => { @@ -878,6 +1034,16 @@ export default { // handle started status if (nomadnetPageDownload.status === "started") { + if (this.pendingNomadPageCancelWithoutId) { + this.pendingNomadPageCancelWithoutId = false; + WebSocketConnection.send( + JSON.stringify({ + type: "nomadnet.download.cancel", + download_id: downloadId, + }) + ); + return; + } this.currentPageDownloadId = downloadId; this.nodePageLoadPhase = "finding_path"; return; @@ -994,8 +1160,9 @@ export default { // clear page download if it matches if (this.currentPageDownloadId === downloadId) { this.currentPageDownloadId = null; + this.pendingNomadPageCancelWithoutId = false; this.isLoadingNodePage = false; - this.nodePageContent = "Download cancelled"; + this.nodePageContent = this.$t("nomadnet.page_download_cancelled"); } // clear file download if it matches @@ -1249,6 +1416,8 @@ export default { // get new sequence for this page load const seq = ++this.nodePageRequestSequence; + this.pendingNomadPageCancelWithoutId = false; + // get previous page path const previousNodePagePath = this.nodePagePath; @@ -1412,6 +1581,10 @@ export default { this.partialIdsByKey = idsByKey; this.partialRefreshByKey = refreshByKey; + const micronOpts = { + useWasm: this.nomadMicronWasmActive, + }; + const muParser = new MicronParser(); const updatePartialDom = (html, ids) => { const container = this.$el.querySelector(".nodeContainer"); @@ -1433,7 +1606,7 @@ export default { path, fields, (pageContent) => { - const html = muParser.convertMicronToHtml(pageContent); + const html = muParser.convertMicronToHtml(pageContent, {}, micronOpts); const ids = this.partialIdsByKey[key]; if (ids) { updatePartialDom(html, ids); @@ -1449,7 +1622,7 @@ export default { const scheduleRefresh = () => { this.partialRefreshTimers[key] = setTimeout(() => { this.downloadNomadNetPage(dest, path, fields, (content) => { - const h = muParser.convertMicronToHtml(content); + const h = muParser.convertMicronToHtml(content, {}, micronOpts); const idList = this.partialIdsByKey[key]; if (idList) { updatePartialDom(h, idList); @@ -2061,7 +2234,23 @@ export default { download_id: this.currentPageDownloadId, }) ); + return; } + if (!this.isLoadingNodePage) { + return; + } + const parsed = this.parseNomadnetworkUrl(this.nodePagePath || ""); + const dh = parsed?.destination_hash || this.selectedNode?.destination_hash; + const pathPart = parsed?.path; + if (dh && pathPart) { + const key = this.getNomadnetPageDownloadCallbackKey(dh, pathPart); + delete this.nomadnetPageDownloadCallbacks[key]; + } + this.pendingNomadPageCancelWithoutId = true; + this.nodePageRequestSequence += 1; + this.isLoadingNodePage = false; + this.nodePageLoadPhase = null; + this.nodePageContent = this.$t("nomadnet.page_download_cancelled"); }, cancelFileDownload() { if (this.currentFileDownloadId !== null) { diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue index a3a04b9..a5bfdb8 100644 --- a/meshchatx/src/frontend/components/settings/SettingsPage.vue +++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue @@ -879,6 +879,28 @@ > +
Default page path (no URL path) @@ -2504,6 +2526,7 @@ import { } from "../../js/settings/incomingDeliveryLimit"; import { normalizeRetentionValue } from "../../js/localMessageRetention"; import { matchesSettingSearch, normalizeSearchString } from "../../js/settingsSearchUtils"; +import { isMicronWasmBundled } from "../../js/MicronWasmLoader.js"; export default { name: "SettingsPage", @@ -2584,6 +2607,7 @@ export default { nomad_render_markdown_enabled: true, nomad_render_html_enabled: true, nomad_render_plaintext_enabled: true, + nomad_micron_wasm_enabled: true, nomad_default_page_path: "/page/index.mu", local_message_auto_delete_enabled: false, local_message_auto_delete_value: 30, @@ -2728,6 +2752,9 @@ export default { "markdown", "HTML", "plaintext", + "WASM", + "micron-parser-go", + "micron-parser", "index.mu", "index.html", "default page", @@ -2877,6 +2904,9 @@ export default { }; }, computed: { + micronWasmBundledInBuild() { + return isMicronWasmBundled(); + }, settingsSearchActive() { return normalizeSearchString(this.searchQuery).length > 0; }, @@ -3546,6 +3576,20 @@ export default { null ); }, + async onNomadMicronWasmToggle(value) { + const prev = this.config.nomad_micron_wasm_enabled; + this.config.nomad_micron_wasm_enabled = value; + try { + const newConfig = await patchServerConfig({ nomad_micron_wasm_enabled: value }, window.api); + this.config = newConfig; + normalizeConfigColors(this.config); + this.syncLxmfTransferLimitInputs(); + } catch (e) { + this.config.nomad_micron_wasm_enabled = prev; + ToastUtils.error(this.$t("common.save_failed")); + console.log(e); + } + }, async onNomadDefaultPagePathChange() { await this.updateConfig( { diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js index 34a3a35..b8e475b 100644 --- a/meshchatx/src/frontend/js/GlobalState.js +++ b/meshchatx/src/frontend/js/GlobalState.js @@ -24,6 +24,7 @@ const globalState = reactive({ nomad_render_markdown_enabled: true, nomad_render_html_enabled: true, nomad_render_plaintext_enabled: true, + nomad_micron_wasm_enabled: true, nomad_default_page_path: "/page/index.mu", ui_transparency: 0, ui_glass_enabled: true, diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js index 1611b23..3387101 100644 --- a/meshchatx/src/frontend/js/MicronParser.js +++ b/meshchatx/src/frontend/js/MicronParser.js @@ -99,6 +99,59 @@ export default class MicronParser extends BaseMicronParser { }); } + static sanitizeRenderedMicronHtml(html) { + if (html == null) { + return ""; + } + const s = typeof html === "string" ? html : String(html); + try { + const sanitized = DOMPurify.sanitize(s, { + USE_PROFILES: { html: true }, + ALLOWED_URI_REGEXP, + }); + try { + return MicronParser.stripOverlayStyles(sanitized); + } catch (e) { + console.warn("MicronParser: stripOverlayStyles failed", e); + return sanitized; + } + } catch (error) { + console.warn( + "DOMPurify is not installed or sanitization failed. Include dompurify or check the build.", + error + ); + return `

DOMPurify is not installed or sanitization failed.

`; + } + } + + /** + * Split Micron source into blocks for WASM conversion, keeping MeshChat partial-include lines on the JS path. + */ + static splitMicronMarkupWasmSegments(markup) { + if (markup == null) { + return []; + } + const lines = String(markup).split("\n"); + const segments = []; + let buf = []; + for (const line of lines) { + const trimmed = line.trim(); + if (MicronParser.PARTIAL_LINE_REGEX.test(trimmed)) { + if (buf.length) { + segments.push({ type: "mu", text: buf.join("\n") }); + buf = []; + } + segments.push({ type: "partial", line }); + } else { + buf.push(line); + } + } + if (buf.length) { + segments.push({ type: "mu", text: buf.join("\n") }); + } + return segments; + } + injectMonospaceStyles() { if (document.getElementById("micron-monospace-styles")) { return; @@ -156,10 +209,23 @@ export default class MicronParser extends BaseMicronParser { document.head.appendChild(styleEl); } - convertMicronToHtml(markup, partialContents = {}) { - if (markup == null) return ""; - if (typeof markup !== "string") markup = String(markup); + convertMicronToHtmlWasmHybrid(markup, partialContents = {}) { + const mc = globalThis.micronConvert; + const segments = MicronParser.splitMicronMarkupWasmSegments(markup); + let html = ""; + for (const seg of segments) { + if (seg.type === "mu") { + html += MicronParser.sanitizeRenderedMicronHtml( + mc(seg.text, this.darkTheme, this.enableForceMonospace) + ); + } else { + html += this._convertMicronToHtmlJs(seg.line + "\n", partialContents); + } + } + return html; + } + _convertMicronToHtmlJs(markup, partialContents = {}) { const build = () => { let html = ""; @@ -230,24 +296,7 @@ export default class MicronParser extends BaseMicronParser { } } - try { - const sanitized = DOMPurify.sanitize(html, { - USE_PROFILES: { html: true }, - ALLOWED_URI_REGEXP, - }); - try { - return MicronParser.stripOverlayStyles(sanitized); - } catch (e) { - console.warn("MicronParser: stripOverlayStyles failed", e); - return sanitized; - } - } catch (error) { - console.warn( - "DOMPurify is not installed or sanitization failed. Include dompurify or check the build.", - error - ); - return `

DOMPurify is not installed or sanitization failed.

`; - } + return MicronParser.sanitizeRenderedMicronHtml(html); }; try { @@ -269,6 +318,22 @@ export default class MicronParser extends BaseMicronParser { } } + convertMicronToHtml(markup, partialContents = {}, options = {}) { + if (markup == null) return ""; + if (typeof markup !== "string") markup = String(markup); + + const wantWasm = options.useWasm === true && typeof globalThis.micronConvert === "function"; + if (wantWasm) { + try { + return this.convertMicronToHtmlWasmHybrid(markup, partialContents); + } catch (e) { + console.warn("MicronParser: WASM Micron conversion failed, using JS parser", e); + } + } + + return this._convertMicronToHtmlJs(markup, partialContents); + } + convertMicronToFragment(markup) { if (markup == null) { return document.createDocumentFragment(); diff --git a/meshchatx/src/frontend/js/MicronWasmLoader.js b/meshchatx/src/frontend/js/MicronWasmLoader.js new file mode 100644 index 0000000..627722f --- /dev/null +++ b/meshchatx/src/frontend/js/MicronWasmLoader.js @@ -0,0 +1,94 @@ +/** + * Lazy-load micron-parser-go WASM (see https://github.com/Quad4-Software/Micron-Parser-Go ). + * Requires wasm_exec.js from Go and micron-parser-go.wasm under /vendor/micron-parser-go/. + * Files are fetched at production build time (scripts/fetch-micron-wasm.mjs); omitted builds set VITE_MICRON_WASM_BUNDLED=false. + */ + +let resolvedPromise = null; + +/** True when WASM artifacts were present at Vite build time (not runtime probing). */ +export function isMicronWasmBundled() { + if (typeof globalThis !== "undefined" && typeof globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ === "boolean") { + return globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__; + } + return import.meta.env.VITE_MICRON_WASM_BUNDLED === "true"; +} + +function baseUrl() { + const root = import.meta.env.BASE_URL || "/"; + return `${root.replace(/\/?$/, "/")}vendor/micron-parser-go`; +} + +function injectScript(src) { + const id = "meshchatx-micron-wasm-exec"; + if (document.getElementById(id)) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const s = document.createElement("script"); + s.id = id; + s.async = true; + s.src = src; + s.onload = () => resolve(); + s.onerror = () => reject(new Error(`Micron WASM: failed to load script ${src}`)); + document.head.appendChild(s); + }); +} + +async function instantiateOnce() { + if (typeof WebAssembly === "undefined") { + throw new Error("Micron WASM: WebAssembly is not available"); + } + const root = baseUrl(); + await injectScript(`${root}/wasm_exec.js`); + if (typeof globalThis.Go === "undefined") { + throw new Error("Micron WASM: Go runtime missing after wasm_exec.js load"); + } + const wasmUrl = `${root}/micron-parser-go.wasm`; + const go = new globalThis.Go(); + let result; + try { + result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject); + } catch { + const buf = await fetch(wasmUrl).then((r) => { + if (!r.ok) { + throw new Error(`Micron WASM: fetch failed (${r.status})`); + } + return r.arrayBuffer(); + }); + result = await WebAssembly.instantiate(buf, go.importObject); + } + go.run(result.instance); + if (typeof globalThis.micronConvert !== "function") { + throw new Error("Micron WASM: micronConvert was not registered"); + } +} + +export function invalidateNomadMicronWasmPreload() { + resolvedPromise = null; +} + +/** + * Ensures micron-parser-go WASM is initialized; resolves true when micronConvert is callable. + */ +export function preloadNomadMicronWasm() { + if (!isMicronWasmBundled()) { + return Promise.resolve(false); + } + if (typeof globalThis.micronConvert === "function") { + return Promise.resolve(true); + } + if (resolvedPromise === null) { + resolvedPromise = (async () => { + try { + await instantiateOnce(); + return typeof globalThis.micronConvert === "function"; + } catch (e) { + console.warn(e); + resolvedPromise = null; + return false; + } + })(); + } + return resolvedPromise; +} diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js index 8c282e0..11d8f36 100644 --- a/meshchatx/src/frontend/js/NomadPageRenderer.js +++ b/meshchatx/src/frontend/js/NomadPageRenderer.js @@ -286,10 +286,13 @@ export function renderNomadPageByPath( const renderPlaintext = renderOptions.renderPlaintext !== false; const nomadDestinationHash = renderOptions.nomadDestinationHash || null; const linkOpts = { destinationHash: nomadDestinationHash }; + const micronOpts = { + useWasm: renderOptions.nomad_micron_wasm_use === true && typeof globalThis.micronConvert === "function", + }; const p = (pagePathWithoutData || "").toLowerCase(); if (p.endsWith(".mu")) { const muParser = new MicronParserClass(); - let out = muParser.convertMicronToHtml(content, pagePartials); + let out = muParser.convertMicronToHtml(content, pagePartials, micronOpts); if (nomadDestinationHash) { out = isolateNomadLinksInHtml(out, nomadDestinationHash); } diff --git a/package.json b/package.json index f68a09b..b5e42fa 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "vite dev", "watch": "pnpm run build-frontend -- --watch", + "prebuild-frontend": "node scripts/fetch-micron-wasm.mjs", "build-frontend": "vite build", "build-backend": "node scripts/build-backend.js", "build-docs": "python3 scripts/build/fetch_reticulum_manual.py", diff --git a/scripts/fetch-micron-wasm.mjs b/scripts/fetch-micron-wasm.mjs new file mode 100644 index 0000000..48978da --- /dev/null +++ b/scripts/fetch-micron-wasm.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Downloads micron-parser-go WASM release assets and matching wasm_exec.js for Vite public/. + * Safe to run offline: exits 0 without files when MICRON_WASM_SKIP=1 or network fails. + * + * Override URLs: + * MICRON_PARSER_GO_WASM_URL + * MICRON_GO_WASM_EXEC_URL + */ +import fs from "fs"; +import path from "path"; +import { MICRON_PARSER_GO_RELEASE_TAG } from "./micron-parser-go-version.mjs"; +import { micronWasmVendorPaths, micronWasmRepoRoot } from "./micron-wasm-resolve-bundled.mjs"; + +const DEFAULT_WASM_URL = `https://github.com/Quad4-Software/Micron-Parser-Go/releases/download/${MICRON_PARSER_GO_RELEASE_TAG}/micron-parser-go.wasm`; +const DEFAULT_WASM_EXEC_URL = "https://raw.githubusercontent.com/golang/go/go1.26.2/lib/wasm/wasm_exec.js"; + +const TIMEOUT_MS = Number(process.env.MICRON_WASM_FETCH_TIMEOUT_MS || 120000); + +function rmQuiet(p) { + try { + fs.rmSync(p, { force: true }); + } catch { + /* ignore */ + } +} + +async function fetchBinary(url, destFile) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { signal: ctrl.signal }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + fs.mkdirSync(path.dirname(destFile), { recursive: true }); + fs.writeFileSync(destFile, buf); + return buf.length; + } finally { + clearTimeout(t); + } +} + +async function main() { + if (process.env.MICRON_WASM_SKIP === "1") { + console.log("fetch-micron-wasm: MICRON_WASM_SKIP=1, skipping."); + process.exit(0); + } + + const root = micronWasmRepoRoot(); + const { dir, wasm, wasmExec } = micronWasmVendorPaths(root); + const wasmUrl = process.env.MICRON_PARSER_GO_WASM_URL || DEFAULT_WASM_URL; + const execUrl = process.env.MICRON_GO_WASM_EXEC_URL || DEFAULT_WASM_EXEC_URL; + + fs.mkdirSync(dir, { recursive: true }); + + try { + console.log("fetch-micron-wasm: downloading wasm_exec.js..."); + await fetchBinary(execUrl, wasmExec); + console.log("fetch-micron-wasm: downloading micron-parser-go.wasm..."); + const n = await fetchBinary(wasmUrl, wasm); + console.log(`fetch-micron-wasm: OK (${n} bytes WASM)`); + } catch (e) { + console.warn("fetch-micron-wasm: failed:", e?.message || e); + rmQuiet(wasm); + rmQuiet(wasmExec); + process.exit(0); + } +} + +main(); diff --git a/scripts/micron-parser-go-version.mjs b/scripts/micron-parser-go-version.mjs new file mode 100644 index 0000000..2043fe9 --- /dev/null +++ b/scripts/micron-parser-go-version.mjs @@ -0,0 +1,5 @@ +/** + * Micron-Parser-Go release tag for default WASM URL (scripts/fetch-micron-wasm.mjs) + * and build-time tooltip copy (VITE_MICRON_PARSER_GO_RELEASE). + */ +export const MICRON_PARSER_GO_RELEASE_TAG = "v1.0.2"; diff --git a/scripts/micron-wasm-resolve-bundled.mjs b/scripts/micron-wasm-resolve-bundled.mjs new file mode 100644 index 0000000..31dc06f --- /dev/null +++ b/scripts/micron-wasm-resolve-bundled.mjs @@ -0,0 +1,39 @@ +/** + * Resolve paths and detect whether micron-parser-go WASM artifacts are present for Vite/Vitest. + */ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** Repository root (parent of scripts/). */ +export function micronWasmRepoRoot() { + return path.join(__dirname, ".."); +} + +export function micronWasmVendorPaths(repoRoot = micronWasmRepoRoot()) { + const dir = path.join(repoRoot, "meshchatx", "src", "frontend", "public", "vendor", "micron-parser-go"); + return { + dir, + wasm: path.join(dir, "micron-parser-go.wasm"), + wasmExec: path.join(dir, "wasm_exec.js"), + }; +} + +/** + * True when both wasm_exec.js and a minimally-sized WASM binary exist (build-time fetch succeeded). + */ +export function isMicronWasmBundled(repoRoot = micronWasmRepoRoot()) { + const { wasm, wasmExec } = micronWasmVendorPaths(repoRoot); + try { + if (!fs.existsSync(wasm) || !fs.existsSync(wasmExec)) { + return false; + } + const wasmStat = fs.statSync(wasm); + const execStat = fs.statSync(wasmExec); + return wasmStat.size >= 8192 && execStat.size >= 1024; + } catch { + return false; + } +} diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js index 2dd6460..4bcb0d0 100644 --- a/tests/frontend/MicronParser.test.js +++ b/tests/frontend/MicronParser.test.js @@ -707,4 +707,43 @@ describe("MicronParser.js", () => { expect(frag.childNodes.length).toBeGreaterThan(0); }); }); + + describe("micron-parser-go WASM integration helpers", () => { + afterEach(() => { + delete globalThis.micronConvert; + }); + + it("splitMicronMarkupWasmSegments isolates MeshChat partial lines", () => { + const dest = "a".repeat(32); + const partialLine = `\`{${dest}:/page/partial.mu}`; + const segments = MicronParser.splitMicronMarkupWasmSegments(`> hello\nworld\n${partialLine}\ntrailer`); + expect(segments).toEqual([ + { type: "mu", text: "> hello\nworld" }, + { type: "partial", line: partialLine }, + { type: "mu", text: "trailer" }, + ]); + }); + + it("convertMicronToHtml falls back to JS when WASM throws", () => { + globalThis.micronConvert = vi.fn(() => { + throw new Error("forced wasm failure"); + }); + const p = new MicronParser(true, false); + const html = p.convertMicronToHtml("`!ok`!", {}, { useWasm: true }); + expect(html.length).toBeGreaterThan(0); + expect(html.toLowerCase()).toContain("ok"); + }); + + it("convertMicronToHtml stitches WASM segments with JS partial lines", () => { + globalThis.micronConvert = vi.fn(() => '

wasm-body

'); + const dest = "b".repeat(32); + const partialLine = `\`{${dest}:/page/inc.mu}`; + const p = new MicronParser(true, false); + const html = p.convertMicronToHtml(`intro line\n${partialLine}`, {}, { useWasm: true }); + expect(globalThis.micronConvert).toHaveBeenCalled(); + expect(html).toContain("wasm-body"); + expect(html).toContain("mu-partial"); + expect(html).toContain("data-dest"); + }); + }); }); diff --git a/tests/frontend/MicronWasmLoader.test.js b/tests/frontend/MicronWasmLoader.test.js new file mode 100644 index 0000000..001ccb4 --- /dev/null +++ b/tests/frontend/MicronWasmLoader.test.js @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + invalidateNomadMicronWasmPreload, + isMicronWasmBundled, + preloadNomadMicronWasm, +} from "../../meshchatx/src/frontend/js/MicronWasmLoader.js"; + +describe("MicronWasmLoader.js", () => { + let origBundledFlag; + + beforeEach(() => { + origBundledFlag = globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__; + invalidateNomadMicronWasmPreload(); + delete globalThis.micronConvert; + delete globalThis.Go; + document.getElementById("meshchatx-micron-wasm-exec")?.remove(); + }); + + afterEach(() => { + invalidateNomadMicronWasmPreload(); + delete globalThis.micronConvert; + delete globalThis.Go; + document.getElementById("meshchatx-micron-wasm-exec")?.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (origBundledFlag === undefined) { + delete globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__; + } else { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = origBundledFlag; + } + }); + + it("isMicronWasmBundled honors __MESHCHATX_TEST_MICRON_WASM_BUNDLED__", () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + expect(isMicronWasmBundled()).toBe(true); + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = false; + expect(isMicronWasmBundled()).toBe(false); + }); + + it("preloadNomadMicronWasm resolves false without bundling and does not fetch", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = false; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("preloadNomadMicronWasm resolves false when WebAssembly is unavailable", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + vi.stubGlobal("WebAssembly", undefined); + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + }); + + it("preloadNomadMicronWasm resolves false when wasm_exec script fails to load", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onerror === "function") { + queueMicrotask(() => node.onerror()); + } + return node; + }); + try { + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm resolves false when wasm_exec loads but Go is missing", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onload === "function") { + queueMicrotask(() => node.onload()); + } + return node; + }); + try { + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm resolves false when WASM instantiation fails", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + globalThis.Go = class { + constructor() { + this.importObject = {}; + this.run = vi.fn(); + } + }; + + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onload === "function") { + queueMicrotask(() => node.onload()); + } + return node; + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(16), + headers: new Headers({ "content-type": "application/wasm" }), + }); + + const streaming = vi + .spyOn(WebAssembly, "instantiateStreaming") + .mockRejectedValue(new Error("streaming failed")); + const instantiate = vi.spyOn(WebAssembly, "instantiate").mockRejectedValue(new Error("bad wasm")); + + try { + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + expect(streaming).toHaveBeenCalled(); + expect(instantiate).toHaveBeenCalled(); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm can retry after invalidateNomadMicronWasmPreload", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + globalThis.Go = class { + constructor() { + this.importObject = {}; + this.run = vi.fn(); + } + }; + + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onload === "function") { + queueMicrotask(() => node.onload()); + } + return node; + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(16), + headers: new Headers({ "content-type": "application/wasm" }), + }); + + vi.spyOn(WebAssembly, "instantiateStreaming").mockRejectedValue(new Error("streaming failed")); + const instantiate = vi + .spyOn(WebAssembly, "instantiate") + .mockRejectedValueOnce(new Error("first")) + .mockRejectedValueOnce(new Error("second")); + + try { + expect(await preloadNomadMicronWasm()).toBe(false); + invalidateNomadMicronWasmPreload(); + expect(await preloadNomadMicronWasm()).toBe(false); + expect(instantiate).toHaveBeenCalledTimes(2); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm resolves true when micronConvert is already defined", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + globalThis.micronConvert = vi.fn(() => ""); + expect(await preloadNomadMicronWasm()).toBe(true); + }); +}); diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js index 0e7f66a..a3167b0 100644 --- a/tests/frontend/fixtures/settingsPageTestApi.js +++ b/tests/frontend/fixtures/settingsPageTestApi.js @@ -123,6 +123,7 @@ export function buildFullServerConfig(overrides = {}) { nomad_render_markdown_enabled: true, nomad_render_html_enabled: true, nomad_render_plaintext_enabled: true, + nomad_micron_wasm_enabled: true, nomad_default_page_path: "/page/index.mu", gitea_base_url: "https://git.quad4.io", ...overrides, diff --git a/vite.config.js b/vite.config.js index 20309ab..ceab4aa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import path from "path"; import fs from "fs"; import { defineConfig } from "vite"; +import { MICRON_PARSER_GO_RELEASE_TAG } from "./scripts/micron-parser-go-version.mjs"; import tailwindcss from "@tailwindcss/vite"; import vue from "@vitejs/plugin-vue"; import vuetify from "vite-plugin-vuetify"; @@ -34,9 +35,27 @@ const e2eBackendWs = `ws://127.0.0.1:${e2eBackendPort}`; const appBuildTimeIso = new Date().toISOString(); +function isMicronWasmBundledResolved() { + const wasmDir = path.join(__dirname, "meshchatx", "src", "frontend", "public", "vendor", "micron-parser-go"); + const wasmFile = path.join(wasmDir, "micron-parser-go.wasm"); + const execFile = path.join(wasmDir, "wasm_exec.js"); + try { + if (!fs.existsSync(wasmFile) || !fs.existsSync(execFile)) { + return false; + } + return fs.statSync(wasmFile).size >= 8192 && fs.statSync(execFile).size >= 1024; + } catch { + return false; + } +} + +const micronWasmBundled = isMicronWasmBundledResolved(); + export default defineConfig({ define: { __APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso), + "import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"), + "import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG), }, plugins: [ tailwindcss(), diff --git a/vitest.config.js b/vitest.config.js index b7591d0..973f6d5 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,12 +1,32 @@ import { defineConfig } from "vitest/config"; import vue from "@vitejs/plugin-vue"; import path from "path"; +import fs from "fs"; +import { MICRON_PARSER_GO_RELEASE_TAG } from "./scripts/micron-parser-go-version.mjs"; + +function isMicronWasmBundledResolved(repoRoot) { + const wasmDir = path.join(repoRoot, "meshchatx", "src", "frontend", "public", "vendor", "micron-parser-go"); + const wasmFile = path.join(wasmDir, "micron-parser-go.wasm"); + const execFile = path.join(wasmDir, "wasm_exec.js"); + try { + if (!fs.existsSync(wasmFile) || !fs.existsSync(execFile)) { + return false; + } + return fs.statSync(wasmFile).size >= 8192 && fs.statSync(execFile).size >= 1024; + } catch { + return false; + } +} + +const micronWasmBundled = isMicronWasmBundledResolved(__dirname); const appBuildTimeIso = new Date().toISOString(); export default defineConfig({ define: { __APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso), + "import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"), + "import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG), }, plugins: [ vue({