diff --git a/css/tailwind.css b/css/tailwind.css
index 7666e460a9..68ff665246 100644
--- a/css/tailwind.css
+++ b/css/tailwind.css
@@ -1254,6 +1254,10 @@ video {
flex-grow: 1;
}
+.border-collapse {
+ border-collapse: collapse;
+}
+
.border-separate {
border-collapse: separate;
}
diff --git a/directory/index.html b/directory/index.html
index f14822e5bf..492d783462 100644
--- a/directory/index.html
+++ b/directory/index.html
@@ -621,6 +621,57 @@ window.addEventListener('click',(e)=>{
color: magenta;
}
+ #directory .entry {
+ --md-card: rgba(139, 135, 134, 0.12);
+ --md-line: rgba(139, 135, 134, 0.35);
+ --md-bar: rgba(0, 0, 0, 0.16);
+ --md-stripe: rgba(139, 135, 134, 0.08);
+ --md-link: #0053D0;
+ }
+
+ .dark #directory .entry {
+ --md-card: rgba(255, 255, 255, 0.08);
+ --md-line: rgba(139, 135, 134, 0.45);
+ --md-bar: rgba(255, 255, 255, 0.22);
+ --md-stripe: rgba(139, 135, 134, 0.12);
+ --md-link: #70F0F9;
+ }
+
+ #directory .entry .text-container { min-width: 0; }
+ #directory .entry .md-gap { height: 0.7em; }
+ #directory .entry .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; }
+ #directory .entry .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; }
+ #directory .entry .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; }
+ #directory .entry .md-h4 { font-weight: 700; }
+ #directory .entry .md-entry { display: flex; gap: 6px; }
+ #directory .entry .md-mark { flex: none; min-width: 1.1em; }
+ #directory .entry .md-text { flex: 1; min-width: 0; }
+ #directory .entry .md-quote { display: flex; gap: 10px; }
+ #directory .entry .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--md-bar); }
+ #directory .entry .md-quote > .md-text { opacity: 0.7; }
+ #directory .entry .md-code { position: relative; background: var(--md-card); border-radius: 4px; overflow: hidden; }
+ #directory .entry .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; }
+ #directory .entry .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 4px; background: var(--md-card); color: var(--md-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; }
+ #directory .entry .md-code:hover .md-copy,
+ #directory .entry .md-code.tapped .md-copy,
+ #directory .entry .md-copy:focus-visible { opacity: 1; pointer-events: auto; }
+ #directory .entry .md-copy svg { width: 18px; height: 18px; }
+ #directory .entry .md-icon-done,
+ #directory .entry .md-copy.copied .md-icon-copy { display: none; }
+ #directory .entry .md-copy.copied .md-icon-done { display: block; }
+ #directory .entry .md-table { overflow-x: auto; }
+ #directory .entry .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--md-line); border-radius: 4px; overflow: hidden; }
+ #directory .entry .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; }
+ #directory .entry .md-frame th,
+ #directory .entry .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--md-line); border-bottom: 1px solid var(--md-line); }
+ #directory .entry .md-frame th:last-child,
+ #directory .entry .md-frame td:last-child { border-right: 0; }
+ #directory .entry .md-frame tr:last-child th,
+ #directory .entry .md-frame tr:last-child td { border-bottom: 0; }
+ #directory .entry .md-frame th { font-weight: 700; background: var(--md-card); }
+ #directory .entry .md-frame tr:nth-child(odd) td { background: var(--md-stripe); }
+ #directory .entry .md-rule { border: 0; border-top: 1px solid var(--md-line); margin: 6px 0; }
+
.pagination {
display: flex;
justify-content: center;
diff --git a/js/channel-preview.js b/js/channel-preview.js
index 30e5a97bd7..959f55ec91 100644
--- a/js/channel-preview.js
+++ b/js/channel-preview.js
@@ -67,7 +67,157 @@ function targetBlank(uri) {
return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"'
}
-function renderMarkdown(fts) {
+const MAX_LIST_DEPTH = 7;
+
+const MAX_TABLE_CELLS = 1000;
+
+const COPY_BUTTON =
+ '';
+
+// navigator.clipboard is undefined outside secure contexts; fall back to execCommand
+function copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text);
+ }
+ return new Promise(function(resolve, reject) {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.top = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ let ok = false;
+ try { ok = document.execCommand('copy'); } catch (e) {}
+ document.body.removeChild(ta);
+ ok ? resolve() : reject(new Error('copy failed'));
+ });
+}
+
+function handleCodeClick(e) {
+ const card = e.target.closest('.md-code');
+ if (!card) return;
+ const button = e.target.closest('.md-copy');
+ if (!button) {
+ card.classList.toggle('tapped');
+ return;
+ }
+ const pre = card.querySelector('pre');
+ if (!pre) return;
+ copyToClipboard(pre.innerText).then(() => {
+ button.classList.add('copied');
+ setTimeout(() => button.classList.remove('copied'), 1500);
+ }).catch(() => {});
+}
+
+function validBlocks(fts, blocks) {
+ let pos = 0;
+ const range = (r) => {
+ const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length;
+ if (valid) pos = r.to;
+ return valid;
+ };
+ const entries = (items, depth) =>
+ Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1));
+ const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range));
+ return Array.isArray(blocks) && blocks.every((b) => {
+ switch (b && b.type) {
+ case 'paragraph':
+ case 'heading':
+ case 'codeBlock':
+ case 'quote':
+ return range(b.content);
+ case 'list':
+ return entries(b.items, 1);
+ case 'table':
+ return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows);
+ case 'rule':
+ return range(b.mark);
+ default:
+ return false;
+ }
+ });
+}
+
+function renderBlocks(fts, blocks) {
+ let html = '';
+ let pos = 0;
+ const segments = (r) => renderSegments(fts.slice(r.from, r.to), true);
+ const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak';
+ const gap = (to) => {
+ if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '
';
+ pos = Math.max(pos, to);
+ };
+ const block = (r, open, close) => {
+ gap(r.from);
+ html += open + segments(r) + close;
+ pos = r.to;
+ };
+ const entry = (e, level) => {
+ html += `${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`;
+ pos = e.content.to;
+ e.items.forEach((item) => entry(item, level + 1));
+ };
+ const table = (b) => {
+ const rows = [b.header].concat(b.rows);
+ const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0;
+ const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left');
+ const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}${tag}>`;
+ const row = (cells, tag) =>
+ '' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + '
';
+ gap(b.header.length > 0 ? b.header[0].from : pos);
+ html += '' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
';
+ const last = rows[rows.length - 1];
+ pos = last.length > 0 ? last[last.length - 1].to : pos;
+ };
+ for (const b of blocks) {
+ switch (b.type) {
+ case 'paragraph':
+ block(b.content, '', '
');
+ break;
+ case 'heading':
+ block(b.content, ``, '
');
+ break;
+ case 'codeBlock':
+ block(b.content, '');
+ break;
+ case 'quote':
+ block(b.content, '');
+ break;
+ case 'list':
+ if (b.items.length > 0) gap(b.items[0].content.from);
+ b.items.forEach((item) => entry(item, 0));
+ break;
+ case 'table':
+ table(b);
+ break;
+ case 'rule':
+ block(b.mark, '
', '');
+ break;
+ }
+ }
+ return html;
+}
+
+function markupHtml(token) {
+ switch (token && token.type) {
+ case 'softBreak':
+ return ' ';
+ case 'lineBreak':
+ case 'hardBreak':
+ return '
';
+ default:
+ return '';
+ }
+}
+
+function renderMarkdown(fts, blocks) {
+ return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false);
+}
+
+function renderSegments(fts, withBlocks) {
let html = '';
for (const ft of fts) {
const { format, text } = ft;
@@ -78,7 +228,7 @@ function renderMarkdown(fts) {
try {
switch (format.type) {
case 'bold':
- html += `${escapeHtml(text)}`;
+ html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`;
break;
case 'italic':
html += `${escapeHtml(text)}`;
@@ -126,6 +276,11 @@ function renderMarkdown(fts) {
case 'phone':
html += `${escapeHtml(text)}`;
break;
+ case 'escape':
+ break;
+ case 'markup':
+ html += withBlocks ? markupHtml(format.token) : escapeHtml(text);
+ break;
case 'unknown':
html += escapeHtml(text);
break;
@@ -189,6 +344,9 @@ const STYLE = `
--sp-color-white: #000;
--sp-qr-fg: #062D56;
--sp-qr-bg: #ffffff;
+ --sp-md-line: rgba(139, 135, 134, 0.35);
+ --sp-md-bar: rgba(0, 0, 0, 0.16);
+ --sp-md-stripe: rgba(139, 135, 134, 0.08);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.4;
@@ -224,6 +382,9 @@ const STYLE = `
--sp-color-white: #fff;
--sp-qr-fg: #FFFBFA;
--sp-qr-bg: transparent;
+ --sp-md-line: rgba(139, 135, 134, 0.45);
+ --sp-md-bar: rgba(255, 255, 255, 0.22);
+ --sp-md-stripe: rgba(139, 135, 134, 0.12);
}
.simplex-preview-header {
@@ -663,6 +824,44 @@ const STYLE = `
.simplex-preview-text .black { color: var(--sp-color-black); }
.simplex-preview-text .white { color: var(--sp-color-white); }
+.simplex-preview-msg-col {
+ min-width: 0;
+}
+
+.simplex-preview-text .md-gap { height: 0.7em; }
+.simplex-preview-text .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; }
+.simplex-preview-text .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; }
+.simplex-preview-text .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; }
+.simplex-preview-text .md-h4 { font-weight: 700; }
+.simplex-preview-text .md-entry { display: flex; gap: 6px; }
+.simplex-preview-text .md-mark { flex: none; min-width: 1.1em; }
+.simplex-preview-text .md-text { flex: 1; min-width: 0; }
+.simplex-preview-text .md-quote { display: flex; gap: 10px; }
+.simplex-preview-text .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--sp-md-bar); }
+.simplex-preview-text .md-quote > .md-text { opacity: 0.7; }
+.simplex-preview-text .md-code { position: relative; background: var(--sp-quote); border-radius: 10px; overflow: hidden; }
+.simplex-preview-text .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; }
+.simplex-preview-text .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 7px; background: var(--sp-quote); color: var(--sp-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; }
+.simplex-preview-text .md-code:hover .md-copy,
+.simplex-preview-text .md-code.tapped .md-copy,
+.simplex-preview-text .md-copy:focus-visible { opacity: 1; pointer-events: auto; }
+.simplex-preview-text .md-copy svg { width: 18px; height: 18px; }
+.simplex-preview-text .md-icon-done,
+.simplex-preview-text .md-copy.copied .md-icon-copy { display: none; }
+.simplex-preview-text .md-copy.copied .md-icon-done { display: block; }
+.simplex-preview-text .md-table { overflow-x: auto; }
+.simplex-preview-text .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--sp-md-line); border-radius: 10px; overflow: hidden; }
+.simplex-preview-text .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; }
+.simplex-preview-text .md-frame th,
+.simplex-preview-text .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--sp-md-line); border-bottom: 1px solid var(--sp-md-line); }
+.simplex-preview-text .md-frame th:last-child,
+.simplex-preview-text .md-frame td:last-child { border-right: 0; }
+.simplex-preview-text .md-frame tr:last-child th,
+.simplex-preview-text .md-frame tr:last-child td { border-bottom: 0; }
+.simplex-preview-text .md-frame th { font-weight: 700; background: var(--sp-quote); }
+.simplex-preview-text .md-frame tr:nth-child(odd) td { background: var(--sp-md-stripe); }
+.simplex-preview-text .md-rule { border: 0; border-top: 1px solid var(--sp-md-line); margin: 6px 0; }
+
.simplex-preview-main {
flex: 1;
min-width: 0;
@@ -1051,6 +1250,7 @@ function render(container, data, channelLink, showAppBadges) {
ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(),
content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' },
formattedText: Array.isArray(welcome) ? welcome : null,
+ markdownBlocks: data.welcomeMessageBlocks,
reactions: []
};
allMessages = [welcomeMsg].concat(messages);
@@ -1153,7 +1353,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp
if (shortDescr) {
const descrDiv = document.createElement('div');
descrDiv.className = 'simplex-preview-info-descr';
- descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr);
+ descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr, data.shortDescriptionBlocks) : escapeHtml(shortDescr);
container.appendChild(descrDiv);
}
@@ -1234,25 +1434,6 @@ function qrColor(value, fallback) {
return value === 'transparent' ? '#0000' : value;
}
-// navigator.clipboard is undefined outside secure contexts; fall back to execCommand
-function copyToClipboard(text) {
- if (navigator.clipboard && navigator.clipboard.writeText) {
- return navigator.clipboard.writeText(text);
- }
- return new Promise(function(resolve, reject) {
- const ta = document.createElement('textarea');
- ta.value = text;
- ta.style.position = 'fixed';
- ta.style.top = '-9999px';
- document.body.appendChild(ta);
- ta.select();
- let ok = false;
- try { ok = document.execCommand('copy'); } catch (e) {}
- document.body.removeChild(ta);
- ok ? resolve() : reject(new Error('copy failed'));
- });
-}
-
function renderDesktopConversion(container, channelLink, showAppBadges) {
if (showAppBadges) {
renderAppBadges(container);
@@ -1356,6 +1537,7 @@ function setupSecretToggles(container) {
container.addEventListener('click', (e) => {
const secret = e.target.closest('.secret');
if (secret) secret.classList.toggle('visible');
+ handleCodeClick(e);
});
}
@@ -1412,6 +1594,7 @@ function renderMessages(container, messages, membersMap, channel) {
}
const col = document.createElement('div');
+ col.className = 'simplex-preview-msg-col';
const bubble = renderBubble(msg, member, showTail, membersMap, channel);
col.appendChild(bubble);
@@ -1685,7 +1868,7 @@ function appendTextBlock(bubble, msg) {
textDiv.className = 'simplex-preview-text';
const meta = renderMetaHTML(msg);
if (msg.formattedText && msg.formattedText.length > 0) {
- textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta;
+ textDiv.innerHTML = renderMarkdown(msg.formattedText, msg.markdownBlocks) + meta;
} else {
textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta;
}
diff --git a/js/directory.js b/js/directory.js
index 18fdb2926f..f7c021605e 100644
--- a/js/directory.js
+++ b/js/directory.js
@@ -72,7 +72,157 @@ function targetBlank(uri) {
return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"'
}
-function renderMarkdown(fts) {
+const MAX_LIST_DEPTH = 7;
+
+const MAX_TABLE_CELLS = 1000;
+
+const COPY_BUTTON =
+ '';
+
+// navigator.clipboard is undefined outside secure contexts; fall back to execCommand
+function copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text);
+ }
+ return new Promise(function(resolve, reject) {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.top = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ let ok = false;
+ try { ok = document.execCommand('copy'); } catch (e) {}
+ document.body.removeChild(ta);
+ ok ? resolve() : reject(new Error('copy failed'));
+ });
+}
+
+function handleCodeClick(e) {
+ const card = e.target.closest('.md-code');
+ if (!card) return;
+ const button = e.target.closest('.md-copy');
+ if (!button) {
+ card.classList.toggle('tapped');
+ return;
+ }
+ const pre = card.querySelector('pre');
+ if (!pre) return;
+ copyToClipboard(pre.innerText).then(() => {
+ button.classList.add('copied');
+ setTimeout(() => button.classList.remove('copied'), 1500);
+ }).catch(() => {});
+}
+
+function validBlocks(fts, blocks) {
+ let pos = 0;
+ const range = (r) => {
+ const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length;
+ if (valid) pos = r.to;
+ return valid;
+ };
+ const entries = (items, depth) =>
+ Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1));
+ const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range));
+ return Array.isArray(blocks) && blocks.every((b) => {
+ switch (b && b.type) {
+ case 'paragraph':
+ case 'heading':
+ case 'codeBlock':
+ case 'quote':
+ return range(b.content);
+ case 'list':
+ return entries(b.items, 1);
+ case 'table':
+ return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows);
+ case 'rule':
+ return range(b.mark);
+ default:
+ return false;
+ }
+ });
+}
+
+function renderBlocks(fts, blocks) {
+ let html = '';
+ let pos = 0;
+ const segments = (r) => renderSegments(fts.slice(r.from, r.to), true);
+ const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak';
+ const gap = (to) => {
+ if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '';
+ pos = Math.max(pos, to);
+ };
+ const block = (r, open, close) => {
+ gap(r.from);
+ html += open + segments(r) + close;
+ pos = r.to;
+ };
+ const entry = (e, level) => {
+ html += `${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`;
+ pos = e.content.to;
+ e.items.forEach((item) => entry(item, level + 1));
+ };
+ const table = (b) => {
+ const rows = [b.header].concat(b.rows);
+ const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0;
+ const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left');
+ const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}${tag}>`;
+ const row = (cells, tag) =>
+ '' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + '
';
+ gap(b.header.length > 0 ? b.header[0].from : pos);
+ html += '' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
';
+ const last = rows[rows.length - 1];
+ pos = last.length > 0 ? last[last.length - 1].to : pos;
+ };
+ for (const b of blocks) {
+ switch (b.type) {
+ case 'paragraph':
+ block(b.content, '', '
');
+ break;
+ case 'heading':
+ block(b.content, ``, '
');
+ break;
+ case 'codeBlock':
+ block(b.content, '');
+ break;
+ case 'quote':
+ block(b.content, '');
+ break;
+ case 'list':
+ if (b.items.length > 0) gap(b.items[0].content.from);
+ b.items.forEach((item) => entry(item, 0));
+ break;
+ case 'table':
+ table(b);
+ break;
+ case 'rule':
+ block(b.mark, '
', '');
+ break;
+ }
+ }
+ return html;
+}
+
+function markupHtml(token) {
+ switch (token && token.type) {
+ case 'softBreak':
+ return ' ';
+ case 'lineBreak':
+ case 'hardBreak':
+ return '
';
+ default:
+ return '';
+ }
+}
+
+function renderMarkdown(fts, blocks) {
+ return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false);
+}
+
+function renderSegments(fts, withBlocks) {
let html = '';
for (const ft of fts) {
const { format, text } = ft;
@@ -83,7 +233,7 @@ function renderMarkdown(fts) {
try {
switch (format.type) {
case 'bold':
- html += `${escapeHtml(text)}`;
+ html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`;
break;
case 'italic':
html += `${escapeHtml(text)}`;
@@ -131,6 +281,11 @@ function renderMarkdown(fts) {
case 'phone':
html += `${escapeHtml(text)}`;
break;
+ case 'escape':
+ break;
+ case 'markup':
+ html += withBlocks ? markupHtml(format.token) : escapeHtml(text);
+ break;
case 'unknown':
html += escapeHtml(text);
break;
@@ -198,6 +353,7 @@ async function initDirectory() {
liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn));
newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn));
topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn));
+ document.getElementById('directory').addEventListener('click', handleCodeClick);
window.addEventListener('popstate', applyHash);
function applyHash() {
@@ -395,7 +551,7 @@ function displayEntries(entries) {
for (let entry of entries) {
try {
- const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry;
+ const { entryType, displayName, simplexName, groupLink, shortDescr, shortDescrBlocks, welcomeMessage, welcomeMessageBlocks, imageFile } = entry;
const entryDiv = document.createElement('div');
entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8';
@@ -414,11 +570,11 @@ function displayEntries(entries) {
textContainer.appendChild(simplexNameElement);
}
- const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined;
- const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined;
+ const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage, welcomeMessageBlocks) : undefined;
+ const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr, shortDescrBlocks) : undefined;
if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) {
const descrElement = document.createElement('p');
- descrElement.innerHTML = renderMarkdown(shortDescr);
+ descrElement.innerHTML = shortDescrHTML;
textContainer.appendChild(descrElement);
}
diff --git a/js/simplex-lib.js b/js/simplex-lib.js
index e8f11c8efb..c97d2336e8 100644
--- a/js/simplex-lib.js
+++ b/js/simplex-lib.js
@@ -65,7 +65,157 @@ function targetBlank(uri) {
return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"'
}
-function renderMarkdown(fts) {
+const MAX_LIST_DEPTH = 7;
+
+const MAX_TABLE_CELLS = 1000;
+
+const COPY_BUTTON =
+ '';
+
+// navigator.clipboard is undefined outside secure contexts; fall back to execCommand
+function copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text);
+ }
+ return new Promise(function(resolve, reject) {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.top = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ let ok = false;
+ try { ok = document.execCommand('copy'); } catch (e) {}
+ document.body.removeChild(ta);
+ ok ? resolve() : reject(new Error('copy failed'));
+ });
+}
+
+function handleCodeClick(e) {
+ const card = e.target.closest('.md-code');
+ if (!card) return;
+ const button = e.target.closest('.md-copy');
+ if (!button) {
+ card.classList.toggle('tapped');
+ return;
+ }
+ const pre = card.querySelector('pre');
+ if (!pre) return;
+ copyToClipboard(pre.innerText).then(() => {
+ button.classList.add('copied');
+ setTimeout(() => button.classList.remove('copied'), 1500);
+ }).catch(() => {});
+}
+
+function validBlocks(fts, blocks) {
+ let pos = 0;
+ const range = (r) => {
+ const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length;
+ if (valid) pos = r.to;
+ return valid;
+ };
+ const entries = (items, depth) =>
+ Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1));
+ const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range));
+ return Array.isArray(blocks) && blocks.every((b) => {
+ switch (b && b.type) {
+ case 'paragraph':
+ case 'heading':
+ case 'codeBlock':
+ case 'quote':
+ return range(b.content);
+ case 'list':
+ return entries(b.items, 1);
+ case 'table':
+ return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows);
+ case 'rule':
+ return range(b.mark);
+ default:
+ return false;
+ }
+ });
+}
+
+function renderBlocks(fts, blocks) {
+ let html = '';
+ let pos = 0;
+ const segments = (r) => renderSegments(fts.slice(r.from, r.to), true);
+ const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak';
+ const gap = (to) => {
+ if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '';
+ pos = Math.max(pos, to);
+ };
+ const block = (r, open, close) => {
+ gap(r.from);
+ html += open + segments(r) + close;
+ pos = r.to;
+ };
+ const entry = (e, level) => {
+ html += `${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`;
+ pos = e.content.to;
+ e.items.forEach((item) => entry(item, level + 1));
+ };
+ const table = (b) => {
+ const rows = [b.header].concat(b.rows);
+ const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0;
+ const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left');
+ const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}${tag}>`;
+ const row = (cells, tag) =>
+ '' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + '
';
+ gap(b.header.length > 0 ? b.header[0].from : pos);
+ html += '' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
';
+ const last = rows[rows.length - 1];
+ pos = last.length > 0 ? last[last.length - 1].to : pos;
+ };
+ for (const b of blocks) {
+ switch (b.type) {
+ case 'paragraph':
+ block(b.content, '', '
');
+ break;
+ case 'heading':
+ block(b.content, ``, '
');
+ break;
+ case 'codeBlock':
+ block(b.content, '');
+ break;
+ case 'quote':
+ block(b.content, '');
+ break;
+ case 'list':
+ if (b.items.length > 0) gap(b.items[0].content.from);
+ b.items.forEach((item) => entry(item, 0));
+ break;
+ case 'table':
+ table(b);
+ break;
+ case 'rule':
+ block(b.mark, '
', '');
+ break;
+ }
+ }
+ return html;
+}
+
+function markupHtml(token) {
+ switch (token && token.type) {
+ case 'softBreak':
+ return ' ';
+ case 'lineBreak':
+ case 'hardBreak':
+ return '
';
+ default:
+ return '';
+ }
+}
+
+function renderMarkdown(fts, blocks) {
+ return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false);
+}
+
+function renderSegments(fts, withBlocks) {
let html = '';
for (const ft of fts) {
const { format, text } = ft;
@@ -76,7 +226,7 @@ function renderMarkdown(fts) {
try {
switch (format.type) {
case 'bold':
- html += `${escapeHtml(text)}`;
+ html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`;
break;
case 'italic':
html += `${escapeHtml(text)}`;
@@ -124,6 +274,11 @@ function renderMarkdown(fts) {
case 'phone':
html += `${escapeHtml(text)}`;
break;
+ case 'escape':
+ break;
+ case 'markup':
+ html += withBlocks ? markupHtml(format.token) : escapeHtml(text);
+ break;
case 'unknown':
html += escapeHtml(text);
break;