OASIS_MOBILE/src/backend/renderStyledText.js
hacklab 83f26ba19e integrar la 1.3.5 del dev y perfiles Minimo, Acampada y Activista
Lo del dev, tal cual desde el nodejs-project de su APK oficial 1.3.5, mezclado
a tres bandas con la 1.3.3 como base. Lo que no habiamos tocado se coge entero.

Conflictos:
- backend.js: su middleware nuevo de media va delante de nuestro router.
- main_views.js y bookmark_view.js: lo suyo. Su menu de tarjeta (⋯) ya aparta
  borrar del pulgar, que era lo que hacia nuestro cambio.
- settings_view.js y tribes_view.js: lo suyo mas nuestra linea de enganche.

La 1.3.5 trae una cabecera de modulo para el movil (apkModbar) que se lleva la
primera caja de fichas, el buscador y los contadores. Con el skin Activista su
CSS (OasisMobile.css) no se carga: se le da estilo en mobile_ux.css, los avisos
suben encima con order y se ajusta la cabecera nueva de las tarjetas. Filtrar
va ahora dentro de la caja de fichas (casilla + etiqueta) para viajar con ella.

Perfiles: Minimo (6 modulos, de serie), Acampada (16), Activista (24, con
tribus, votaciones, campañas, encuestas y tareas) y despues los del dev por
numero de modulos.
2026-10-22 12:12:20 +02:00

338 lines
19 KiB
JavaScript

