openvibers/openvibe.chat Commits
Vibe's answers render tables and nested lists (model answers use both; they showed as pipes and flat bullets) (#200)
Changes
@@@ -26,6 +26,12 @@ a { color: var(--accent); }.em-bubble ul, .em-bubble ol { margin: .2em 0 .5em; padding-left: 1.2em; }.em-bubble code { font: .88em ui-monospace, Menlo, Consolas, monospace; background: rgba(127, 127, 127, .15); padding: .1em .3em; border-radius: 5px; }.em-bubble pre { white-space: pre-wrap; background: rgba(127, 127, 127, .12); padding: 8px 10px; border-radius: 10px; }++.em-bubble .md-table { overflow-x: auto; margin: .3em 0 .6em; border: 1px solid rgba(127, 127, 127, .3); border-radius: 8px; }++.em-bubble .md-table table { border-collapse: collapse; width: 100%; font-size: .92em; }++.em-bubble .md-table th, .em-bubble .md-table td { padding: 5px 8px; border-bottom: 1px solid rgba(127, 127, 127, .25); text-align: left; vertical-align: top; }++.em-bubble .md-table thead th { background: rgba(127, 127, 127, .12); }++.em-bubble .md-table .al-c { text-align: center; }++.em-bubble .md-table .al-r { text-align: right; }.em-system { justify-self: center; max-width: 92%; text-align: center; font-size: .8rem; color: var(--muted); }.em-typing .em-bubble { color: var(--muted); }.em-dots { display: inline-flex; gap: 4px; } .em-dots i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .4; animation: emDot 1.2s infinite; }@@@ -1,6 +1,7 @@/* Vibe's answers as HTML (server/web/vibe-pages.js renders them, public/web/vibe.js draws live ones): a small Markdown* subset, every character of the source escaped first, so the only markup in the output is the markup made here.-- * blocks: ``` fenced code ```, # / ## / ### headings (drawn as h3/h4), - * • lists, 1. lists, > quotes, paragraphs++ * blocks: ``` fenced code ```, # / ## / ### headings (drawn as h3/h4), - * • lists and 1. lists (nested by indenting,++ * 4 deep), | pipe | tables | (with :-: alignment; they scroll inside their own box), > quotes, paragraphs* inline: `code`, **bold**, *italic* / _italic_, [text](https://…), bare https:// links (rel nofollow, new tab)* Links are http(s) only. One file for both sides: CommonJS on the server, window.VibeMarkdown in the browser. */(function (root) {@@@ -32,14 +33,55 @@return s.replace(/\u0000(\d+)\u0000/g, function (_, i) { return '<code>' + codes[Number(i)] + '</code>'; });}++ // A table row's cells: split on pipes not escaped as \|, one optional outer pipe on each side.++ function cells(line) {++ var t = line.trim();++ if (t.charAt(0) === '|') t = t.slice(1);++ if (t.slice(-1) === '|' && t.slice(-2) !== '\\|') t = t.slice(0, -1);++ var out = [];++ var cur = '';++ for (var k = 0; k < t.length; k++) {++ if (t.charAt(k) === '\\' && t.charAt(k + 1) === '|') { cur += '|'; k++; continue; }++ if (t.charAt(k) === '|') { out.push(cur.trim()); cur = ''; continue; }++ cur += t.charAt(k);++ }++ out.push(cur.trim());++ return out;++ }++ // The alignments of a table's delimiter row (| --- | :-: | --: |), or null when the line is not one.++ function delimiter(line) {++ if (!line || line.indexOf('|') < 0 || !/^[\s|:-]+$/.test(line)) return null;++ var cs = cells(line);++ var al = [];++ for (var k = 0; k < cs.length; k++) {++ if (!/^:?-+:?$/.test(cs[k])) return null;++ var l = cs[k].charAt(0) === ':', r = cs[k].slice(-1) === ':';++ al.push(l && r ? 'c' : r ? 'r' : l ? 'l' : '');++ }++ return al.length <= 32 ? al : null;++ }++ function cell(tag, text, a) { return '<' + tag + (a ? ' class="al-' + a + '"' : '') + '>' + inline(esc(text || '')) + '</' + tag + '>'; }++++ var MAX_DEPTH = 4; // lists nest this deep; a deeper item joins the deepest list++function render(src) {-- // The two placeholder characters inline() uses can never come from the text itself.-- var lines = String(src == null ? '' : src).replace(/[\u0000\u0001]/g, '').replace(/\r\n?/g, '\n').split('\n');++ // The two placeholder characters inline() uses can never come from the text itself. Leading tabs count 4 columns.++ var lines = String(src == null ? '' : src).replace(/[\u0000\u0001]/g, '').replace(/\r\n?/g, '\n').split('\n')++ .map(function (l) { return l.replace(/^\t+/, function (t) { return new Array(t.length + 1).join(' '); }); });var out = [];var para = [];-- var list = null; // { tag, items: [] }++ // Open lists, outermost first: { tag, indent, items: [{ text, sub }] }. A deeper item opens a list inside the last one.++ var stack = [];function flushPara() { if (para.length) { out.push('<p>' + para.map(function (l) { return inline(esc(l)); }).join('<br>') + '</p>'); para = []; } }-- function flushList() { if (list) { out.push('<' + list.tag + '>' + list.items.map(function (i) { return '<li>' + inline(esc(i)) + '</li>'; }).join('') + '</' + list.tag + '>'); list = null; } }++ function listHtml(l) { return '<' + l.tag + '>' + l.items.map(function (it) { return '<li>' + inline(esc(it.text)) + it.sub + '</li>'; }).join('') + '</' + l.tag + '>'; }++ function closeDeeperThan(indent) {++ while (stack.length > 1 && stack[stack.length - 1].indent > indent) {++ var done = stack.pop();++ var parent = stack[stack.length - 1];++ parent.items[parent.items.length - 1].sub += listHtml(done);++ }++ }++ function flushList() { if (stack.length) { closeDeeperThan(-1); out.push(listHtml(stack[0])); stack = []; } }for (var i = 0; i < lines.length; i++) {var line = lines[i];var fence = /^\s*```\s*([\w+#.-]{0,20})\s*$/.exec(line);@@@ -53,19 +95,47 @@if (!line.trim()) { flushPara(); flushList(); continue; }var h = /^\s{0,3}(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);if (h) { flushPara(); flushList(); out.push((h[1].length === 1 ? '<h3>' : '<h4>') + inline(esc(h[2])) + (h[1].length === 1 ? '</h3>' : '</h4>')); continue; }-- var ul = /^\s{0,6}[-*•]\s+(.+)$/.exec(line);-- var ol = /^\s{0,6}\d{1,3}[.)]\s+(.+)$/.exec(line);++ // A pipe table: a header row whose cells match the delimiter row under it; rows run to a blank or pipe-less line.++ var al = line.indexOf('|') >= 0 ? delimiter(lines[i + 1]) : null;++ var head = al ? cells(line) : null;++ if (head && head.length === al.length) {++ flushPara(); flushList();++ var rows = [];++ for (i += 2; i < lines.length && lines[i].trim() && lines[i].indexOf('|') >= 0 && rows.length < 500; i++) rows.push(cells(lines[i]));++ i--;++ // A wide table scrolls inside its own box on a phone: a labelled region keyboard users can reach.++ out.push('<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr>'++ + head.map(function (c, k) { return cell('th', c, al[k]); }).join('') + '</tr></thead><tbody>'++ + rows.map(function (r) { return '<tr>' + al.map(function (a, k) { return cell('td', r[k], a); }).join('') + '</tr>'; }).join('')++ + '</tbody></table></div>');++ continue;++ }++ var ul = /^(\s{0,24})[-*•]\s+(.+)$/.exec(line);++ var ol = ul ? null : /^(\s{0,24})\d{1,3}[.)]\s+(.+)$/.exec(line);if (ul || ol) {flushPara();++ var m = ul || ol;var tag = ul ? 'ul' : 'ol';-- if (list && list.tag !== tag) flushList();-- if (!list) list = { tag: tag, items: [] };-- list.items.push((ul || ol)[1]);++ var indent = m[1].length;++ var top = stack[stack.length - 1];++ if (top && indent > top.indent + 1 && top.items.length && stack.length < MAX_DEPTH) {++ stack.push({ tag: tag, indent: indent, items: [] });++ } else if (top) {++ closeDeeperThan(indent);++ top = stack[stack.length - 1];++ if (top.tag !== tag) {++ if (stack.length === 1) { flushList(); stack.push({ tag: tag, indent: indent, items: [] }); }++ else { var done = stack.pop(); stack[stack.length - 1].items[stack[stack.length - 1].items.length - 1].sub += listHtml(done); stack.push({ tag: tag, indent: indent, items: [] }); }++ }++ } else {++ stack.push({ tag: tag, indent: indent, items: [] });++ }++ stack[stack.length - 1].items.push({ text: m[2], sub: '' });continue;}var q = /^\s{0,3}>\s?(.*)$/.exec(line);if (q) { flushPara(); flushList(); out.push('<blockquote>' + inline(esc(q[1])) + '</blockquote>'); continue; }-- if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ' ' + line.trim(); continue; }++ if (stack.length && /^\s{2,}\S/.test(line)) { var last = stack[stack.length - 1]; last.items[last.items.length - 1].text += ' ' + line.trim(); continue; }flushList();para.push(line.trim());}@@@ -135,6 +135,15 @@ body.oc-app-body { overflow: hidden; }.vb-md pre { margin: 0 0 1em; padding: 14px 16px; border-radius: 14px; background: color-mix(in srgb, var(--oc-panel) 80%, #000); border: 1px solid var(--oc-line); overflow-x: auto; }.vb-md pre code { background: none; padding: 0; font-size: .86rem; white-space: pre; }.vb-md blockquote { margin: 0 0 .9em; padding: 2px 0 2px 14px; border-left: 3px solid var(--oc-line); color: var(--oc-muted); }++.vb-md li > ul, .vb-md li > ol { margin: .2em 0 .3em; }++.md-table { overflow-x: auto; margin: 0 0 1em; border: 1px solid var(--oc-line); border-radius: 12px; }++.md-table:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }++.md-table table { border-collapse: collapse; width: 100%; font-size: .94em; line-height: 1.45; }++.md-table th, .md-table td { padding: 8px 12px; border-bottom: 1px solid var(--oc-line); text-align: left; vertical-align: top; min-width: 6em; }++.md-table thead th { background: var(--oc-panel-2); font-weight: 650; }++.md-table tbody tr:last-child td { border-bottom: 0; }++.md-table .al-c { text-align: center; }++.md-table .al-r { text-align: right; }.vb-dots { display: inline-flex; gap: 5px; align-items: center; height: 26px; }.vb-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }@@@ -179,6 +179,21 @@ t('answers render as escaped Markdown with http(s) links only', () => {assert.ok(md.render('see https://openvibe.live/docs.').includes('<a href="https://openvibe.live/docs" rel="nofollow ugc noopener" target="_blank">https://openvibe.live/docs</a>.'));assert.ok(!md.render('[x](https://a.com" onmouseover="alert(1))').includes('onmouseover="'));assert.strictEqual(md.render('```\n<i>\n```'), '<pre><code><i></code></pre>');++ // Tables and nested lists (answers are model-written Markdown, which uses both): escaped like everything else.++ assert.strictEqual(md.render('| Plan | Cost |\n|---|--:|\n| Rice | $2 |\n| <b>x</b> | 1 \\| 2 |'),++ '<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr><th>Plan</th><th class="al-r">Cost</th></tr></thead>'++ + '<tbody><tr><td>Rice</td><td class="al-r">$2</td></tr><tr><td><b>x</b></td><td class="al-r">1 | 2</td></tr></tbody></table></div>');++ assert.strictEqual(md.render('Intro\n| a | b |\n| - | - |\n| 1 | 2 |\nafter'), '<p>Intro</p><div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></div><p>after</p>');++ assert.strictEqual(md.render('a | b\nc | d'), '<p>a | b<br>c | d</p>', 'pipes without a delimiter row are text');++ assert.strictEqual(md.render('- a\n - a1\n - a2\n- b'), '<ul><li>a<ul><li>a1</li><li>a2</li></ul></li><li>b</li></ul>');++ assert.strictEqual(md.render('1. one\n - x\n2. two'), '<ol><li>one<ul><li>x</li></ul></li><li>two</li></ol>');++ assert.strictEqual(md.render('- a\n continued'), '<ul><li>a continued</li></ul>');++ const deep = md.render(Array.from({ length: 10 }, (_, k) => `${' '.repeat(k)}- l${k}`).join('\n'));++ assert.ok((deep.match(/<ul>/g) || []).length === 4 && deep.includes('l9'), deep);++ const started = Date.now();++ md.render(`${'|a'.repeat(20000)}|\n${'|-'.repeat(20000)}|\n${'| x | y |\n'.repeat(20000)}`);++ md.render(Array.from({ length: 5000 }, (_, k) => `${' '.repeat(k % 24)}- item ${k}`).join('\n'));++ assert.ok(Date.now() - started < 1500, `pathological Markdown took ${Date.now() - started} ms`);});t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
@@ -26,6 +26,12 @@ a { color: var(--accent); }
@@ -26,6 +26,12 @@ a { color: var(--accent); }
.em-bubble ul, .em-bubble ol { margin: .2em 0 .5em; padding-left: 1.2em; }
.em-bubble ul, .em-bubble ol { margin: .2em 0 .5em; padding-left: 1.2em; }
.em-bubble code { font: .88em ui-monospace, Menlo, Consolas, monospace; background: rgba(127, 127, 127, .15); padding: .1em .3em; border-radius: 5px; }
.em-bubble code { font: .88em ui-monospace, Menlo, Consolas, monospace; background: rgba(127, 127, 127, .15); padding: .1em .3em; border-radius: 5px; }
.em-bubble pre { white-space: pre-wrap; background: rgba(127, 127, 127, .12); padding: 8px 10px; border-radius: 10px; }
.em-bubble pre { white-space: pre-wrap; background: rgba(127, 127, 127, .12); padding: 8px 10px; border-radius: 10px; }
+.em-bubble .md-table { overflow-x: auto; margin: .3em 0 .6em; border: 1px solid rgba(127, 127, 127, .3); border-radius: 8px; }
+.em-bubble .md-table table { border-collapse: collapse; width: 100%; font-size: .92em; }
+.em-bubble .md-table th, .em-bubble .md-table td { padding: 5px 8px; border-bottom: 1px solid rgba(127, 127, 127, .25); text-align: left; vertical-align: top; }
+.em-bubble .md-table thead th { background: rgba(127, 127, 127, .12); }
+.em-bubble .md-table .al-c { text-align: center; }
+.em-bubble .md-table .al-r { text-align: right; }
.em-system { justify-self: center; max-width: 92%; text-align: center; font-size: .8rem; color: var(--muted); }
.em-system { justify-self: center; max-width: 92%; text-align: center; font-size: .8rem; color: var(--muted); }
.em-typing .em-bubble { color: var(--muted); }
.em-typing .em-bubble { color: var(--muted); }
.em-dots { display: inline-flex; gap: 4px; } .em-dots i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .4; animation: emDot 1.2s infinite; }
.em-dots { display: inline-flex; gap: 4px; } .em-dots i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .4; animation: emDot 1.2s infinite; }
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
/* Vibe's answers as HTML (server/web/vibe-pages.js renders them, public/web/vibe.js draws live ones): a small Markdown
/* Vibe's answers as HTML (server/web/vibe-pages.js renders them, public/web/vibe.js draws live ones): a small Markdown
* subset, every character of the source escaped first, so the only markup in the output is the markup made here.
* subset, every character of the source escaped first, so the only markup in the output is the markup made here.
- * blocks: ``` fenced code ```, # / ## / ### headings (drawn as h3/h4), - * • lists, 1. lists, > quotes, paragraphs
+ * blocks: ``` fenced code ```, # / ## / ### headings (drawn as h3/h4), - * • lists and 1. lists (nested by indenting,
+ * 4 deep), | pipe | tables | (with :-: alignment; they scroll inside their own box), > quotes, paragraphs
* inline: `code`, **bold**, *italic* / _italic_, [text](https://…), bare https:// links (rel nofollow, new tab)
* inline: `code`, **bold**, *italic* / _italic_, [text](https://…), bare https:// links (rel nofollow, new tab)
* Links are http(s) only. One file for both sides: CommonJS on the server, window.VibeMarkdown in the browser. */
* Links are http(s) only. One file for both sides: CommonJS on the server, window.VibeMarkdown in the browser. */
(function (root) {
(function (root) {
@@ -32,14 +33,55 @@
@@ -32,14 +33,55 @@
return s.replace(/\u0000(\d+)\u0000/g, function (_, i) { return '<code>' + codes[Number(i)] + '</code>'; });
return s.replace(/\u0000(\d+)\u0000/g, function (_, i) { return '<code>' + codes[Number(i)] + '</code>'; });
}
}
+ // A table row's cells: split on pipes not escaped as \|, one optional outer pipe on each side.
+ function cells(line) {
+ var t = line.trim();
+ if (t.charAt(0) === '|') t = t.slice(1);
+ if (t.slice(-1) === '|' && t.slice(-2) !== '\\|') t = t.slice(0, -1);
+ var out = [];
+ var cur = '';
+ for (var k = 0; k < t.length; k++) {
+ if (t.charAt(k) === '\\' && t.charAt(k + 1) === '|') { cur += '|'; k++; continue; }
+ if (t.charAt(k) === '|') { out.push(cur.trim()); cur = ''; continue; }
+ cur += t.charAt(k);
+ }
+ out.push(cur.trim());
+ return out;
+ }
+ // The alignments of a table's delimiter row (| --- | :-: | --: |), or null when the line is not one.
+ function delimiter(line) {
+ if (!line || line.indexOf('|') < 0 || !/^[\s|:-]+$/.test(line)) return null;
+ var cs = cells(line);
+ var al = [];
+ for (var k = 0; k < cs.length; k++) {
+ if (!/^:?-+:?$/.test(cs[k])) return null;
+ var l = cs[k].charAt(0) === ':', r = cs[k].slice(-1) === ':';
+ al.push(l && r ? 'c' : r ? 'r' : l ? 'l' : '');
+ }
+ return al.length <= 32 ? al : null;
+ }
+ function cell(tag, text, a) { return '<' + tag + (a ? ' class="al-' + a + '"' : '') + '>' + inline(esc(text || '')) + '</' + tag + '>'; }
+
+ var MAX_DEPTH = 4; // lists nest this deep; a deeper item joins the deepest list
+
function render(src) {
function render(src) {
- // The two placeholder characters inline() uses can never come from the text itself.
- var lines = String(src == null ? '' : src).replace(/[\u0000\u0001]/g, '').replace(/\r\n?/g, '\n').split('\n');
+ // The two placeholder characters inline() uses can never come from the text itself. Leading tabs count 4 columns.
+ var lines = String(src == null ? '' : src).replace(/[\u0000\u0001]/g, '').replace(/\r\n?/g, '\n').split('\n')
+ .map(function (l) { return l.replace(/^\t+/, function (t) { return new Array(t.length + 1).join(' '); }); });
var out = [];
var out = [];
var para = [];
var para = [];
- var list = null; // { tag, items: [] }
+ // Open lists, outermost first: { tag, indent, items: [{ text, sub }] }. A deeper item opens a list inside the last one.
+ var stack = [];
function flushPara() { if (para.length) { out.push('<p>' + para.map(function (l) { return inline(esc(l)); }).join('<br>') + '</p>'); para = []; } }
function flushPara() { if (para.length) { out.push('<p>' + para.map(function (l) { return inline(esc(l)); }).join('<br>') + '</p>'); para = []; } }
- function flushList() { if (list) { out.push('<' + list.tag + '>' + list.items.map(function (i) { return '<li>' + inline(esc(i)) + '</li>'; }).join('') + '</' + list.tag + '>'); list = null; } }
+ function listHtml(l) { return '<' + l.tag + '>' + l.items.map(function (it) { return '<li>' + inline(esc(it.text)) + it.sub + '</li>'; }).join('') + '</' + l.tag + '>'; }
+ function closeDeeperThan(indent) {
+ while (stack.length > 1 && stack[stack.length - 1].indent > indent) {
+ var done = stack.pop();
+ var parent = stack[stack.length - 1];
+ parent.items[parent.items.length - 1].sub += listHtml(done);
+ }
+ }
+ function flushList() { if (stack.length) { closeDeeperThan(-1); out.push(listHtml(stack[0])); stack = []; } }
for (var i = 0; i < lines.length; i++) {
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var line = lines[i];
var fence = /^\s*```\s*([\w+#.-]{0,20})\s*$/.exec(line);
var fence = /^\s*```\s*([\w+#.-]{0,20})\s*$/.exec(line);
@@ -53,19 +95,47 @@
@@ -53,19 +95,47 @@
if (!line.trim()) { flushPara(); flushList(); continue; }
if (!line.trim()) { flushPara(); flushList(); continue; }
var h = /^\s{0,3}(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);
var h = /^\s{0,3}(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);
if (h) { flushPara(); flushList(); out.push((h[1].length === 1 ? '<h3>' : '<h4>') + inline(esc(h[2])) + (h[1].length === 1 ? '</h3>' : '</h4>')); continue; }
if (h) { flushPara(); flushList(); out.push((h[1].length === 1 ? '<h3>' : '<h4>') + inline(esc(h[2])) + (h[1].length === 1 ? '</h3>' : '</h4>')); continue; }
- var ul = /^\s{0,6}[-*•]\s+(.+)$/.exec(line);
- var ol = /^\s{0,6}\d{1,3}[.)]\s+(.+)$/.exec(line);
+ // A pipe table: a header row whose cells match the delimiter row under it; rows run to a blank or pipe-less line.
+ var al = line.indexOf('|') >= 0 ? delimiter(lines[i + 1]) : null;
+ var head = al ? cells(line) : null;
+ if (head && head.length === al.length) {
+ flushPara(); flushList();
+ var rows = [];
+ for (i += 2; i < lines.length && lines[i].trim() && lines[i].indexOf('|') >= 0 && rows.length < 500; i++) rows.push(cells(lines[i]));
+ i--;
+ // A wide table scrolls inside its own box on a phone: a labelled region keyboard users can reach.
+ out.push('<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr>'
+ + head.map(function (c, k) { return cell('th', c, al[k]); }).join('') + '</tr></thead><tbody>'
+ + rows.map(function (r) { return '<tr>' + al.map(function (a, k) { return cell('td', r[k], a); }).join('') + '</tr>'; }).join('')
+ + '</tbody></table></div>');
+ continue;
+ }
+ var ul = /^(\s{0,24})[-*•]\s+(.+)$/.exec(line);
+ var ol = ul ? null : /^(\s{0,24})\d{1,3}[.)]\s+(.+)$/.exec(line);
if (ul || ol) {
if (ul || ol) {
flushPara();
flushPara();
+ var m = ul || ol;
var tag = ul ? 'ul' : 'ol';
var tag = ul ? 'ul' : 'ol';
- if (list && list.tag !== tag) flushList();
- if (!list) list = { tag: tag, items: [] };
- list.items.push((ul || ol)[1]);
+ var indent = m[1].length;
+ var top = stack[stack.length - 1];
+ if (top && indent > top.indent + 1 && top.items.length && stack.length < MAX_DEPTH) {
+ stack.push({ tag: tag, indent: indent, items: [] });
+ } else if (top) {
+ closeDeeperThan(indent);
+ top = stack[stack.length - 1];
+ if (top.tag !== tag) {
+ if (stack.length === 1) { flushList(); stack.push({ tag: tag, indent: indent, items: [] }); }
+ else { var done = stack.pop(); stack[stack.length - 1].items[stack[stack.length - 1].items.length - 1].sub += listHtml(done); stack.push({ tag: tag, indent: indent, items: [] }); }
+ }
+ } else {
+ stack.push({ tag: tag, indent: indent, items: [] });
+ }
+ stack[stack.length - 1].items.push({ text: m[2], sub: '' });
continue;
continue;
}
}
var q = /^\s{0,3}>\s?(.*)$/.exec(line);
var q = /^\s{0,3}>\s?(.*)$/.exec(line);
if (q) { flushPara(); flushList(); out.push('<blockquote>' + inline(esc(q[1])) + '</blockquote>'); continue; }
if (q) { flushPara(); flushList(); out.push('<blockquote>' + inline(esc(q[1])) + '</blockquote>'); continue; }
- if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ' ' + line.trim(); continue; }
+ if (stack.length && /^\s{2,}\S/.test(line)) { var last = stack[stack.length - 1]; last.items[last.items.length - 1].text += ' ' + line.trim(); continue; }
flushList();
flushList();
para.push(line.trim());
para.push(line.trim());
}
}
@@ -135,6 +135,15 @@ body.oc-app-body { overflow: hidden; }
@@ -135,6 +135,15 @@ body.oc-app-body { overflow: hidden; }
.vb-md pre { margin: 0 0 1em; padding: 14px 16px; border-radius: 14px; background: color-mix(in srgb, var(--oc-panel) 80%, #000); border: 1px solid var(--oc-line); overflow-x: auto; }
.vb-md pre { margin: 0 0 1em; padding: 14px 16px; border-radius: 14px; background: color-mix(in srgb, var(--oc-panel) 80%, #000); border: 1px solid var(--oc-line); overflow-x: auto; }
.vb-md pre code { background: none; padding: 0; font-size: .86rem; white-space: pre; }
.vb-md pre code { background: none; padding: 0; font-size: .86rem; white-space: pre; }
.vb-md blockquote { margin: 0 0 .9em; padding: 2px 0 2px 14px; border-left: 3px solid var(--oc-line); color: var(--oc-muted); }
.vb-md blockquote { margin: 0 0 .9em; padding: 2px 0 2px 14px; border-left: 3px solid var(--oc-line); color: var(--oc-muted); }
+.vb-md li > ul, .vb-md li > ol { margin: .2em 0 .3em; }
+.md-table { overflow-x: auto; margin: 0 0 1em; border: 1px solid var(--oc-line); border-radius: 12px; }
+.md-table:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
+.md-table table { border-collapse: collapse; width: 100%; font-size: .94em; line-height: 1.45; }
+.md-table th, .md-table td { padding: 8px 12px; border-bottom: 1px solid var(--oc-line); text-align: left; vertical-align: top; min-width: 6em; }
+.md-table thead th { background: var(--oc-panel-2); font-weight: 650; }
+.md-table tbody tr:last-child td { border-bottom: 0; }
+.md-table .al-c { text-align: center; }
+.md-table .al-r { text-align: right; }
.vb-dots { display: inline-flex; gap: 5px; align-items: center; height: 26px; }
.vb-dots { display: inline-flex; gap: 5px; align-items: center; height: 26px; }
.vb-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }
.vb-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }
@@ -179,6 +179,21 @@ t('answers render as escaped Markdown with http(s) links only', () => {
@@ -179,6 +179,21 @@ t('answers render as escaped Markdown with http(s) links only', () => {
assert.ok(md.render('see https://openvibe.live/docs.').includes('<a href="https://openvibe.live/docs" rel="nofollow ugc noopener" target="_blank">https://openvibe.live/docs</a>.'));
assert.ok(md.render('see https://openvibe.live/docs.').includes('<a href="https://openvibe.live/docs" rel="nofollow ugc noopener" target="_blank">https://openvibe.live/docs</a>.'));
assert.ok(!md.render('[x](https://a.com" onmouseover="alert(1))').includes('onmouseover="'));
assert.ok(!md.render('[x](https://a.com" onmouseover="alert(1))').includes('onmouseover="'));
assert.strictEqual(md.render('```\n<i>\n```'), '<pre><code><i></code></pre>');
assert.strictEqual(md.render('```\n<i>\n```'), '<pre><code><i></code></pre>');
+ // Tables and nested lists (answers are model-written Markdown, which uses both): escaped like everything else.
+ assert.strictEqual(md.render('| Plan | Cost |\n|---|--:|\n| Rice | $2 |\n| <b>x</b> | 1 \\| 2 |'),
+ '<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr><th>Plan</th><th class="al-r">Cost</th></tr></thead>'
+ + '<tbody><tr><td>Rice</td><td class="al-r">$2</td></tr><tr><td><b>x</b></td><td class="al-r">1 | 2</td></tr></tbody></table></div>');
+ assert.strictEqual(md.render('Intro\n| a | b |\n| - | - |\n| 1 | 2 |\nafter'), '<p>Intro</p><div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></div><p>after</p>');
+ assert.strictEqual(md.render('a | b\nc | d'), '<p>a | b<br>c | d</p>', 'pipes without a delimiter row are text');
+ assert.strictEqual(md.render('- a\n - a1\n - a2\n- b'), '<ul><li>a<ul><li>a1</li><li>a2</li></ul></li><li>b</li></ul>');
+ assert.strictEqual(md.render('1. one\n - x\n2. two'), '<ol><li>one<ul><li>x</li></ul></li><li>two</li></ol>');
+ assert.strictEqual(md.render('- a\n continued'), '<ul><li>a continued</li></ul>');
+ const deep = md.render(Array.from({ length: 10 }, (_, k) => `${' '.repeat(k)}- l${k}`).join('\n'));
+ assert.ok((deep.match(/<ul>/g) || []).length === 4 && deep.includes('l9'), deep);
+ const started = Date.now();
+ md.render(`${'|a'.repeat(20000)}|\n${'|-'.repeat(20000)}|\n${'| x | y |\n'.repeat(20000)}`);
+ md.render(Array.from({ length: 5000 }, (_, k) => `${' '.repeat(k % 24)}- item ${k}`).join('\n'));
+ assert.ok(Date.now() - started < 1500, `pathological Markdown took ${Date.now() - started} ms`);
});
});
t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });