ATProto social network
Something went wrong. Try again.
3.7 kB · 122 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123<script lang="ts"> import type { TextBlock } from '$lib/types/content-blocks'; import { marked } from 'marked'; import DOMPurify from 'isomorphic-dompurify';
type Props = { block: TextBlock; format?: 'plain' | 'markdown'; class?: string; };
let { block, format, class: className = '' }: Props = $props();
let effectiveFormat = $derived(format ?? block.format ?? 'plain');
/** * Render Markdown to sanitised HTML. */ function renderMarkdown(text: string): string { const raw = marked.parse(text, { async: false }) as string; return DOMPurify.sanitize(raw, { ALLOWED_TAGS: [ 'p', 'br', 'strong', 'em', 'a', 'img', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'del', 'hr' ], ALLOWED_ATTR: ['href', 'src', 'alt', 'target', 'rel', 'class'] }); }
/** * Process text with facets into HTML segments. * Facets annotate byte ranges with links, mentions, or hashtags. */ function renderRichText(text: string, facets?: TextBlock['facets']): string { if (!facets || facets.length === 0) { return escapeHtml(text); }
// Convert string to bytes for accurate slicing const encoder = new TextEncoder(); const decoder = new TextDecoder(); const bytes = encoder.encode(text);
// Sort facets by byteStart const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
let html = ''; let cursor = 0;
for (const facet of sorted) { const { byteStart, byteEnd } = facet.index;
// Add plain text before this facet if (byteStart > cursor) { html += escapeHtml(decoder.decode(bytes.slice(cursor, byteStart))); }
const facetText = escapeHtml(decoder.decode(bytes.slice(byteStart, byteEnd)));
// Process features const feature = facet.features[0]; if (feature) { if (feature.$type === 'app.bsky.richtext.facet#link' && feature.uri && isSafeUri(feature.uri)) { html += `<a href="${escapeAttr(feature.uri)}" class="text-primary underline hover:text-primary/80" target="_blank" rel="noopener noreferrer">${facetText}</a>`; } else if (feature.$type === 'app.bsky.richtext.facet#mention' && feature.did) { html += `<a href="/profile/${escapeAttr(feature.did)}" class="text-primary font-medium hover:underline">${facetText}</a>`; } else if (feature.$type === 'app.bsky.richtext.facet#tag' && feature.tag) { html += `<a href="/tag/${encodeURIComponent(feature.tag)}" class="text-secondary hover:underline">${facetText}</a>`; } else { html += facetText; } } else { html += facetText; }
cursor = byteEnd; }
// Add remaining text if (cursor < bytes.length) { html += escapeHtml(decoder.decode(bytes.slice(cursor))); }
return html; }
function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); }
function escapeAttr(str: string): string { return str.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); }
function isSafeUri(uri: string): boolean { const normalised = uri.trimStart().toLowerCase(); return normalised.startsWith('http:') || normalised.startsWith('https:'); }
let html = $derived( effectiveFormat === 'markdown' ? renderMarkdown(block.text) : DOMPurify.sanitize(renderRichText(block.text, block.facets), { ALLOWED_TAGS: ['a', 'span'], ALLOWED_ATTR: ['href', 'class', 'target', 'rel'] }) );</script>
{#if effectiveFormat === 'markdown'} <div class="prose prose-sm max-w-none {className}" data-testid="text-block"> {@html html} </div>{:else} <div class="text-base leading-relaxed whitespace-pre-wrap {className}" data-testid="text-block"> {@html html} </div>{/if}