source: Klonkt/src/services/NoteRender.js@ 44c9f3f

main
Last change on this file since 44c9f3f was 3597763, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Web-timeline: custom-emoji + embedded quote-kaart (afgekeken van Shaer)

De Shaer-apps renderen FEP-9098 custom-emoji's en de FEP-044f quote-kaart
inline; de Klonkt web-timeline toonde nog letterlijke :shortcodes: en geen
quote. Nu rendert de web-nieuwsfeed dezelfde dingen server-side: emoji's in
content, auteur-naam en booster-naam, plus de geneste quote-kaart (avatar +
naam + content + thumbnail). De byline (avatar/naam/handle) en de boost-icon
(SVG, themebaar) had de web-UI al.

De data was al aanwezig op de timeline-rijen (emoji_json, author_emoji_json,
reblog_emoji_json, quote_json); dit is puur de render-kant.

New file:
src/services/NoteRender.js

  • pure helpers: emojiMap (beide vormen), emojiHtml (tag-bewust, skip code/pre), emojiName (escape + emoji), parseQuote

src/views/partials/quote-card.ejs

  • geneste quote-kaart (mirror van de Shaer QuoteCard)

test/note-render.test.js

  • 7 tests voor de render-helpers

Changed files:
src/middleware/render.js

  • emojiHtml/emojiName/noteQuote als template-helpers in de render-locals

src/views/partials/tl-item.ejs

  • content, auteur-naam en booster-naam via de emoji-helpers; quote-kaart erna

src/views/pages/news.ejs

  • CSS: img.emoji (inline, geen media-styling) + .tl-quote* (themebaar)

remarks: 187 tests groen (was 180). Visueel geverifieerd via een standalone
render van tl-item met echte emoji-urls. Volgende surfaces (messages, profiel,
cirkel) kunnen dezelfde helpers hergebruiken.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 3.6 KB
Line 
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
6const SHORTCODE = /:[A-Za-z0-9_+-]+:/g;
7
8const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
9export function escapeHtml(s) {
10 return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
11}
12function 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.
19export 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
41function emojiImg(url, alt) {
42 return `<img class="emoji" src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" title="${escapeAttr(alt)}" draggable="false" loading="lazy">`;
43}
44
45function 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.
52export 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.
81export 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.
89export 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
96export default { escapeHtml, emojiMap, emojiHtml, emojiName, parseQuote };
Note: See TracBrowser for help on using the repository browser.