| 1 | // Server-side rendering of the bits the Shaer clients render natively, so the
|
|---|
| 2 | // Klonkt web timeline looks the same: FEP-9098 custom emojis (`:shortcode:` →
|
|---|
| 3 | // image) in note content and display names, and the FEP-044f embedded quote
|
|---|
| 4 | // card. Pure + deterministic (no DB, no I/O), so it is unit-testable and cheap.
|
|---|
| 5 |
|
|---|
| 6 | const SHORTCODE = /:[A-Za-z0-9_+-]+:/g;
|
|---|
| 7 |
|
|---|
| 8 | const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|---|
| 9 | export function escapeHtml(s) {
|
|---|
| 10 | return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
|---|
| 11 | }
|
|---|
| 12 | function escapeAttr(s) {
|
|---|
| 13 | return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ESCAPES[c]);
|
|---|
| 14 | }
|
|---|
| 15 |
|
|---|
| 16 | // Normalise either representation into a { ":shortcode:": url } map:
|
|---|
| 17 | // - emoji_json: an array of Emoji tag objects [{ name, icon:{url} }]
|
|---|
| 18 | // - author_emoji_json / reblog_emoji_json / quote.emojis: already a map.
|
|---|
| 19 | export function emojiMap(json) {
|
|---|
| 20 | try {
|
|---|
| 21 | const v = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
|
|---|
| 22 | if (!v) return {};
|
|---|
| 23 | if (Array.isArray(v)) {
|
|---|
| 24 | const m = {};
|
|---|
| 25 | for (const t of v) {
|
|---|
| 26 | const icon = t && t.icon;
|
|---|
| 27 | const url = icon && (icon.url || (Array.isArray(icon) && icon[0] && icon[0].url));
|
|---|
| 28 | if (t && typeof t.name === 'string' && url) m[t.name] = url;
|
|---|
| 29 | }
|
|---|
| 30 | return m;
|
|---|
| 31 | }
|
|---|
| 32 | if (typeof v === 'object') {
|
|---|
| 33 | const m = {};
|
|---|
| 34 | for (const k of Object.keys(v)) if (typeof v[k] === 'string') m[k] = v[k];
|
|---|
| 35 | return m;
|
|---|
| 36 | }
|
|---|
| 37 | return {};
|
|---|
| 38 | } catch { return {}; }
|
|---|
| 39 | }
|
|---|
| 40 |
|
|---|
| 41 | function emojiImg(url, alt) {
|
|---|
| 42 | return `<img class="emoji" src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" title="${escapeAttr(alt)}" draggable="false" loading="lazy">`;
|
|---|
| 43 | }
|
|---|
| 44 |
|
|---|
| 45 | function substitute(text, map) {
|
|---|
| 46 | return text.replace(SHORTCODE, (m) => (map[m] ? emojiImg(map[m], m) : m));
|
|---|
| 47 | }
|
|---|
| 48 |
|
|---|
| 49 | // Inject <img> for each known custom emoji into an already-sanitised HTML
|
|---|
| 50 | // fragment (note content). Substitutes only in text between tags (never inside
|
|---|
| 51 | // a tag or its attributes) and skips <code>/<pre>, mirroring the Shaer render.
|
|---|
| 52 | export function emojiHtml(html, json) {
|
|---|
| 53 | const map = emojiMap(json);
|
|---|
| 54 | if (!html || !Object.keys(map).length) return html || '';
|
|---|
| 55 | let out = '';
|
|---|
| 56 | let i = 0;
|
|---|
| 57 | let code = 0;
|
|---|
| 58 | while (i < html.length) {
|
|---|
| 59 | if (html[i] === '<') {
|
|---|
| 60 | const close = html.indexOf('>', i);
|
|---|
| 61 | if (close < 0) { out += html.slice(i); break; }
|
|---|
| 62 | const raw = html.slice(i + 1, close);
|
|---|
| 63 | const name = raw.replace(/^\//, '').split(/[\s/>]/)[0].toLowerCase();
|
|---|
| 64 | if (name === 'code' || name === 'pre') code = Math.max(0, code + (raw[0] === '/' ? -1 : 1));
|
|---|
| 65 | out += html.slice(i, close + 1); // copy the tag verbatim
|
|---|
| 66 | i = close + 1;
|
|---|
| 67 | } else {
|
|---|
| 68 | const next = html.indexOf('<', i);
|
|---|
| 69 | const end = next < 0 ? html.length : next;
|
|---|
| 70 | const text = html.slice(i, end);
|
|---|
| 71 | out += code > 0 ? text : substitute(text, map);
|
|---|
| 72 | i = end;
|
|---|
| 73 | }
|
|---|
| 74 | }
|
|---|
| 75 | return out;
|
|---|
| 76 | }
|
|---|
| 77 |
|
|---|
| 78 | // A plain-text display name with custom emojis → safe HTML. The name is HTML-
|
|---|
| 79 | // escaped first; shortcode characters ([A-Za-z0-9_+-]) survive escaping, so the
|
|---|
| 80 | // image substitution stays correct.
|
|---|
| 81 | export function emojiName(text, json) {
|
|---|
| 82 | const esc = escapeHtml(text);
|
|---|
| 83 | const map = emojiMap(json);
|
|---|
| 84 | if (!Object.keys(map).length) return esc;
|
|---|
| 85 | return substitute(esc, map);
|
|---|
| 86 | }
|
|---|
| 87 |
|
|---|
| 88 | // The resolved quoted-post snapshot Klonkt stored (quote_json), or null.
|
|---|
| 89 | export function parseQuote(json) {
|
|---|
| 90 | try {
|
|---|
| 91 | const q = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
|
|---|
| 92 | return (q && typeof q === 'object' && q.url) ? q : null;
|
|---|
| 93 | } catch { return null; }
|
|---|
| 94 | }
|
|---|
| 95 |
|
|---|
| 96 | export default { escapeHtml, emojiMap, emojiHtml, emojiName, parseQuote };
|
|---|