const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea, object } = require("../server/node_modules/hyperaxe");
const i18nBase = require("../client/assets/translations/i18n");
const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model");
let zoomSeq = 0;
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
const ESCAPE_RE = /\\([\\*_~`#>\-\[\]!])/g;
const CODE_BLOCK_RE = /```([\s\S]*?)```[ \t]*(?:\n(?:[ \t]*\n)*|$)/g;
const RULE_RE = /^[ \t]*---[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const HEADER_CLOSED_RE = /^[ \t]*(#{1,6})[ \t]*([^\n]{1,200}?)[ \t]*\1[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const HEADER_SPACED_RE = /^[ \t]*(#{1,6})[ \t]+([^\n]{1,200}?)[ \t]*#*[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const QUOTE_RE = /^[ \t]*>[ \t]?([^\n]*)(?:\n(?:[ \t]*\n)*|$)/gm;
const BULLET_RE = /^([ \t]*)-[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
const NUMBER_RE = /^([ \t]*)(\d{1,3})([.)])[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
const BOLD_RE = /\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g;
const UNDERLINE_RE = /(?<![A-Za-z0-9])__(?!\s)((?:[^_\n]|_(?!_))+?)(?<!\s)__(?![A-Za-z0-9])/g;
const STRIKE_RE = /~~(?!\s)([^~\n]+?)(?<!\s)~~/g;
const ITALIC_RE = /\*(?!\s)((?:[^*\n]|\*\*)+?)(?<!\s)\*(?!\*)/g;
const INLINE_CODE_RE = /`([^`\n]+)`/g;
const BLOB_IMAGE_RE = /!\[([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_VIDEO_RE = /\[video:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_AUDIO_RE = /\[audio:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_PDF_RE = /\[pdf:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_TORRENT_RE = /\[torrent:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_FILE_RE = /(?<!!)\[(?!(?:video|audio|pdf|torrent):)([^\]\n]{1,160})\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const MD_MENTION_RE = /\[@([^\]\[\n]{1,120})\]\(@?([A-Za-z0-9+/=.\-]{1,120}\.ed25519)\)/g;
const RAW_MENTION_RE = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
const BARE_MENTION_RE = /(^|[^\w\u0080-\uFFFF\[\]/@])@([\w\u0080-\uFFFF.\-]{1,64})(?=$|[^\w\u0080-\uFFFF/+=])/g;
let mentionResolver = null;
const setMentionResolver = (fn) => { mentionResolver = typeof fn === 'function' ? fn : null; };
const bareMentionName = (token) => (/\.ed25519$/i.test(token) ? '' : token.replace(/[.\-]+$/, ''));
const MSG_REF_RE = /%[A-Za-z0-9+/=]{44}\.sha256/g;
const MD_LINK_RE = /!?\[([^\]\n]{1,160})\]\((https?:\/\/[^)\s]+|\/(?![\/\\])[^)\s]*)\)/g;
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`]+/gi;
const EMAIL_RE = /\b[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,24}\b/g;
const HASHTAG_RE = /#[\w\u0080-\uFFFF]{1,32}(?![\w\u0080-\uFFFF])/g;
const URL_TAIL_RE = /[.,;:!?»"')\]}>]+$/;
const SELF_HOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i;
const INTERNAL_PATHS = [
'agenda','ai','audios','author','backup','banking','blockexplorer','blogs','bookmarks','calendars','campaigns',
'chats','cipher','courts','cv','dev','docs','emergencies','events','favorites','feed','files','forum','games','graphos',
'hashtag','housing','images','industry','inbox','inhabitants','invites','jobs','larp','logistics','logs','mailing',
'maps','market','melody','mentions','modules','multiverse','opinions','pads','parliament','rooms','peers','pixelia','pm',
'podcasts','polls','popular','profile','projects','publish','reports','school','search','settings','shops','spread',
'stats','tags','tasks','thread','torrents','transfers','trending','tribes','tribe','videos','votes','votations','wallet','wiki'
];
const INTERNAL_PATH_SET = new Set(INTERNAL_PATHS);
const SSB_ID_RE = /^(?:%[A-Za-z0-9+/=]{20,}\.sha256|@[A-Za-z0-9+/=]{20,}\.ed25519|&[A-Za-z0-9+/=]{20,}\.sha256)$/;
const internalTargetOf = (url) => {
let parsed = null;
try { parsed = new URL(url); } catch (_) { return null; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
const segments = parsed.pathname.split('/').filter(Boolean).map(seg => { try { return decodeURIComponent(seg); } catch (_) { return seg; } });
if (!segments.length || !INTERNAL_PATH_SET.has(segments[0].toLowerCase())) return null;
const sameHost = SELF_HOST_RE.test(url);
const hasSsbId = segments.slice(1).some(seg => SSB_ID_RE.test(seg));
if (!sameHost && !hasSsbId) return null;
const id = segments.slice(1).find(seg => SSB_ID_RE.test(seg)) || segments[1] || '';
return { href: `${parsed.pathname}${parsed.search}${parsed.hash}`, kind: segments[0].toLowerCase(), id };
};
const shortInternalId = (id) => {
const value = String(id || '');
if (!value) return '';
const sigil = /^[%@&]/.test(value) ? value[0] : '';
const body = sigil ? value.slice(1) : value;
return body.length > 8 ? `${sigil}${body.slice(0, 8)}…` : `${sigil}${body}`;
};
const MAX_DEPTH = 8;
const headerLevel = (hashes) => Math.min(3, String(hashes || '#').length);
const indentLevel = (indent) => {
const width = String(indent || '').replace(/\t/g, ' ').length;
return Math.min(4, Math.floor(width / 2) + 1);
};
const codeBlockBody = (raw) => {
const s = String(raw);
const nl = s.indexOf('\n');
if (nl === -1) return s;
return s.slice(nl + 1).replace(/\n[ \t]*$/, '');
};
function renderStyledText(value, opts = {}) {
if (typeof value !== 'string') return value === null || value === undefined ? [] : [String(value)];
const text = value.indexOf('\r') === -1 ? value : value.replace(/\r\n?/g, '\n');
const depth = Number(opts.depth) || 0;
if (depth > MAX_DEPTH) return [text];
const allMatches = [];
const push = (m, extra) => allMatches.push({ index: m.index, length: m[0].length, ...extra });
for (const m of text.matchAll(ESCAPE_RE)) push(m, { type: 'escaped', body: m[1] });
for (const m of text.matchAll(WIKILINK_RE)) push(m, { type: 'wikilink', target: linkTarget(m[1]), label: (m[2] || linkTarget(m[1])).trim() });
for (const m of text.matchAll(CODE_BLOCK_RE)) push(m, { type: 'code-block', body: codeBlockBody(m[1]) });
for (const m of text.matchAll(RULE_RE)) push(m, { type: 'rule' });
for (const m of text.matchAll(HEADER_CLOSED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
for (const m of text.matchAll(HEADER_SPACED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
for (const m of text.matchAll(QUOTE_RE)) push(m, { type: 'quote', body: m[1] });
for (const m of text.matchAll(BULLET_RE)) push(m, { type: 'bullet', level: indentLevel(m[1]), body: m[2] });
for (const m of text.matchAll(NUMBER_RE)) push(m, { type: 'number', level: indentLevel(m[1]), marker: `${m[2]}${m[3]}`, body: m[4] });
for (const m of text.matchAll(BLOB_IMAGE_RE)) push(m, { type: 'blob-image', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_VIDEO_RE)) push(m, { type: 'blob-video', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_AUDIO_RE)) push(m, { type: 'blob-audio', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_PDF_RE)) push(m, { type: 'blob-pdf', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_TORRENT_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_FILE_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
for (const m of text.matchAll(MD_MENTION_RE)) push(m, { type: 'md-mention', name: m[1], feedId: m[2] });
for (const m of text.matchAll(RAW_MENTION_RE)) push(m, { type: 'raw-mention', feedId: m[1] });
for (const m of text.matchAll(BARE_MENTION_RE)) {
const name = bareMentionName(m[2]);
if (name) allMatches.push({ index: m.index + m[1].length, length: name.length + 1, type: 'bare-mention', name });
}
for (const m of text.matchAll(MSG_REF_RE)) push(m, { type: 'msg-ref', id: m[0] });
for (const m of text.matchAll(MD_LINK_RE)) push(m, { type: 'md-link', label: m[1], href: m[2] });
for (const m of text.matchAll(BOLD_RE)) push(m, { type: 'bold', body: m[1] });
for (const m of text.matchAll(UNDERLINE_RE)) push(m, { type: 'underline', body: m[1] });
for (const m of text.matchAll(STRIKE_RE)) push(m, { type: 'strike', body: m[1] });
for (const m of text.matchAll(ITALIC_RE)) push(m, { type: 'italic', body: m[1] });
for (const m of text.matchAll(INLINE_CODE_RE)) push(m, { type: 'inline-code', body: m[1] });
for (const m of text.matchAll(URL_RE)) {
const trimmed = m[0].replace(URL_TAIL_RE, '');
if (trimmed) allMatches.push({ index: m.index, length: trimmed.length, type: 'url', text: trimmed });
}
for (const m of text.matchAll(EMAIL_RE)) push(m, { type: 'email', text: m[0] });
for (const m of text.matchAll(HASHTAG_RE)) push(m, { type: 'hashtag', tag: m[0].slice(1) });
allMatches.sort((x, y) => x.index - y.index);
const filtered = [];
let lastEnd = 0;
for (const m of allMatches) {
if (m.index < lastEnd) continue;
filtered.push(m);
lastEnd = m.index + m.length;
}
const blobHref = (id) => String(id).startsWith('/blob/sealed/') ? String(id) : `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`;
const linksOn = opts.links !== false;
const internalOn = opts.internalLinks !== false;
const plain = (value) => opts.plainUrlClass ? span({ class: opts.plainUrlClass }, value) : value;
const inner = (body) => renderStyledText(body, { ...opts, depth: depth + 1 });
const result = [];
let cursor = 0;
for (const m of filtered) {
if (cursor < m.index) result.push(text.slice(cursor, m.index));
if (m.type === 'escaped') {
result.push(m.body);
} else if (m.type === 'msg-ref') {
result.push(internalOn ? a({ href: `/thread/${encodeURIComponent(m.id)}`, class: 'styled-link' }, m.id.slice(0, 10) + '...') : m.id);
} else if (m.type === 'wikilink') {
result.push(typeof opts.wikiLink === 'function'
? opts.wikiLink(m.target, slugify(m.target), m.label)
: internalOn
? a({ href: `/wiki/${encodeURIComponent(slugify(m.target))}`, class: 'wiki-link' }, m.label)
: m.label);
} else if (m.type === 'code-block') {
result.push(span({ class: 'rt-code-block' }, m.body));
} else if (m.type === 'rule') {
result.push(span({ class: 'rt-rule' }));
} else if (m.type === 'header') {
result.push(span({ class: `rt-header rt-header-${m.level}` }, ...inner(m.body)));
} else if (m.type === 'quote') {
result.push(span({ class: 'rt-quote' }, ...inner(m.body)));
} else if (m.type === 'bullet') {
result.push(span({ class: `rt-item rt-item-${m.level}` }, ...inner(m.body)));
} else if (m.type === 'number') {
result.push(span({ class: `rt-item rt-item-number rt-item-${m.level}` }, `${m.marker} `, ...inner(m.body)));
} else if (m.type === 'bold') {
result.push(strong(...inner(m.body)));
} else if (m.type === 'underline') {
result.push(u(...inner(m.body)));
} else if (m.type === 'strike') {
result.push(strike(...inner(m.body)));
} else if (m.type === 'italic') {
result.push(em(...inner(m.body)));
} else if (m.type === 'inline-code') {
result.push(code({ class: 'rt-code' }, m.body));
} else if (m.type === 'blob-image') {
const imageSrc = blobHref(m.blob);
if (opts.zoomImages) {
zoomSeq = (zoomSeq + 1) % 1000000;
const zoomId = `rtzoom-${zoomSeq}-${String(m.blob).replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
result.push(a({ href: `#${zoomId}`, id: `${zoomId}-src`, class: 'zoom-link' }, img({ src: imageSrc, alt: m.name || '', class: 'post-image' })));
result.push(span({ id: zoomId, class: 'lightbox' },
a({ href: `${imageSrc}${imageSrc.includes('?') ? '&' : '?'}download=1`, class: 'lightbox-download', title: getI18n().fileShareDownload || 'Download' }, '\u2913'),
a({ href: `#${zoomId}-src`, class: 'lightbox-close' }, '\u00d7'),
img({ src: imageSrc, alt: m.name || '', class: 'lightbox-image' })
));
} else {
result.push(img({ src: imageSrc, alt: m.name || '', class: 'post-image' }));
}
} else if (m.type === 'blob-video') {
result.push(video({ controls: true, class: 'post-video', src: blobHref(m.blob) }));
} else if (m.type === 'blob-audio') {
result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) }));
} else if (m.type === 'blob-pdf') {
const i18n = getI18n();
const pdfLabel = m.name || i18n.pdfFallbackLabel || 'PDF';
const clearnet = String(opts.blobPrefix || '').startsWith('/c/');
let lang = '';
try { lang = clearnet ? ((require('../models/typed_log').requestScope.getStore() || {}).cnLang || '') : ''; } catch (_) {}
const fid = require('./pdf_view').frameIdOf(blobHref(m.blob));
const query = new URLSearchParams({ src: blobHref(m.blob), title: String(m.name || ''), ...(lang ? { lang } : {}), fid });
const holder = (src, cls) => span({ class: 'pdf-holder' },
object({ id: fid, data: src, type: 'text/html', class: cls, title: pdfLabel, 'aria-label': pdfLabel }, ''),
a({ href: '#', class: 'pdf-unmax', title: i18n.pdfClose || 'Close' }, '✕'));
result.push(clearnet
? holder(`/c/pdf/embed?${query}`, 'cn-pdf-frame')
: opts.blobPrefix
? a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, pdfLabel)
: holder(`/pdf/embed?${query}`, 'pdf-frame'));
} else if (m.type === 'blob-file') {
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || m.blob.slice(1, 9)));
} else if (m.type === 'md-mention') {
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
} else if (m.type === 'raw-mention') {
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...') : '@' + m.feedId.slice(0, 8) + '...');
} else if (m.type === 'bare-mention') {
const feedId = internalOn && mentionResolver ? mentionResolver(m.name) : null;
result.push(feedId ? a({ href: `/author/${encodeURIComponent(feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
} else if (m.type === 'md-link') {
const external = /^https?:\/\//.test(m.href) && !SELF_HOST_RE.test(m.href);
result.push(external
? (linksOn ? a({ href: m.href, class: 'styled-link', target: '_blank', rel: 'noopener noreferrer' }, ...inner(m.label)) : plain(m.label))
: (internalOn ? a({ href: m.href, class: 'styled-link' }, ...inner(m.label)) : m.label));
} else if (m.type === 'url') {
const schemeMatch = m.text.match(/^(https?):\/\//i);
const href = schemeMatch ? `${schemeMatch[1].toLowerCase()}://${m.text.slice(schemeMatch[0].length)}` : `https://${m.text}`;
const internalTarget = internalTargetOf(href);
if (!linksOn) result.push(plain(m.text));
else if (internalTarget) {
result.push(internalOn
? a({ href: internalTarget.href, class: 'internal-link' },
span({ class: 'internal-link-kind' }, internalTarget.kind.toUpperCase()),
internalTarget.id ? span({ class: 'internal-link-id' }, shortInternalId(internalTarget.id)) : null
)
: plain(internalTarget.href));
}
else if (SELF_HOST_RE.test(href)) result.push(a({ href }, m.text));
else result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
} else if (m.type === 'email') {
result.push(linksOn ? a({ href: `mailto:${m.text}` }, m.text) : plain(m.text));
} else if (m.type === 'hashtag') {
const tagHref = typeof opts.hashtagHref === 'function' ? opts.hashtagHref(m.tag) : (internalOn ? `/search?query=%23${encodeURIComponent(m.tag)}` : null);
result.push(tagHref ? a({ href: tagHref, class: 'tag-link tag-inline' }, `#${m.tag}`) : `#${m.tag}`);
}
cursor = m.index + m.length;
}
if (cursor < text.length) result.push(text.slice(cursor));
return result;
}
const safeExternalHref = (url) => {
const value = String(url || '').trim();
const lower = value.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:')) return value;
return '';
};
const escapeHtml = (value) => String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function renderStyledHtml(text, opts = {}) {
if (text === null || text === undefined || text === '') return '';
return renderStyledText(String(text), opts)
.map(node => (node && typeof node === 'object' && node.outerHTML) ? node.outerHTML : escapeHtml(node))
.join('');
}
function plainText(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/```[\s\S]*?```/g, '')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/^[ \t]*---[ \t]*$/gm, '')
.replace(/^[ \t]*#{1,6}[ \t]*/gm, '')
.replace(/[ \t]*#{1,6}[ \t]*$/gm, '')
.replace(/^[ \t]*- /gm, '')
.replace(/^[ \t]*\d{1,3}[.)] /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!?\[[^\]\n]{0,160}\]\(\s*&[^)\s\n]{1,120}\.sha256\s*\)/g, '')
.replace(/\[(?:video|audio|pdf):[^\]\n]{0,160}\]\([^)\n]{0,200}\)/g, '')
.replace(/\[\[([^\]|\n]{1,120})(?:\|([^\]\n]{1,120}))?\]\]/g, (m, target, label) => label || target)
.replace(/!?\[([^\]\n]{1,160})\]\(([^)\s\n]{0,200})\)/g, (m, label, href) => href ? `${label} (${href})` : label)
.replace(/\\([\\*_~`#>\-\[\]!])/g, '$1')
.trim();
}
function renderTextPreview(text, maxLength = 220) {
const preview = plainText(text).replace(/\n+/g, ' ').trim();
return preview.length > maxLength ? preview.slice(0, maxLength) + '...' : preview;
}
const FORMAT_MARKS = [
{ mark: '**bold**', style: 'bold' },
{ mark: '*italic*', style: 'italic' },
{ mark: '__underline__', style: 'underline' },
{ mark: '~~strike~~', style: 'strike' },
{ mark: '`code`', style: 'code' },
{ mark: '### title ###', style: 'header' },
{ mark: '- list', style: 'item' },
{ mark: '> quote', style: 'quote' },
{ mark: '[[wiki]]', style: 'wiki' }
];
function renderFormatBar() {
const i18n = getI18n();
const marks = [];
FORMAT_MARKS.forEach((m, i) => {
if (i) marks.push(span({ class: 'rt-sep' }, '|'));
marks.push(span({ class: `rt-mark rt-mark-${m.style}` }, m.mark));
});
return div({ class: 'rt-toolbar' },
span({ class: 'rt-toolbar-label' }, `${i18n.formatBarLabel || 'Format'}:`),
...marks
);
}
function richTextarea(attrs, ...children) {
return [renderFormatBar(), textarea(attrs, ...children)];
}
module.exports = { renderStyledText, renderStyledHtml, renderTextPreview, plainText, escapeHtml, safeExternalHref, renderFormatBar, richTextarea, setMentionResolver, INTERNAL_PATHS };