Files
writing-agent/web/markdown.js
T
chenmoranzhiandCursor a4cec3d708 引入正文安全子集并收紧呈现创作口径,避免本局任意脚本美化。
创作只选壳与分区,渲染与提示共用同一份白名单语法。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:58:48 +08:00

275 lines
7.1 KiB
JavaScript

/** Optional Markdown display — escaped first, then a small safe subset.
* Feature list (prompt injection): skills/.../markdown-safe-subset/catalog.yaml
*/
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function isTableSeparator(line) {
const t = String(line ?? "").trim();
if (!t.includes("-")) return false;
// GFM: | --- | :---: | ---: |
return /^\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(t);
}
function splitTableRow(line) {
let s = String(line ?? "").trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|")) s = s.slice(0, -1);
return s.split("|").map((c) => c.trim());
}
/** Safe subset: fences, details, tables, headings, emphasis, links, lists, quotes, hr. */
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(
`<pre class="md-pre"><code${langClass}>${esc(code.replace(/\n$/, ""))}</code></pre>`,
);
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("<br>");
out.push(`<p class="md-p">${body}</p>`);
para = [];
};
const flushList = () => {
if (!listType || !listItems.length) {
listType = null;
listItems = [];
return;
}
const tag = listType;
out.push(
`<${tag} class="md-${tag}">${listItems
.map((item) => `<li>${inlineMarkdown(item)}</li>`)
.join("")}</${tag}>`,
);
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;
}
const detailsStart = line.match(/^:::details(?:\s+(open))?\s+(.+?)\s*$/);
if (detailsStart) {
flushPara();
flushList();
const openAttr = detailsStart[1] ? " open" : "";
const summary = detailsStart[2];
const bodyLines = [];
i += 1;
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
bodyLines.push(lines[i]);
i += 1;
}
if (i < lines.length) i += 1;
const inner = renderMarkdownToHtml(bodyLines.join("\n"));
out.push(
`<details class="md-details"${openAttr}><summary class="md-summary">${inlineMarkdown(
summary,
)}</summary><div class="md-details-body">${inner || ""}</div></details>`,
);
continue;
}
if (
line.includes("|") &&
i + 1 < lines.length &&
isTableSeparator(lines[i + 1])
) {
flushPara();
flushList();
const headers = splitTableRow(line);
i += 2;
const rows = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
if (isTableSeparator(lines[i])) {
i += 1;
continue;
}
rows.push(splitTableRow(lines[i]));
i += 1;
}
const thead = `<thead><tr>${headers
.map((h) => `<th>${inlineMarkdown(h)}</th>`)
.join("")}</tr></thead>`;
const tbody = rows.length
? `<tbody>${rows
.map(
(cells) =>
`<tr>${headers
.map((_, idx) => `<td>${inlineMarkdown(cells[idx] ?? "")}</td>`)
.join("")}</tr>`,
)
.join("")}</tbody>`
: "";
out.push(
`<div class="md-table-wrap"><table class="md-table">${thead}${tbody}</table></div>`,
);
continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
flushPara();
flushList();
out.push('<hr class="md-hr">');
i += 1;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushPara();
flushList();
const level = heading[1].length;
out.push(`<h${level} class="md-h">${inlineMarkdown(heading[2])}</h${level}>`);
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(
`<blockquote class="md-quote">${quoteLines
.map((q) => `<p class="md-p">${inlineMarkdown(q)}</p>`)
.join("")}</blockquote>`,
);
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, "<br>");
}
function inlineMarkdown(line) {
let s = esc(line);
const codes = [];
s = s.replace(/`([^`]+)`/g, (_, code) => {
const i = codes.length;
codes.push(`<code class="md-code">${code}</code>`);
return `\u0000CODE${i}\u0000`;
});
s = s.replace(
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
(_, label, url) =>
`<a class="md-a" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`,
);
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
s = s.replace(/_([^_]+)_/g, "<em>$1</em>");
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
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, "<br>");
}