feat(micron-wasm): integrate Micron-Parser-Go WASM support and configuration

This commit is contained in:
Ivan
2026-05-02 09:02:39 -05:00
parent e6647415e8
commit 5097fb632d
17 changed files with 893 additions and 74 deletions
+104 -49
View File
@@ -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
+5
View File
@@ -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",
@@ -95,6 +95,62 @@
</span>
</template>
</span>
<v-tooltip
v-if="nomadBrowserRendererChip && !isLoadingNodePage"
location="bottom"
:open-on-hover="false"
:open-on-focus="false"
:open-on-click="true"
:interactive="true"
max-width="320"
content-class="!bg-transparent !p-0 shadow-none"
>
<template #activator="{ props: tooltipActivatorProps }">
<span
v-bind="tooltipActivatorProps"
class="shrink-0 hidden sm:inline-flex sm:items-center max-w-[7.5rem] md:max-w-[9rem] truncate rounded border border-gray-300 bg-gray-50 px-1.5 py-0.5 text-[10px] font-medium leading-tight text-gray-600 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400 dark:focus-visible:ring-blue-400"
tabindex="0"
role="button"
>{{ nomadBrowserRendererChip.label }}</span
>
</template>
<div
class="max-w-[min(20rem,85vw)] rounded-lg border border-[var(--mc-border-strong)] bg-[var(--mc-surface)] px-3 py-2 text-xs leading-snug text-[var(--mc-text-secondary)] shadow-lg"
>
<template v-if="nomadBrowserRendererChip.popoverVariant === 'wasm_active'">
<span>{{ $t("nomadnet.renderer_popover_micron_wasm_powered") }}</span>
<a
class="font-medium text-[var(--mc-text-secondary)] underline underline-offset-2 hover:opacity-90"
:href="micronParserGoRepoUrl"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ $t("settings.nomad_micron_wasm_link_label") }}</a
><span>{{
$t("nomadnet.renderer_popover_micron_wasm_active_tail", {
version: nomadBrowserRendererChip.micronGoRelease,
})
}}</span>
</template>
<template v-else-if="nomadBrowserRendererChip.popoverVariant === 'wasm_pending'">
<a
class="font-medium text-[var(--mc-text-secondary)] underline underline-offset-2 hover:opacity-90"
:href="micronParserGoRepoUrl"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ $t("settings.nomad_micron_wasm_link_label") }}</a
><span>{{
$t("nomadnet.renderer_popover_micron_wasm_pending_tail", {
version: nomadBrowserRendererChip.micronGoRelease,
})
}}</span>
</template>
<template v-else>
{{ nomadBrowserRendererChip.tooltipBody }}
</template>
</div>
</v-tooltip>
</div>
<!-- archive button -->
@@ -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) {
@@ -879,6 +879,28 @@
>
</span>
</label>
<label v-if="micronWasmBundledInBuild" class="setting-toggle">
<Toggle
id="nomad-micron-wasm"
v-model="config.nomad_micron_wasm_enabled"
@update:model-value="onNomadMicronWasmToggle"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("settings.nomad_micron_wasm_title")
}}</span>
<span class="setting-toggle__description">
{{ $t("settings.nomad_micron_wasm_desc_before_link") }}
<a
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline underline-offset-2"
href="https://git.quad4.io/Go-Libs/micron-parser-go"
target="_blank"
rel="noopener noreferrer"
>{{ $t("settings.nomad_micron_wasm_link_label") }}</a
>{{ $t("settings.nomad_micron_wasm_desc_after_link") }}
</span>
</span>
</label>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
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(
{
+1
View File
@@ -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,
+86 -21
View File
@@ -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 `<p style="color: red;">DOMPurify is not installed or sanitization failed.</p>`;
}
}
/**
* 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 `<p style="color: red;">DOMPurify is not installed or sanitization failed.</p>`;
}
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();
@@ -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;
}
@@ -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);
}
+1
View File
@@ -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",
+72
View File
@@ -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();
+5
View File
@@ -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";
+39
View File
@@ -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;
}
}
+39
View File
@@ -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(() => '<p data-wasm="1">wasm-body</p>');
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");
});
});
});
+167
View File
@@ -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);
});
});
@@ -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,
+19
View File
@@ -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(),
+20
View File
@@ -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({