d48cda4081
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
278 lines
8.7 KiB
JavaScript
278 lines
8.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Minimal GitHub-flavored-markdown subset renderer for Plan Canvas.
|
|
* Renders .claude/plans/*.plan.md artifacts to HTML body content.
|
|
*
|
|
* Security model: the entire source line is HTML-escaped before any inline
|
|
* rule runs, so raw HTML in the markdown always displays as text. Link and
|
|
* image URLs are validated against an allowlist of protocols.
|
|
*/
|
|
|
|
// Placeholders live in the Unicode private-use area so escaped output can
|
|
// never collide with them. Pre-existing occurrences are stripped from input.
|
|
const TOKEN_OPEN = '\uE000';
|
|
const TOKEN_CLOSE = '\uE001';
|
|
const TOKEN_RE = new RegExp(TOKEN_OPEN + '(\\d+)' + TOKEN_CLOSE, 'g');
|
|
const STRIP_RE = new RegExp('[' + TOKEN_OPEN + TOKEN_CLOSE + ']', 'g');
|
|
|
|
const LIST_ITEM_RE = /^(\s*)([-*]|\d+\.)\s+(.*)$/;
|
|
const HR_RE = /^ {0,3}(-{3,}|\*{3,})\s*$/;
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function slugify(text) {
|
|
return String(text ?? '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.trim()
|
|
.replace(/[\s-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
// Strip whitespace/control characters so "Ja vaScript:" style tricks cannot
|
|
// hide a scheme, then classify against the allowlist.
|
|
function classifyUrl(rawUrl) {
|
|
const compact = String(rawUrl)
|
|
.split('')
|
|
.filter((ch) => ch.charCodeAt(0) > 32)
|
|
.join('')
|
|
.toLowerCase();
|
|
if (compact.startsWith('#')) return 'anchor';
|
|
if (compact.startsWith('//')) return 'blocked';
|
|
const scheme = compact.match(/^[a-z][a-z0-9+.-]*:/);
|
|
if (!scheme) return 'relative';
|
|
if (scheme[0] === 'http:' || scheme[0] === 'https:') return 'http';
|
|
if (scheme[0] === 'mailto:') return 'mailto';
|
|
return 'blocked';
|
|
}
|
|
|
|
function applyEmphasis(s) {
|
|
return s
|
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
|
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
.replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1<em>$2</em>');
|
|
}
|
|
|
|
function renderInline(rawText) {
|
|
const tokens = [];
|
|
const stash = (html) => {
|
|
tokens.push(html);
|
|
return TOKEN_OPEN + (tokens.length - 1) + TOKEN_CLOSE;
|
|
};
|
|
|
|
let s = escapeHtml(rawText);
|
|
|
|
// Code spans first: contents stay escaped and opt out of all other rules.
|
|
s = s.replace(/`([^`]+)`/g, (_m, code) => stash('<code>' + code + '</code>'));
|
|
|
|
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, src) => {
|
|
const kind = classifyUrl(src);
|
|
if (kind !== 'http' && kind !== 'relative') return alt;
|
|
return stash('<img src="' + src.trim() + '" alt="' + alt + '">');
|
|
});
|
|
|
|
s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, label, url) => {
|
|
const kind = classifyUrl(url);
|
|
const text = applyEmphasis(label);
|
|
if (kind === 'blocked') return text;
|
|
const extra = kind === 'http' ? ' target="_blank" rel="noopener"' : '';
|
|
return stash('<a href="' + url.trim() + '"' + extra + '>' + text + '</a>');
|
|
});
|
|
|
|
s = applyEmphasis(s);
|
|
|
|
// Stashed anchors may hold code-span tokens, so resolve until none remain.
|
|
while (s.includes(TOKEN_OPEN)) {
|
|
s = s.replace(TOKEN_RE, (_m, idx) => tokens[Number(idx)]);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function splitTableRow(line) {
|
|
let s = line.trim();
|
|
if (s.startsWith('|')) s = s.slice(1);
|
|
if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
|
|
return s
|
|
.replace(/\\\|/g, TOKEN_OPEN)
|
|
.split('|')
|
|
.map((cell) => cell.split(TOKEN_OPEN).join('|').trim());
|
|
}
|
|
|
|
function isAlignmentRow(line) {
|
|
if (!line || !line.includes('|')) return false;
|
|
const cells = splitTableRow(line);
|
|
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
|
}
|
|
|
|
function cellAlign(spec) {
|
|
const left = spec.startsWith(':');
|
|
const right = spec.endsWith(':');
|
|
if (left && right) return 'center';
|
|
if (right) return 'right';
|
|
if (left) return 'left';
|
|
return '';
|
|
}
|
|
|
|
function renderListItem(text) {
|
|
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
|
|
if (task) {
|
|
const checked = task[1].trim() ? ' checked' : '';
|
|
return '<li class="task"><input type="checkbox" disabled' + checked + '> ' +
|
|
renderInline(task[2]) + '</li>';
|
|
}
|
|
return '<li>' + renderInline(text) + '</li>';
|
|
}
|
|
|
|
function buildList(items, start, indent) {
|
|
const tag = /^\d/.test(items[start].marker) ? 'ol' : 'ul';
|
|
const parts = [];
|
|
let i = start;
|
|
while (i < items.length && items[i].indent >= indent) {
|
|
if (items[i].indent > indent) {
|
|
// Deeper item: nest a sublist inside the previous <li>
|
|
const nested = buildList(items, i, items[i].indent);
|
|
if (parts.length > 0) {
|
|
const last = parts.pop();
|
|
parts.push(last.replace(/<\/li>$/, '\n' + nested.html + '\n</li>'));
|
|
} else {
|
|
parts.push('<li>\n' + nested.html + '\n</li>');
|
|
}
|
|
i = nested.end;
|
|
} else {
|
|
parts.push(renderListItem(items[i].text));
|
|
i += 1;
|
|
}
|
|
}
|
|
return { html: '<' + tag + '>\n' + parts.join('\n') + '\n</' + tag + '>', end: i };
|
|
}
|
|
|
|
function startsBlock(line, nextLine) {
|
|
return /^```/.test(line) ||
|
|
/^#{1,6}\s/.test(line) ||
|
|
HR_RE.test(line) ||
|
|
/^ {0,3}>/.test(line) ||
|
|
LIST_ITEM_RE.test(line) ||
|
|
(line.includes('|') && isAlignmentRow(nextLine || ''));
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
if (!text) return '';
|
|
const lines = String(text)
|
|
.replace(STRIP_RE, '')
|
|
.replace(/\r\n?/g, '\n')
|
|
.split('\n');
|
|
const out = [];
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
|
|
if (!line.trim()) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
const fence = line.match(/^```(.*)$/);
|
|
if (fence) {
|
|
const lang = fence[1].trim().split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
const body = [];
|
|
i += 1;
|
|
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
|
body.push(lines[i]);
|
|
i += 1;
|
|
}
|
|
i += 1; // skip closing fence (or run off EOF)
|
|
if (lang === 'mermaid') {
|
|
// Mermaid reads the element's textContent, and the browser decodes
|
|
// character references there — so escaping keeps `-->`/`<` intact for
|
|
// the renderer while preventing HTML injection or a </pre> breakout.
|
|
out.push('<pre class="mermaid">' + escapeHtml(body.join('\n')) + '</pre>');
|
|
continue;
|
|
}
|
|
const cls = lang ? ' class="language-' + lang + '"' : '';
|
|
out.push('<pre><code' + cls + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
|
continue;
|
|
}
|
|
|
|
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
if (heading) {
|
|
const level = heading[1].length;
|
|
out.push('<h' + level + ' id="' + slugify(heading[2]) + '">' +
|
|
renderInline(heading[2]) + '</h' + level + '>');
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
// Horizontal rule (alignment rows never reach here: tables consume them)
|
|
if (HR_RE.test(line)) {
|
|
out.push('<hr>');
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
// Blockquote: strip one `>` level and recurse, which handles nesting
|
|
if (/^ {0,3}>/.test(line)) {
|
|
const inner = [];
|
|
while (i < lines.length && /^ {0,3}>/.test(lines[i])) {
|
|
inner.push(lines[i].replace(/^ {0,3}> ?/, ''));
|
|
i += 1;
|
|
}
|
|
out.push('<blockquote>\n' + renderMarkdown(inner.join('\n')) + '\n</blockquote>');
|
|
continue;
|
|
}
|
|
|
|
// Table: header row followed by an alignment row
|
|
if (line.includes('|') && isAlignmentRow(lines[i + 1] || '')) {
|
|
const aligns = splitTableRow(lines[i + 1]).map(cellAlign);
|
|
const row = (tag, cells) => '<tr>' + cells.map((cell, idx) => {
|
|
const style = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : '';
|
|
return '<' + tag + style + '>' + renderInline(cell) + '</' + tag + '>';
|
|
}).join('') + '</tr>';
|
|
const head = row('th', splitTableRow(line));
|
|
const body = [];
|
|
i += 2;
|
|
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
|
body.push(row('td', splitTableRow(lines[i])));
|
|
i += 1;
|
|
}
|
|
out.push('<table>\n<thead>\n' + head + '\n</thead>\n<tbody>\n' +
|
|
body.join('\n') + '\n</tbody>\n</table>');
|
|
continue;
|
|
}
|
|
|
|
if (LIST_ITEM_RE.test(line)) {
|
|
const items = [];
|
|
while (i < lines.length) {
|
|
const m = lines[i].match(LIST_ITEM_RE);
|
|
if (!m) break;
|
|
items.push({ indent: m[1].length, marker: m[2], text: m[3] });
|
|
i += 1;
|
|
}
|
|
out.push(buildList(items, 0, items[0].indent).html);
|
|
continue;
|
|
}
|
|
|
|
// Paragraph: run of plain lines up to a blank line or block start
|
|
const para = [line.trim()];
|
|
i += 1;
|
|
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
|
|
para.push(lines[i].trim());
|
|
i += 1;
|
|
}
|
|
out.push('<p>' + renderInline(para.join('\n')) + '</p>');
|
|
}
|
|
|
|
return out.join('\n');
|
|
}
|
|
|
|
module.exports = { renderMarkdown, escapeHtml, slugify };
|