mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-24 08:22:24 +02:00
* feat: add Plan Canvas - browser annotate-and-approve review for plan artifacts - scripts/plan-canvas.js CLI (open/await/end/stop/server; bin ecc-plan-canvas) - loopback server + ECC-styled chrome + annotation SDK + zero-dep markdown renderer - Approve/Request-changes verdicts wired to the /plan confirmation gate - plan-canvas skill, /plan-canvas command, SessionStart hook surfacing open reviews - shared scripts/lib/loopback-guard.js extracted from control-pane (API re-exported) - 121 new tests incl. full-workflow E2E; registered in manifests, catalog, registry Inspired by lavish-axi (https://github.com/kunchenguid/lavish-axi) by @kunchenguid; original ECC-native implementation, not a port. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(plan-canvas): invoke via ecc-plan-canvas bin so the skill works from any project Skill/command referenced a cwd-relative `node scripts/plan-canvas.js`, unusable outside the ECC root. Switch to the ecc-plan-canvas bin (and $CLAUDE_PLUGIN_ROOT fallback) and align CLI next_step hints so an agent can run it as a skill in any repo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plan-canvas): render Mermaid diagrams + ship Codex cross-harness surface - markdown renderer emits <pre class="mermaid"> for ```mermaid blocks (source entity-escaped so the browser decodes it for the renderer while blocking injection) - artifact template loads a pinned Mermaid build only when a diagram is present, themed to ECC dark, securityLevel strict, graceful offline fallback to source (ECC_PLAN_CANVAS_MERMAID_URL overrides for a local mirror) - skill teaches Mermaid-for-diagrams and states the CLI+JSON loop is harness-agnostic - add .agents/skills/plan-canvas (Codex) with agents/openai.yaml interface manifest - register in install-modules workflow-quality paths; docs updated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan-canvas): add demo screenshot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): sync yarn.lock with new bin; add contributor checklist - yarn.lock records the ecc-plan-canvas bin so Yarn hardened-mode install no longer wants to modify the lockfile on public PRs - PR template + CONTRIBUTING gain a pre-push checklist covering the lockfile trap and the full skill/command/CLI registration surfaces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Haley Chen <2022hachen@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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 };
|