Skip to content

Commit 92a2054

Browse files
forge: GFM tables in markdown + extract renderer to lib/markdown.js
Adds GitHub-flavored tables to the forge's markdown renderer: a header row followed by a | --- | :--: | ---: | delimiter becomes a <table> with per-column alignment (left/center/right), cells run through the same escape-first mdInline (structurally XSS-proof — verified by a test that a cell's <img onerror> is neutralized), escaped \| inside cells honored, bounded by column/row caps. GitHub-style table CSS added to .markdown-body. Also folds the whole renderer (safeHref + mdInline + renderMarkdown) into lib/markdown.js — a third split slice; only renderMarkdown is public, imported back into plugin.js. Tests: +3 (130 total, all green).
1 parent 545189e commit 92a2054

3 files changed

Lines changed: 149 additions & 85 deletions

File tree

forge/lib/markdown.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// forge/lib/markdown.js — the forge's bounded, escape-first markdown renderer,
2+
// extracted from plugin.js. Deliberately small grammar, structurally XSS-proof:
3+
// raw text is HTML-escaped BEFORE any tag is introduced. Blocks: #..###### h1-h6,
4+
// ``` fenced code, > blockquote, -/* and 1. lists, GFM tables, paragraphs. No
5+
// nesting, no HTML passthrough. Inline: `code` **bold** *em*/_em_ [t](href)
6+
// ![alt](src). Only renderMarkdown is public (mdInline/safeHref stay internal).
7+
import { esc } from './helpers.js';
8+
9+
const TABLE_COL_CAP = 100; // columns rendered per table (defense in depth)
10+
const TABLE_ROW_CAP = 1000; // body rows rendered per table
11+
12+
function safeHref(url, base) {
13+
if (/^https?:\/\//i.test(url)) return url;
14+
if (url.startsWith('#')) return url;
15+
if (url.startsWith('//')) return null;
16+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return null; // javascript:, data:, …
17+
let rel = url.replace(/^\.\//, '');
18+
if (rel.startsWith('/') || rel.split('/').some((s) => s === '..' || s === '')) return null;
19+
return `${base}/${rel}`;
20+
}
21+
22+
function mdInline(escaped, { rawBase, blobBase }) {
23+
const codes = [];
24+
let s = escaped.replace(/`([^`]+)`/g, (m, c) => { codes.push(c); return `\x01${codes.length - 1}\x01`; });
25+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => {
26+
const href = safeHref(url, rawBase);
27+
return href ? `<img src="${href}" alt="${alt}">` : m;
28+
});
29+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, url) => {
30+
const href = safeHref(url, blobBase);
31+
return href ? `<a href="${href}">${text}</a>` : m;
32+
});
33+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
34+
s = s.replace(/\*([^*\s][^*]*)\*/g, '<em>$1</em>');
35+
s = s.replace(/(^|\s)_([^_]+)_(?=\s|$)/g, '$1<em>$2</em>');
36+
return s.replace(/\x01(\d+)\x01/g, (m, i) => `<code>${codes[i] ?? ''}</code>`);
37+
}
38+
39+
// --- GFM tables: a header row (any line with a pipe) immediately followed by a
40+
// delimiter row of dashes/colons — | --- | :--: | ---: | — with optional outer
41+
// pipes. Colons set per-column alignment. Cells are inline-only, escaped first.
42+
const TABLE_DELIM = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
43+
function splitRow(line) {
44+
let t = line.trim();
45+
if (t.startsWith('|')) t = t.slice(1);
46+
t = t.replace(/\|\s*$/, '');
47+
return t.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
48+
}
49+
function alignOf(cell) {
50+
const l = cell.startsWith(':');
51+
const r = cell.endsWith(':');
52+
return (l && r) ? 'center' : r ? 'right' : l ? 'left' : '';
53+
}
54+
55+
export function renderMarkdown(src, ctx) {
56+
const lines = String(src).replace(/[\x00\x01]/g, '').replace(/\r\n?/g, '\n').split('\n');
57+
const out = [];
58+
let para = [];
59+
let list = null; // { tag, items }
60+
let quote = [];
61+
const flushPara = () => { if (para.length) { out.push(`<p>${mdInline(esc(para.join(' ')), ctx)}</p>`); para = []; } };
62+
const flushList = () => { if (list) { out.push(`<${list.tag}>${list.items.map((i) => `<li>${i}</li>`).join('')}</${list.tag}>`); list = null; } };
63+
const flushQuote = () => { if (quote.length) { out.push(`<blockquote><p>${mdInline(esc(quote.join(' ')), ctx)}</p></blockquote>`); quote = []; } };
64+
const flushAll = () => { flushPara(); flushList(); flushQuote(); };
65+
66+
for (let i = 0; i < lines.length; i++) {
67+
const line = lines[i];
68+
const fence = /^```/.exec(line);
69+
if (fence) {
70+
flushAll();
71+
const code = [];
72+
i += 1;
73+
while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i += 1; }
74+
out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`);
75+
continue;
76+
}
77+
if (line.includes('|') && i + 1 < lines.length && TABLE_DELIM.test(lines[i + 1])) {
78+
flushAll();
79+
const header = splitRow(line).slice(0, TABLE_COL_CAP);
80+
const aligns = splitRow(lines[i + 1]).map(alignOf);
81+
const rows = [];
82+
let j = i + 2;
83+
while (j < lines.length && lines[j].includes('|') && !/^\s*$/.test(lines[j]) && rows.length < TABLE_ROW_CAP) {
84+
rows.push(splitRow(lines[j])); j += 1;
85+
}
86+
const cell = (txt, tag, al) => `<${tag}${al ? ` style="text-align:${al}"` : ''}>${mdInline(esc(txt ?? ''), ctx)}</${tag}>`;
87+
const thead = `<tr>${header.map((c, k) => cell(c, 'th', aligns[k])).join('')}</tr>`;
88+
const tbody = rows.map((r) => `<tr>${header.map((_, k) => cell(r[k], 'td', aligns[k])).join('')}</tr>`).join('');
89+
out.push(`<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`);
90+
i = j - 1;
91+
continue;
92+
}
93+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
94+
if (h) { flushAll(); out.push(`<h${h[1].length}>${mdInline(esc(h[2].trim()), ctx)}</h${h[1].length}>`); continue; }
95+
const q = /^>\s?(.*)$/.exec(line);
96+
if (q) { flushPara(); flushList(); quote.push(q[1]); continue; }
97+
const ul = /^\s*[-*]\s+(.*)$/.exec(line);
98+
if (ul) {
99+
flushPara(); flushQuote();
100+
if (!list || list.tag !== 'ul') { flushList(); list = { tag: 'ul', items: [] }; }
101+
list.items.push(mdInline(esc(ul[1]), ctx));
102+
continue;
103+
}
104+
const ol = /^\s*\d+\.\s+(.*)$/.exec(line);
105+
if (ol) {
106+
flushPara(); flushQuote();
107+
if (!list || list.tag !== 'ol') { flushList(); list = { tag: 'ol', items: [] }; }
108+
list.items.push(mdInline(esc(ol[1]), ctx));
109+
continue;
110+
}
111+
if (/^\s*$/.test(line)) { flushAll(); continue; }
112+
flushList(); flushQuote();
113+
para.push(line.trim());
114+
}
115+
flushAll();
116+
return out.join('\n');
117+
}

forge/plugin.js

Lines changed: 7 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ import {
142142
ICON_DIR, ICON_FILE, ICON_REPO, ICON_BRANCH, ICON_TAG, ICON_ISSUE_OPEN, ICON_ISSUE_CLOSED,
143143
ICON_PR_OPEN, ICON_PR_TAB, ICON_PR_MERGED, ICON_PR_CLOSED,
144144
} from './lib/helpers.js';
145+
import { renderMarkdown } from './lib/markdown.js';
145146

146147
const execFileP = promisify(execFile);
147148

@@ -317,91 +318,8 @@ async function readJsonBody(request, cap = ISSUE_BODY_CAP + 8192) {
317318
} catch { return null; }
318319
}
319320

320-
// ------------------------------------------------------ markdown (bounded)
321-
// Grammar (deliberately small; escape-first, so it is structurally
322-
// XSS-proof — raw text is HTML-escaped BEFORE any tag is introduced):
323-
// blocks: # h1..###### h6 | ``` fenced code | > blockquote |
324-
// -/* unordered list | 1. ordered list | paragraphs (blank-line
325-
// separated). No nesting, no tables, no HTML passthrough.
326-
// inline: `code` | **bold** | *em* / _em_ | [text](href) |
327-
// ![alt](src). hrefs: http(s) or relative only (no other
328-
// schemes, no scheme-relative //); relative images are routed
329-
// through the raw endpoint, relative links through blob view.
330-
331-
function safeHref(url, base) {
332-
if (/^https?:\/\//i.test(url)) return url;
333-
if (url.startsWith('#')) return url;
334-
if (url.startsWith('//')) return null;
335-
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return null; // javascript:, data:, …
336-
let rel = url.replace(/^\.\//, '');
337-
if (rel.startsWith('/') || rel.split('/').some((s) => s === '..' || s === '')) return null;
338-
return `${base}/${rel}`;
339-
}
340-
341-
function mdInline(escaped, { rawBase, blobBase }) {
342-
const codes = [];
343-
let s = escaped.replace(/`([^`]+)`/g, (m, c) => { codes.push(c); return `\x01${codes.length - 1}\x01`; });
344-
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => {
345-
const href = safeHref(url, rawBase);
346-
return href ? `<img src="${href}" alt="${alt}">` : m;
347-
});
348-
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, url) => {
349-
const href = safeHref(url, blobBase);
350-
return href ? `<a href="${href}">${text}</a>` : m;
351-
});
352-
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
353-
s = s.replace(/\*([^*\s][^*]*)\*/g, '<em>$1</em>');
354-
s = s.replace(/(^|\s)_([^_]+)_(?=\s|$)/g, '$1<em>$2</em>');
355-
return s.replace(/\x01(\d+)\x01/g, (m, i) => `<code>${codes[i] ?? ''}</code>`);
356-
}
357-
358-
function renderMarkdown(src, ctx) {
359-
const lines = String(src).replace(/[\x00\x01]/g, '').replace(/\r\n?/g, '\n').split('\n');
360-
const out = [];
361-
let para = [];
362-
let list = null; // { tag, items }
363-
let quote = [];
364-
const flushPara = () => { if (para.length) { out.push(`<p>${mdInline(esc(para.join(' ')), ctx)}</p>`); para = []; } };
365-
const flushList = () => { if (list) { out.push(`<${list.tag}>${list.items.map((i) => `<li>${i}</li>`).join('')}</${list.tag}>`); list = null; } };
366-
const flushQuote = () => { if (quote.length) { out.push(`<blockquote><p>${mdInline(esc(quote.join(' ')), ctx)}</p></blockquote>`); quote = []; } };
367-
const flushAll = () => { flushPara(); flushList(); flushQuote(); };
368-
369-
for (let i = 0; i < lines.length; i++) {
370-
const line = lines[i];
371-
const fence = /^```/.exec(line);
372-
if (fence) {
373-
flushAll();
374-
const code = [];
375-
i += 1;
376-
while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i += 1; }
377-
out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`);
378-
continue;
379-
}
380-
const h = /^(#{1,6})\s+(.*)$/.exec(line);
381-
if (h) { flushAll(); out.push(`<h${h[1].length}>${mdInline(esc(h[2].trim()), ctx)}</h${h[1].length}>`); continue; }
382-
const q = /^>\s?(.*)$/.exec(line);
383-
if (q) { flushPara(); flushList(); quote.push(q[1]); continue; }
384-
const ul = /^\s*[-*]\s+(.*)$/.exec(line);
385-
if (ul) {
386-
flushPara(); flushQuote();
387-
if (!list || list.tag !== 'ul') { flushList(); list = { tag: 'ul', items: [] }; }
388-
list.items.push(mdInline(esc(ul[1]), ctx));
389-
continue;
390-
}
391-
const ol = /^\s*\d+\.\s+(.*)$/.exec(line);
392-
if (ol) {
393-
flushPara(); flushQuote();
394-
if (!list || list.tag !== 'ol') { flushList(); list = { tag: 'ol', items: [] }; }
395-
list.items.push(mdInline(esc(ol[1]), ctx));
396-
continue;
397-
}
398-
if (/^\s*$/.test(line)) { flushAll(); continue; }
399-
flushList(); flushQuote();
400-
para.push(line.trim());
401-
}
402-
flushAll();
403-
return out.join('\n');
404-
}
321+
// markdown renderer (escape-first grammar + GFM tables) moved to
322+
// ./lib/markdown.js — renderMarkdown imported above.
405323

406324
// ------------------------------------------------------------- diff parse
407325
// ONE structured parse feeds both the HTML renderer and the JSON API's
@@ -524,6 +442,10 @@ table.files td.age{color:#59636e;text-align:right;white-space:nowrap}
524442
.markdown-body code{background:rgba(129,139,152,0.12);border-radius:6px;padding:.2em .4em;font-size:85%}
525443
.markdown-body pre{background:#f6f8fa;border-radius:6px;padding:16px;overflow:auto;font-size:85%;line-height:1.45}
526444
.markdown-body pre code{background:transparent;padding:0;font-size:100%}
445+
.markdown-body table{border-collapse:collapse;margin:0 0 16px;display:block;width:max-content;max-width:100%;overflow-x:auto}
446+
.markdown-body th,.markdown-body td{border:1px solid #d0d7de;padding:6px 13px}
447+
.markdown-body th{background:#f6f8fa;font-weight:600}
448+
.markdown-body tr:nth-child(2n) td{background:#f6f8fa}
527449
.markdown-body blockquote{margin:0 0 16px;padding:0 1em;color:#59636e;border-left:.25em solid #d0d7de}
528450
.markdown-body img{max-width:100%}
529451
.clonebox{display:flex;gap:8px;align-items:center}

forge/test.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { promisify } from 'node:util';
2121
import { schnorr } from '@noble/curves/secp256k1';
2222
import { startJss } from '../helpers.js';
2323
import { markStateHash, npubEncode, trailAddress, trailProgram } from './plugin.js';
24+
import { renderMarkdown } from './lib/markdown.js';
2425

2526
const execFileP = promisify(execFile);
2627
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)));
@@ -127,6 +128,30 @@ const README_MD = [
127128

128129
const BINARY = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x00, 0xff, 0xfe, 0x00, 0x89, 0x50]);
129130

131+
describe('markdown GFM tables (lib/markdown.js)', () => {
132+
const ctx = { rawBase: '/raw', blobBase: '/blob' };
133+
it('renders a table with header row, per-column alignment, and inline cells', () => {
134+
const md = ['| Name | Score |', '| :--- | ---: |', '| **a** | `9` |', '| b | 10 |'].join('\n');
135+
const html = renderMarkdown(md, ctx);
136+
assert.match(html, /<table><thead>/);
137+
assert.match(html, /<th style="text-align:left">Name<\/th>/);
138+
assert.match(html, /<th style="text-align:right">Score<\/th>/);
139+
assert.match(html, /<td style="text-align:left"><strong>a<\/strong><\/td>/);
140+
assert.match(html, /<td style="text-align:right"><code>9<\/code><\/td>/);
141+
assert.match(html, /<td style="text-align:right">10<\/td>/);
142+
assert.ok(!html.includes('<p>| Name'), 'the header row is a table, not a paragraph');
143+
});
144+
it('is escape-first: HTML in a cell is neutralized', () => {
145+
const html = renderMarkdown('| x |\n| - |\n| <img src=x onerror=alert(1)> |', ctx);
146+
assert.match(html, /&lt;img src=x/);
147+
assert.ok(!/<img src=x/.test(html), 'no raw <img> tag survives');
148+
});
149+
it('honors escaped pipes inside cells', () => {
150+
const html = renderMarkdown('| a | b |\n| - | - |\n| x \\| y | z |', ctx);
151+
assert.match(html, /<td[^>]*>x \| y<\/td>/);
152+
});
153+
});
154+
130155
describe('forge plugin', () => {
131156
let jss;
132157
let casey; // { access_token, webid }

0 commit comments

Comments
 (0)