openvibers/openvibe.services Commits

ADR pages draw the decision records' pipe tables (14 ADRs have them; they showed as a paragraph of pipes) (#116)

OP OpenVibers committed 17 days ago · 93f8718c9e · 2 files, +59 −3

Changes

ddiff --git a/server/render/markdown.js b/server/render/markdown.js
iindex 0b58a74..c39703b 100644
---- a/server/render/markdown.js
++++ b/server/render/markdown.js
@@@ -3,8 +3,8 @@
/**
* A deliberately small Markdown renderer for the ADRs shipped inside openvibe-contracts (the policy
* pages render them verbatim, so the policy cannot drift from the decision record). Everything is
-- * escaped first; only headings, paragraphs, lists, fenced code, bold, inline code and links are
-- * recognised. Links are kept only when http(s) or relative.
++ * escaped first; only headings, paragraphs, lists, fenced code, pipe tables, bold, inline code and
++ * links are recognised. Links are kept only when http(s) or relative.
*/
const { esc } = require('./html');
@@@ -20,6 +20,25 @@ function inline(s) {
return out;
}
++/** A table row's cells: split on pipes not escaped as \|, one optional outer pipe on each side. */
++function cells(line) {
++ let t = line.trim();
++ if (t.startsWith('|')) t = t.slice(1);
++ if (t.endsWith('|') && !t.endsWith('\\|')) t = t.slice(0, -1);
++ const out = [];
++ let cur = '';
++ for (let k = 0; k < t.length; k++) {
++ if (t[k] === '\\' && t[k + 1] === '|') { cur += '|'; k++; continue; }
++ if (t[k] === '|') { out.push(cur.trim()); cur = ''; continue; }
++ cur += t[k];
++ }
++ out.push(cur.trim());
++ return out;
++}
++
++/** Whether a line is a table's delimiter row (| --- | :-: |), checked without a backtracking pattern. */
++const isDelimiter = (line) => !!line && line.includes('|') && /^[\s|:-]+$/.test(line) && cells(line).every((c) => /^:?-+:?$/.test(c));
++
function markdown(src, { headingOffset = 1 } = {}) {
const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
const out = [];
@@@ -28,12 +47,26 @@ function markdown(src, { headingOffset = 1 } = {}) {
let fence = null;
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
-- for (const line of lines) {
++ for (let i = 0; i < lines.length; i++) {
++ const line = lines[i];
if (fence) {
if (/^```/.test(line)) { out.push(`<pre tabindex="0"><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
continue;
}
if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }
++ // A pipe table (14 ADRs have them): a header row whose cells match the delimiter row under it, then rows while
++ // lines hold a pipe. It scrolls inside its own box, so it is a labelled region keyboard users can reach.
++ // At most 64 columns and 1000 rows, so a hostile table costs at most 64,000 cells.
++ if (line.includes('|') && isDelimiter(lines[i + 1]) && cells(line).length === cells(lines[i + 1]).length && cells(line).length <= 64) {
++ flushPara(); flushList();
++ const head = cells(line);
++ const rows = [];
++ for (i += 2; i < lines.length && lines[i].trim() && lines[i].includes('|') && rows.length < 1000; i++) rows.push(cells(lines[i]));
++ i--;
++ out.push(`<div class="table-wrap" tabindex="0" role="region" aria-label="Table: ${esc(head.join(', '))}"><table><thead><tr>${head.map((h) => `<th scope="col">${inline(h)}</th>`).join('')}</tr></thead>`
++ + `<tbody>${rows.map((r) => `<tr>${head.map((_, k) => `<td>${inline(r[k] || '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);
++ continue;
++ }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
flushPara(); flushList();
ddiff --git a/test/markdown.test.js b/test/markdown.test.js
nnew file mode 100644
iindex 0000000..2b3ddd0
---- /dev/null
++++ b/test/markdown.test.js
@@@ -0,0 +1,23 @@
++'use strict';
++/**
++ * The ADR renderer (server/render/markdown.js): pipe tables render as tables (14 ADRs have them; they showed as a
++ * paragraph of pipes), everything stays escaped, and a delimiter-looking line is checked in linear time.
++ */
++const assert = require('assert');
++const { markdown } = require('../server/render/markdown');
++
++const t = markdown('Intro\n\n| Concern | Owner |\n|---|:-:|\n| Rows | `authority` |\n| a \\| b | **x** |\n| <script> | |\n\nafter');
++assert.strictEqual(t, '<p>Intro</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Table: Concern, Owner"><table><thead><tr>'
++ + '<th scope="col">Concern</th><th scope="col">Owner</th></tr></thead><tbody><tr><td>Rows</td><td><code>authority</code></td></tr>'
++ + '<tr><td>a | b</td><td><strong>x</strong></td></tr><tr><td>&lt;script&gt;</td><td></td></tr></tbody></table></div>\n<p>after</p>');
++assert.strictEqual(markdown('a | b\nc | d'), '<p>a | b c | d</p>', 'pipes without a delimiter row are text');
++assert.strictEqual(markdown('| a | b |\n| --- |'), '<p>| a | b | | --- |</p>', 'a delimiter row must match the header');
++const started = Date.now();
++markdown(`${'|a'.repeat(20000)}|\n${'|-'.repeat(20000)}|\n${'| x | y |\n'.repeat(20000)}`);
++markdown(`| a |\n${'|-'.repeat(40000)}x`);
++// A wide header and many rows together stay bounded (64 columns, 1000 rows).
++markdown(`${'|a'.repeat(5000)}|\n${'|-'.repeat(5000)}|\n${`${'|x'.repeat(5000)}|\n`.repeat(5000)}`);
++const tall = markdown(`| a |\n|---|\n${'| x |\n'.repeat(3000)}`);
++assert.strictEqual((tall.match(/<tr>/g) || []).length, 1001, 'a header and 1000 rows');
++assert.ok(Date.now() - started < 1500, `pathological tables took ${Date.now() - started} ms`);
++console.log('markdown: all checks passed');