src/markdown.js
Rama main · réplica de lectura del repositorio original.
let ICONS = null, ICON_SVG = null, MARKS = null, CHIP = null;
try {
const here = new URL('.', import.meta.url);
for (const f of ['icon-registry.js', 'vendor/lucide-moving.js', 'mark-icons.js', 'chip-core.js', 'view-core.js', 'cell-graphics.js', 'chip-view.js']) {
await import(new URL(`../public/${f}`, here).href);
}
ICONS = globalThis.weaveIconRegistry ?? null;
ICON_SVG = globalThis.LUCIDE_MOVING ?? null;
MARKS = globalThis.weaveMarkIcons ?? null;
CHIP = globalThis.weaveChipView ?? null;
} catch {}
const ICON_TOKEN = /^:([a-z0-9][a-z0-9-]*):/;
export function inlineIconHtml(token) {
if (!ICONS || !ICON_SVG || !MARKS) return null;
const hit = ICONS.inline(token);
if (!hit) return null;
if (hit.name) {
return `<span class="wv-icon md-icon mi mi-${hit.name}" data-ms="${ICONS.MOTION[hit.name] || 0}" title="${escapeHtml(token)}">${ICON_SVG[hit.name]}</span>`;
}
return `<span class="wv-icon md-icon" title="${escapeHtml(token)}"><svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden="true">${MARKS.markSvg(hit.mark)}</svg></span>`;
}
export function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
}
const EMPTY_EMBED = /(?<!\\)!\[[^\]\n]*\]\(\s*\)/g;
function outsideCode(line) {
return line.split(/(`+[^`]*`+)/).map((part, i) => (i % 2 ? part : part.replace(EMPTY_EMBED, ''))).join('');
}
export function stripEmptyEmbeds(markdown) {
const text = String(markdown ?? '');
if (!text.includes(']()') && !/\]\(\s+\)/.test(text)) return text;
const lines = text.split('\n');
let fenced = false;
for (let i = 0; i < lines.length; i += 1) {
if (/^\s*```/.test(lines[i])) { fenced = !fenced; continue; }
if (!fenced) lines[i] = outsideCode(lines[i]);
}
return lines.join('\n');
}
const ENTITY = { colon: ':', tab: '\t', newline: '\n', amp: '&', sol: '/', period: '.', lpar: '(', rpar: ')' };
function decodeEntities(s) {
for (let n = 0; n < 4; n++) {
const next = s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (m, e) => {
if (e[0] !== '#') return ENTITY[e.toLowerCase()] ?? m;
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
return code > 0 && code < 0x110000 ? String.fromCodePoint(code) : '';
});
if (next === s) return s;
s = next;
}
return s;
}
function safeUrl(url, { image = false } = {}) {
const plain = decodeEntities(String(url)).replace(/[\u0000-\u0020\u007f-\u009f]/g, '').toLowerCase();
const scheme = plain.match(/^([^/?#]*?):/);
if (!scheme) return url;
if (['http', 'https', 'mailto'].includes(scheme[1])) return url;
if (image && /^data:image\/(png|jpeg|gif|webp)[;,]/.test(plain)) return url;
return null;
}
function linkTarget(href) {
return /^https?:\/\//i.test(String(href)) ? ' target="_blank" rel="noopener"' : '';
}
function renderInline(text, resolveMention) {
let out = '';
let i = 0;
const src = String(text).replace(/[ \t]*$/, '').replace(/(?: {2,}|\\)\n/g, '\u0000\n');
while (i < src.length) {
if (src.startsWith('[[', i)) {
const end = src.indexOf(']]', i);
if (end > 0) {
const inner = src.slice(i + 2, end);
const pipe = inner.indexOf('|');
const ref = (pipe < 0 ? inner : inner.slice(0, pipe)).trim();
const label = pipe < 0 ? null : inner.slice(pipe + 1).trim();
const typed = ref.match(/^(table|space|workspace)(?::(.*))?$/);
const kind = typed ? typed[1] : (/^.+#\d+$/.test(ref) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(ref)) ? 'entity' : null;
const target = typed ? (typed[2] ?? '').trim() : ref;
if (kind && resolveMention && (kind === 'workspace' || target)) {
let resolved = null;
try {
resolved = resolveMention(kind, target);
} catch {}
if (resolved && safeUrl(resolved.href) != null) {
const v = resolved.view ?? {
name: resolved.label, link: false, state: null,
fields: (resolved.fields ?? []).filter((f) => f && f.value != null && f.value !== '').slice(0, 3),
};
const title = label ?? (resolved.view ? resolved.name : resolved.label);
out += CHIP
? CHIP.chipHtml(v, { kind, href: resolved.href, home: resolved.home, label: title })
: `<a class="mention mention-${kind}" href="${escapeHtml(resolved.href)}">${escapeHtml(title)}</a>`;
i = end + 2;
continue;
}
}
out += `<span class="mention broken">${escapeHtml(inner)}</span>`;
i = end + 2;
continue;
}
}
if (src[i] === ':') {
const m = src.slice(i).match(ICON_TOKEN);
const html = m && inlineIconHtml(m[1]);
if (html) { out += html; i += m[0].length; continue; }
}
if (src[i] === '`') {
const end = src.indexOf('`', i + 1);
if (end > 0) {
out += `<code>${escapeHtml(src.slice(i + 1, end))}</code>`;
i = end + 1;
continue;
}
}
if (src.startsWith('![', i)) {
const m = src.slice(i).match(/^!\[([^\]]*)\]\(([^)\s]+)\)/);
if (m) {
const url = safeUrl(m[2], { image: true });
out += url == null ? escapeHtml(m[1]) : `<img src="${escapeHtml(url)}" alt="${escapeHtml(m[1])}">`;
i += m[0].length;
continue;
}
}
if (src[i] === '[') {
const m = src.slice(i).match(/^\[([^\]]+)\]\(([^)\s]+)\)/);
if (m) {
const url = safeUrl(m[2]);
out += url == null ? escapeHtml(m[1]) : `<a href="${escapeHtml(url)}"${linkTarget(url)}>${renderInline(m[1], resolveMention)}</a>`;
i += m[0].length;
continue;
}
}
if (src.startsWith('**', i)) {
const end = src.indexOf('**', i + 2);
if (end > 0) {
out += `<strong>${renderInline(src.slice(i + 2, end), resolveMention)}</strong>`;
i = end + 2;
continue;
}
}
if (src.startsWith('~~', i)) {
const end = src.indexOf('~~', i + 2);
if (end > 0) {
out += `<del>${renderInline(src.slice(i + 2, end), resolveMention)}</del>`;
i = end + 2;
continue;
}
}
if (src[i] === '*' || (src[i] === '_' && /\s|^/.test(src[i - 1] ?? ' '))) {
const ch = src[i];
const end = src.indexOf(ch, i + 1);
if (end > 0 && end > i + 1) {
out += `<em>${renderInline(src.slice(i + 1, end), resolveMention)}</em>`;
i = end + 1;
continue;
}
}
out += escapeHtml(src[i]);
i++;
}
return out.replace(/\u0000/g, '<br>');
}
export function parseBlocks(markdown) {
const lines = String(markdown ?? '').replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) { i++; continue; }
const fence = line.match(/^```(\w*)\s*$/);
if (fence) {
const code = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);
i++;
const lang = fence[1] || '';
blocks.push({
type: ['mermaid', 'mmd'].includes(lang.toLowerCase()) ? 'mermaid' : 'code',
lang,
text: code.join('\n'),
});
continue;
}
if (/^<[a-zA-Z!/]/.test(line)) {
const html = [line];
i++;
while (i < lines.length && !/^\s*$/.test(lines[i])) html.push(lines[i++]);
blocks.push({ type: 'html', text: html.join('\n') });
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
blocks.push({ type: 'heading', level: h[1].length, text: h[2].trim() });
i++;
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
blocks.push({ type: 'hr' });
i++;
continue;
}
if (/^>\s?/.test(line)) {
const quote = [];
while (i < lines.length && /^>\s?/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ''));
blocks.push({ type: 'quote', text: quote.join('\n') });
continue;
}
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
const parseRow = (l) => l.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
const header = parseRow(line);
i += 2;
const rows = [];
while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) rows.push(parseRow(lines[i++]));
blocks.push({ type: 'table', header, rows });
continue;
}
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
if (listMatch) {
const items = [];
while (i < lines.length) {
const m = lines[i].match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
if (!m) break;
const task = m[3].match(/^\[([ xX])\]\s+(.*)$/);
items.push({
depth: Math.floor(m[1].length / 2),
ordered: /\d/.test(m[2]),
text: task ? task[2] : m[3],
checked: task ? task[1].toLowerCase() === 'x' : null,
});
i++;
}
blocks.push({ type: 'list', items });
continue;
}
const para = [line];
i++;
while (
i < lines.length &&
!/^\s*$/.test(lines[i]) &&
!/^(#{1,6})\s/.test(lines[i]) &&
!/^```/.test(lines[i]) &&
!/^>\s?/.test(lines[i]) &&
!/^(\s*)([-*+]|\d+\.)\s+/.test(lines[i]) &&
!/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i])
) {
para.push(lines[i++]);
}
blocks.push({ type: 'paragraph', text: para.join('\n') });
}
return blocks;
}
function renderList(items, resolveMention) {
let html = '';
const stack = [];
let prevDepth = null;
for (const item of items) {
const tag = item.ordered ? 'ol' : 'ul';
if (prevDepth === null) {
html += `<${tag}>`;
stack.push(tag);
} else if (item.depth > prevDepth) {
for (let d = prevDepth; d < item.depth; d++) {
html += `<${tag}>`;
stack.push(tag);
}
} else {
html += '</li>';
for (let d = item.depth; d < prevDepth; d++) html += `</${stack.pop()}></li>`;
}
const check = item.checked == null ? ''
: `<input type="checkbox" disabled${item.checked ? ' checked' : ''}> `;
html += `<li>${check}${renderInline(item.text, resolveMention)}`;
prevDepth = item.depth;
}
html += '</li>';
while (stack.length > 1) html += `</${stack.pop()}></li>`;
html += `</${stack.pop()}>`;
return html;
}
export function renderMarkdown(markdown, { resolveMention = null, rawHtml = true } = {}) {
const blocks = parseBlocks(markdown);
let html = '';
for (const b of blocks) {
switch (b.type) {
case 'heading':
html += `<h${b.level}>${renderInline(b.text, resolveMention)}</h${b.level}>\n`;
break;
case 'paragraph':
html += `<p>${renderInline(b.text, resolveMention)}</p>\n`;
break;
case 'code':
html += `<pre><code${b.lang ? ` class="language-${escapeHtml(b.lang)}"` : ''}>${escapeHtml(b.text)}</code></pre>\n`;
break;
case 'mermaid':
html += `<pre class="mermaid">${escapeHtml(b.text)}</pre>\n`;
break;
case 'html':
html += rawHtml ? b.text + '\n' : `<p>${escapeHtml(b.text)}</p>\n`;
break;
case 'quote':
html += `<blockquote>${renderMarkdown(b.text, { resolveMention, rawHtml })}</blockquote>\n`;
break;
case 'hr':
html += '<hr>\n';
break;
case 'list':
html += renderList(b.items, resolveMention) + '\n';
break;
case 'table': {
const cells = (row, tag) => row.map((c) => `<${tag}>${renderInline(c, resolveMention)}</${tag}>`).join('');
html += `<table><thead><tr>${cells(b.header, 'th')}</tr></thead><tbody>`;
for (const r of b.rows) html += `<tr>${cells(r, 'td')}</tr>`;
html += '</tbody></table>\n';
break;
}
}
}
return html;
}
export function isHtmlDocument(text) {
return typeof text === 'string' && /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(text);
}
export function renderDocumentPage({ title, subtitle = '', markdown, resolveMention = null }) {
const body = renderMarkdown(markdown, { resolveMention });
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<link rel="icon" type="image/svg+xml" href="/brand/weave-favicon.svg">
<link rel="stylesheet" href="/chip.css">
<style>
:root { --fg: #1a1d23; --muted: #6b7280; --line: #e5e7eb; --accent: #4f46e5; --bg: #ffffff; --soft: #f6f7f9; --code-bg: #ffffff; }
@media (prefers-color-scheme: dark) {
:root { --fg: #e5e7eb; --muted: #9ca3af; --line: #30343c; --accent: #818cf8; --bg: #111318; --soft: #1a1d23; --code-bg: #0d1117; }
}
:root {
--tblr-body-color: var(--fg); --tblr-secondary: var(--muted); --tblr-border-color: var(--line);
--tblr-primary: var(--accent); --tblr-danger: #e5484d; --tblr-bg-surface: var(--bg);
--tblr-bg-surface-secondary: var(--soft);
--tblr-font-monospace: ui-monospace, "SF Mono", Menlo, monospace;
--fs-meta: 11px; --fs-secondary: 12px; --fs-grid: 13px; --fs-body: 14px; --fs-doc: 16px; --fs-h2: 18px; --fs-h1: 20px; --fs-title: 24px;
}
* { box-sizing: border-box; }
body { margin: 0 auto; max-width: 760px; padding: 48px 24px; background: var(--bg); color: var(--fg);
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
h1, h2, h3, h4 { line-height: 1.25; margin: 1.4em 0 0.5em; }
h1:first-child { margin-top: 0; }
.doc-meta { color: var(--muted); font-size: 13px; margin-bottom: 2em; border-bottom: 1px solid var(--line); padding-bottom: 1em; }
a { color: var(--accent); }
.md-icon { display: inline-flex; width: 1em; height: 1em; vertical-align: -.15em; margin-right: .15em; }
.md-icon svg { width: 1em; height: 1em; }
code { background: var(--soft); border-radius: 4px; padding: 1px 5px; font-size: 0.9em; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
/* A code block sits on the ground its palette was drawn for — white for
github, #0d1117 for github-dark — because a token colour answers to the
surface under it, not to the theme around it (Issue #81). On --soft's grey
the github keyword red measured 4.27:1, under AA by a hair. The border is
what makes the block a block when the slab matches the page. */
pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; padding: 14px; overflow-x: auto; position: relative; }
pre code { background: none; padding: 0; }
/* hljs contributes token colours only; the block chrome (background, border,
padding, copy button) stays the page's own. */
pre code.hljs { background: none; padding: 0; }
/* Code is there to be taken, so the button is always on the block rather than
waiting for a hover that a touch screen never sends. */
.code-copy { position: absolute; top: 8px; right: 8px; border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); color: var(--muted); font: 11px/1 ui-monospace, "SF Mono", Menlo, monospace;
padding: 5px 7px; cursor: pointer; opacity: .6; }
.code-copy:hover { opacity: 1; color: var(--fg); }
blockquote { border-left: 3px solid var(--line); margin: 1em 0; padding: 2px 0 2px 16px; color: var(--muted); }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid var(--line); padding: 6px 10px; text-align: left; }
th { background: var(--soft); }
hr { border: none; border-top: 1px solid var(--line); margin: 2em 0; }
img { display: block; margin: 8px auto; max-width: 60%; }
iframe.wv-file { display: block; margin: 8px auto; width: 60%; max-width: 60%; aspect-ratio: 4 / 3;
border: 1px solid var(--line); border-radius: 6px; background: var(--bg); }
pre.mermaid { background: var(--bg); border: 1px dashed var(--line); text-align: center; }
@media print { .pagebreak { page-break-after: always; break-after: page; } .code-copy { display: none; } }
</style>
</head>
<body>
<div class="doc-meta">${escapeHtml(subtitle)}</div>
<div class="wv-prose">
${body}
</div>
<script>
for (const pre of document.querySelectorAll('pre:not(.mermaid)')) {
const btn = document.createElement('button');
btn.className = 'code-copy';
btn.type = 'button';
btn.textContent = 'Copy';
btn.onclick = async () => {
const code = pre.querySelector('code') ?? pre;
try { await navigator.clipboard.writeText(code.textContent); btn.textContent = 'Copied'; }
catch { btn.textContent = 'Press ⌘C'; getSelection().selectAllChildren(code); }
setTimeout(() => { btn.textContent = 'Copy'; }, 1400);
};
pre.append(btn);
}
document.addEventListener('click', (ev) => {
const caret = ev.target.closest('.mention-caret');
if (!caret) return;
ev.preventDefault();
const open = caret.closest('.k-rel').classList.toggle('open');
caret.setAttribute('aria-expanded', String(open));
});
</script>
${body.includes('class="mermaid"') ? `<script src="/vendor/mermaid.min.js" onerror="document.querySelectorAll('pre.mermaid').forEach(p=>p.style.textAlign='left')"></script>
<script>if (window.mermaid) mermaid.initialize({ startOnLoad: true, theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default' });</script>` : ''}
${
body.includes('<pre><code') ? `<link rel="stylesheet" href="/vendor/vditor/dist/js/highlight.js/styles/github.min.css" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="/vendor/vditor/dist/js/highlight.js/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
<script src="/vendor/vditor/dist/js/highlight.js/highlight.min.js"></script>
<script src="/editor-lib.js"></script>
<script>
const OWNED = /language-(mermaid|mmd|math|graphviz|plantuml|echarts|mindmap|abc|flowchart)\\b/;
if (window.hljs) for (const code of document.querySelectorAll('pre > code')) {
if (OWNED.test(code.className)) continue;
if (/language-\\S/.test(code.className)) { hljs.highlightElement(code); continue; }
const text = code.textContent || '';
const lang = window.WeaveEditorLib && WeaveEditorLib.detectCodeLanguage(text);
if (!lang) continue;
try {
code.innerHTML = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
code.className = 'hljs language-' + lang;
} catch (e) { /* the language is not in the vendored bundle */ }
}
</script>` : ''}
</body>
</html>`;
}