'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, ''');
}
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, '$1')
.replace(/~~([^~]+)~~/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1$2');
}
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 + ''));
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, src) => {
const kind = classifyUrl(src);
if (kind !== 'http' && kind !== 'relative') return alt;
return stash('');
});
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('' + text + '');
});
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 '
' + escapeHtml(body.join('\n')) + '');
continue;
}
const cls = lang ? ' class="language-' + lang + '"' : '';
out.push('' + escapeHtml(body.join('\n')) + '');
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
if (heading) {
const level = heading[1].length;
out.push('\n' + renderMarkdown(inner.join('\n')) + '\n'); 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) => '
' + renderInline(para.join('\n')) + '
'); } return out.join('\n'); } module.exports = { renderMarkdown, escapeHtml, slugify };