openvibers/openvibe.services Commits
ADR pages draw the decision records' pipe tables (14 ADRs have them; they showed as a paragraph of pipes) (#116)
Changes
@@@ -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();nnew file mode 100644@@@ -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><script></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');
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
/**
/**
* A deliberately small Markdown renderer for the ADRs shipped inside openvibe-contracts (the policy
* 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
* 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');
const { esc } = require('./html');
@@ -20,6 +20,25 @@ function inline(s) {
@@ -20,6 +20,25 @@ function inline(s) {
return out;
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 } = {}) {
function markdown(src, { headingOffset = 1 } = {}) {
const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
const out = [];
const out = [];
@@ -28,12 +47,26 @@ function markdown(src, { headingOffset = 1 } = {}) {
@@ -28,12 +47,26 @@ function markdown(src, { headingOffset = 1 } = {}) {
let fence = null;
let fence = null;
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
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; } };
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 (fence) {
if (/^```/.test(line)) { out.push(`<pre tabindex="0"><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
if (/^```/.test(line)) { out.push(`<pre tabindex="0"><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
continue;
continue;
}
}
if (/^```/.test(line)) { flushPara(); flushList(); fence = []; 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+(.*)$/);
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
if (h) {
flushPara(); flushList();
flushPara(); flushList();
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -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><script></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');