From 692ba887d026507f041a82916f9684546ac1ea13 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 9 Sep 2026 02:49:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=AF=E9=80=89=E5=9C=B0?= =?UTF-8?q?=E6=8A=8A=E6=B6=88=E6=81=AF=E4=B8=8E=E5=91=88=E7=8E=B0=E5=A3=B3?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E6=8C=89=E6=A0=87=E8=AE=B0=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- web/markdown.js | 199 ++++++++++++++++++++++++++++++++++++++++++ web/present-shells.js | 6 ++ 2 files changed, 205 insertions(+) create mode 100644 web/markdown.js diff --git a/web/markdown.js b/web/markdown.js new file mode 100644 index 0000000..b861cf8 --- /dev/null +++ b/web/markdown.js @@ -0,0 +1,199 @@ +/** Optional Markdown display — escaped first, then a small safe subset. */ + +const STORAGE_KEY = "wa-markdown-render"; + +export function getStoredMarkdownRender() { + try { + return localStorage.getItem(STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +export function isMarkdownRenderEnabled() { + return document.documentElement.getAttribute("data-markdown-render") === "1"; +} + +export function applyMarkdownRender(enabled) { + const on = Boolean(enabled); + document.documentElement.setAttribute("data-markdown-render", on ? "1" : "0"); + try { + localStorage.setItem(STORAGE_KEY, on ? "1" : "0"); + } catch { + /* ignore */ + } + return on; +} + +export function initMarkdownRender() { + return applyMarkdownRender(getStoredMarkdownRender()); +} + +function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** Safe subset: fences, inline code, headings, emphasis, links, lists, quotes, hr, breaks. */ +export function renderMarkdownToHtml(raw) { + const text = String(raw ?? ""); + if (!text) return ""; + + const fences = []; + let src = text.replace(/```([a-zA-Z0-9_-]*)\n?([\s\S]*?)```/g, (_, lang, code) => { + const i = fences.length; + const langClass = lang ? ` class="language-${esc(lang)}"` : ""; + fences.push( + `
${esc(code.replace(/\n$/, ""))}
`, + ); + return `\u0000FENCE${i}\u0000`; + }); + + const lines = src.split(/\n/); + const out = []; + let i = 0; + let para = []; + let listType = null; // "ul" | "ol" + let listItems = []; + + const flushPara = () => { + if (!para.length) return; + const body = para.map(inlineMarkdown).join("
"); + out.push(`

${body}

`); + para = []; + }; + + const flushList = () => { + if (!listType || !listItems.length) { + listType = null; + listItems = []; + return; + } + const tag = listType; + out.push( + `<${tag} class="md-${tag}">${listItems + .map((item) => `
  • ${inlineMarkdown(item)}
  • `) + .join("")}`, + ); + listType = null; + listItems = []; + }; + + while (i < lines.length) { + const line = lines[i]; + const fenceMatch = line.match(/^\u0000FENCE(\d+)\u0000$/); + if (fenceMatch) { + flushPara(); + flushList(); + out.push(fences[Number(fenceMatch[1])] ?? ""); + i += 1; + continue; + } + + if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { + flushPara(); + flushList(); + out.push('
    '); + i += 1; + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + flushPara(); + flushList(); + const level = heading[1].length; + out.push(`${inlineMarkdown(heading[2])}`); + i += 1; + continue; + } + + const bq = line.match(/^>\s?(.*)$/); + if (bq) { + flushPara(); + flushList(); + const quoteLines = []; + while (i < lines.length) { + const m = lines[i].match(/^>\s?(.*)$/); + if (!m) break; + quoteLines.push(m[1]); + i += 1; + } + out.push( + `
    ${quoteLines + .map((q) => `

    ${inlineMarkdown(q)}

    `) + .join("")}
    `, + ); + continue; + } + + const ul = line.match(/^\s*[-*+]\s+(.+)$/); + if (ul) { + flushPara(); + if (listType && listType !== "ul") flushList(); + listType = "ul"; + listItems.push(ul[1]); + i += 1; + continue; + } + + const ol = line.match(/^\s*\d+\.\s+(.+)$/); + if (ol) { + flushPara(); + if (listType && listType !== "ol") flushList(); + listType = "ol"; + listItems.push(ol[1]); + i += 1; + continue; + } + + if (!line.trim()) { + flushPara(); + flushList(); + i += 1; + continue; + } + + flushList(); + para.push(line); + i += 1; + } + + flushPara(); + flushList(); + return out.join("") || esc(text).replace(/\n/g, "
    "); +} + +function inlineMarkdown(line) { + let s = esc(line); + + const codes = []; + s = s.replace(/`([^`]+)`/g, (_, code) => { + const i = codes.length; + codes.push(`${code}`); + return `\u0000CODE${i}\u0000`; + }); + + s = s.replace( + /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, + (_, label, url) => + `${label}`, + ); + + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/__([^_]+)__/g, "$1"); + s = s.replace(/\*([^*]+)\*/g, "$1"); + s = s.replace(/_([^_]+)_/g, "$1"); + s = s.replace(/~~([^~]+)~~/g, "$1"); + + s = s.replace(/\u0000CODE(\d+)\u0000/g, (_, i) => codes[Number(i)] ?? ""); + return s; +} + +/** Plain body: markdown when enabled, else escaped newlines. */ +export function formatBodyHtml(text) { + if (isMarkdownRenderEnabled()) return renderMarkdownToHtml(text); + return esc(text).replace(/\n/g, "
    "); +} diff --git a/web/present-shells.js b/web/present-shells.js index 0b82dec..6f0484f 100644 --- a/web/present-shells.js +++ b/web/present-shells.js @@ -9,6 +9,8 @@ * P3 header / footer / aside — 辅助,紧凑 */ +import { formatBodyHtml, isMarkdownRenderEnabled } from "./markdown.js"; + export const PRESENT_SHELL_IDS = [ "prose", "chat_monitor", @@ -132,6 +134,10 @@ function regionInnerHtml(region, rawValue, esc) { if (chips) return chips; } const text = formatBlockContent(rawValue).trim(); + if (!text) return `
    `; + if (isMarkdownRenderEnabled()) { + return `
    ${formatBodyHtml(text)}
    `; + } const paras = text.split(/\n\n+/).filter(Boolean); const inner = paras .map((p) => `

    ${esc(p).replace(/\n/g, "
    ")}

    `)