A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo, useRef } from 'react';
2import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
3import { useDidResolution } from '../hooks/useDidResolution';
4import { useBlob } from '../hooks/useBlob';
5import { parseAtUri, formatDidForLabel, toBlueskyPostUrl, leafletRkeyUrl, normalizeLeafletBasePath } from '../utils/at-uri';
6import { BlueskyPost } from '../components/BlueskyPost';
7import type {
8 LeafletDocumentRecord,
9 LeafletLinearDocumentPage,
10 LeafletLinearDocumentBlock,
11 LeafletBlock,
12 LeafletTextBlock,
13 LeafletHeaderBlock,
14 LeafletBlockquoteBlock,
15 LeafletImageBlock,
16 LeafletUnorderedListBlock,
17 LeafletListItem,
18 LeafletWebsiteBlock,
19 LeafletIFrameBlock,
20 LeafletMathBlock,
21 LeafletCodeBlock,
22 LeafletBskyPostBlock,
23 LeafletAlignmentValue,
24 LeafletRichTextFacet,
25 LeafletRichTextFeature,
26 LeafletPublicationRecord
27} from '../types/leaflet';
28
29export interface LeafletDocumentRendererProps {
30 record: LeafletDocumentRecord;
31 loading: boolean;
32 error?: Error;
33 colorScheme?: ColorSchemePreference;
34 did: string;
35 rkey: string;
36 canonicalUrl?: string;
37 publicationBaseUrl?: string;
38 publicationRecord?: LeafletPublicationRecord;
39}
40
41export const LeafletDocumentRenderer: React.FC<LeafletDocumentRendererProps> = ({ record, loading, error, colorScheme = 'system', did, rkey, canonicalUrl, publicationBaseUrl, publicationRecord }) => {
42 const scheme = useColorScheme(colorScheme);
43 const palette = scheme === 'dark' ? theme.dark : theme.light;
44 const authorDid = record.author?.startsWith('did:') ? record.author : undefined;
45 const publicationUri = useMemo(() => parseAtUri(record.publication), [record.publication]);
46 const postUrl = useMemo(() => {
47 const postRefUri = record.postRef?.uri;
48 if (!postRefUri) return undefined;
49 const parsed = parseAtUri(postRefUri);
50 return parsed ? toBlueskyPostUrl(parsed) : undefined;
51 }, [record.postRef?.uri]);
52 const { handle: publicationHandle } = useDidResolution(publicationUri?.did);
53 const fallbackAuthorLabel = useAuthorLabel(record.author, authorDid);
54 const resolvedPublicationLabel = publicationRecord?.name?.trim()
55 ?? (publicationHandle ? `@${publicationHandle}` : publicationUri ? formatDidForLabel(publicationUri.did) : undefined);
56 const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel;
57 const authorHref = publicationUri ? `https://bsky.app/profile/${publicationUri.did}` : undefined;
58
59 if (error) return <div style={{ padding: 12, color: 'crimson' }}>Failed to load leaflet.</div>;
60 if (loading && !record) return <div style={{ padding: 12 }}>Loading leaflet…</div>;
61 if (!record) return <div style={{ padding: 12, color: 'crimson' }}>Leaflet record missing.</div>;
62
63 const publishedAt = record.publishedAt ? new Date(record.publishedAt) : undefined;
64 const publishedLabel = publishedAt ? publishedAt.toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short' }) : undefined;
65 const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
66 const publicationRoot = publicationBaseUrl ?? (publicationRecord?.base_path ?? undefined);
67 const resolvedPublicationRoot = publicationRoot ? normalizeLeafletBasePath(publicationRoot) : undefined;
68 const publicationLeafletUrl = leafletRkeyUrl(publicationRoot, rkey);
69 const viewUrl = canonicalUrl ?? publicationLeafletUrl ?? postUrl ?? (publicationUri ? `https://bsky.app/profile/${publicationUri.did}` : undefined) ?? fallbackLeafletUrl;
70
71 const metaItems: React.ReactNode[] = [];
72 if (authorLabel) {
73 const authorNode = authorHref
74 ? (
75 <a href={authorHref} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
76 {authorLabel}
77 </a>
78 )
79 : authorLabel;
80 metaItems.push(<span>By {authorNode}</span>);
81 }
82 if (publishedLabel) metaItems.push(<time dateTime={record.publishedAt}>{publishedLabel}</time>);
83 if (resolvedPublicationRoot) {
84 metaItems.push(
85 <a href={resolvedPublicationRoot} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
86 {resolvedPublicationRoot.replace(/^https?:\/\//, '')}
87 </a>
88 );
89 }
90 if (viewUrl) {
91 metaItems.push(
92 <a href={viewUrl} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
93 View source
94 </a>
95 );
96 }
97
98 return (
99 <article style={{ ...base.container, ...palette.container }}>
100 <header style={{ ...base.header, ...palette.header }}>
101 <div style={base.headerContent}>
102 <h1 style={{ ...base.title, ...palette.title }}>{record.title}</h1>
103 {record.description && (
104 <p style={{ ...base.subtitle, ...palette.subtitle }}>{record.description}</p>
105 )}
106 </div>
107 <div style={{ ...base.meta, ...palette.meta }}>
108 {metaItems.map((item, idx) => (
109 <React.Fragment key={`meta-${idx}`}>
110 {idx > 0 && <span style={palette.metaSeparator}>•</span>}
111 {item}
112 </React.Fragment>
113 ))}
114 </div>
115 </header>
116 <div style={base.body}>
117 {record.pages?.map((page, pageIndex) => (
118 <LeafletPageRenderer
119 key={`page-${pageIndex}`}
120 page={page}
121 documentDid={did}
122 colorScheme={scheme}
123 />
124 ))}
125 </div>
126 </article>
127 );
128};
129
130const LeafletPageRenderer: React.FC<{ page: LeafletLinearDocumentPage; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ page, documentDid, colorScheme }) => {
131 if (!page.blocks?.length) return null;
132 return (
133 <div style={base.page}>
134 {page.blocks.map((blockWrapper, idx) => (
135 <LeafletBlockRenderer
136 key={`block-${idx}`}
137 wrapper={blockWrapper}
138 documentDid={documentDid}
139 colorScheme={colorScheme}
140 isFirst={idx === 0}
141 />
142 ))}
143 </div>
144 );
145};
146
147interface LeafletBlockRendererProps {
148 wrapper: LeafletLinearDocumentBlock;
149 documentDid: string;
150 colorScheme: 'light' | 'dark';
151 isFirst?: boolean;
152}
153
154const LeafletBlockRenderer: React.FC<LeafletBlockRendererProps> = ({ wrapper, documentDid, colorScheme, isFirst }) => {
155 const block = wrapper.block;
156 if (!block || !('$type' in block) || !block.$type) {
157 return null;
158 }
159 const alignment = alignmentValue(wrapper.alignment);
160
161 switch (block.$type) {
162 case 'pub.leaflet.blocks.header':
163 return <LeafletHeaderBlockView block={block} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
164 case 'pub.leaflet.blocks.blockquote':
165 return <LeafletBlockquoteBlockView block={block} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
166 case 'pub.leaflet.blocks.image':
167 return <LeafletImageBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
168 case 'pub.leaflet.blocks.unorderedList':
169 return <LeafletListBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
170 case 'pub.leaflet.blocks.website':
171 return <LeafletWebsiteBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
172 case 'pub.leaflet.blocks.iframe':
173 return <LeafletIframeBlockView block={block} alignment={alignment} />;
174 case 'pub.leaflet.blocks.math':
175 return <LeafletMathBlockView block={block} alignment={alignment} colorScheme={colorScheme} />;
176 case 'pub.leaflet.blocks.code':
177 return <LeafletCodeBlockView block={block} alignment={alignment} colorScheme={colorScheme} />;
178 case 'pub.leaflet.blocks.horizontalRule':
179 return <LeafletHorizontalRuleBlockView alignment={alignment} colorScheme={colorScheme} />;
180 case 'pub.leaflet.blocks.bskyPost':
181 return <LeafletBskyPostBlockView block={block} colorScheme={colorScheme} />;
182 case 'pub.leaflet.blocks.text':
183 default:
184 return <LeafletTextBlockView block={block as LeafletTextBlock} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
185 }
186};
187
188const LeafletTextBlockView: React.FC<{ block: LeafletTextBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
189 const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
190 const textContent = block.plaintext ?? '';
191 if (!textContent.trim() && segments.length === 0) {
192 return null;
193 }
194 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
195 const style: React.CSSProperties = {
196 ...base.paragraph,
197 ...palette.paragraph,
198 ...(alignment ? { textAlign: alignment } : undefined),
199 ...(isFirst ? { marginTop: 0 } : undefined)
200 };
201 return (
202 <p style={style}>
203 {segments.map((segment, idx) => (
204 <React.Fragment key={`text-${idx}`}>
205 {renderSegment(segment, colorScheme)}
206 </React.Fragment>
207 ))}
208 </p>
209 );
210};
211
212const LeafletHeaderBlockView: React.FC<{ block: LeafletHeaderBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
213 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
214 const level = block.level && block.level >= 1 && block.level <= 6 ? block.level : 2;
215 const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
216 const normalizedLevel = Math.min(Math.max(level, 1), 6) as 1 | 2 | 3 | 4 | 5 | 6;
217 const headingTag = (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const)[normalizedLevel - 1];
218 const headingStyles = palette.heading[normalizedLevel];
219 const style: React.CSSProperties = {
220 ...base.heading,
221 ...headingStyles,
222 ...(alignment ? { textAlign: alignment } : undefined),
223 ...(isFirst ? { marginTop: 0 } : undefined)
224 };
225
226 return React.createElement(
227 headingTag,
228 { style },
229 segments.map((segment, idx) => (
230 <React.Fragment key={`header-${idx}`}>
231 {renderSegment(segment, colorScheme)}
232 </React.Fragment>
233 ))
234 );
235};
236
237const LeafletBlockquoteBlockView: React.FC<{ block: LeafletBlockquoteBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
238 const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
239 const textContent = block.plaintext ?? '';
240 if (!textContent.trim() && segments.length === 0) {
241 return null;
242 }
243 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
244 return (
245 <blockquote style={{ ...base.blockquote, ...palette.blockquote, ...(alignment ? { textAlign: alignment } : undefined), ...(isFirst ? { marginTop: 0 } : undefined) }}>
246 {segments.map((segment, idx) => (
247 <React.Fragment key={`quote-${idx}`}>
248 {renderSegment(segment, colorScheme)}
249 </React.Fragment>
250 ))}
251 </blockquote>
252 );
253};
254
255const LeafletImageBlockView: React.FC<{ block: LeafletImageBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
256 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
257 const cid = block.image?.ref?.$link ?? block.image?.cid;
258 const { url, loading, error } = useBlob(documentDid, cid);
259 const aspectRatio = block.aspectRatio?.height && block.aspectRatio?.width
260 ? `${block.aspectRatio.width} / ${block.aspectRatio.height}`
261 : undefined;
262
263 return (
264 <figure style={{ ...base.figure, ...palette.figure, ...(alignment ? { textAlign: alignment } : undefined) }}>
265 <div style={{ ...base.imageWrapper, ...palette.imageWrapper, ...(aspectRatio ? { aspectRatio } : {}) }}>
266 {url && !error ? (
267 <img src={url} alt={block.alt ?? ''} style={{ ...base.image, ...palette.image }} />
268 ) : (
269 <div style={{ ...base.imagePlaceholder, ...palette.imagePlaceholder }}>
270 {loading ? 'Loading image…' : error ? 'Image unavailable' : 'No image'}
271 </div>
272 )}
273 </div>
274 {block.alt && block.alt.trim().length > 0 && (
275 <figcaption style={{ ...base.caption, ...palette.caption }}>{block.alt}</figcaption>
276 )}
277 </figure>
278 );
279};
280
281const LeafletListBlockView: React.FC<{ block: LeafletUnorderedListBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
282 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
283 return (
284 <ul style={{ ...base.list, ...palette.list, ...(alignment ? { textAlign: alignment } : undefined) }}>
285 {block.children?.map((child, idx) => (
286 <LeafletListItemRenderer
287 key={`list-item-${idx}`}
288 item={child}
289 documentDid={documentDid}
290 colorScheme={colorScheme}
291 alignment={alignment}
292 />
293 ))}
294 </ul>
295 );
296};
297
298const LeafletListItemRenderer: React.FC<{ item: LeafletListItem; documentDid: string; colorScheme: 'light' | 'dark'; alignment?: React.CSSProperties['textAlign'] }> = ({ item, documentDid, colorScheme, alignment }) => {
299 return (
300 <li style={{ ...base.listItem, ...(alignment ? { textAlign: alignment } : undefined) }}>
301 <div>
302 <LeafletInlineBlock block={item.content} colorScheme={colorScheme} documentDid={documentDid} alignment={alignment} />
303 </div>
304 {item.children && item.children.length > 0 && (
305 <ul style={{ ...base.nestedList, ...(alignment ? { textAlign: alignment } : undefined) }}>
306 {item.children.map((child, idx) => (
307 <LeafletListItemRenderer key={`nested-${idx}`} item={child} documentDid={documentDid} colorScheme={colorScheme} alignment={alignment} />
308 ))}
309 </ul>
310 )}
311 </li>
312 );
313};
314
315const LeafletInlineBlock: React.FC<{ block: LeafletBlock; colorScheme: 'light' | 'dark'; documentDid: string; alignment?: React.CSSProperties['textAlign'] }> = ({ block, colorScheme, documentDid, alignment }) => {
316 switch (block.$type) {
317 case 'pub.leaflet.blocks.header':
318 return <LeafletHeaderBlockView block={block as LeafletHeaderBlock} colorScheme={colorScheme} alignment={alignment} />;
319 case 'pub.leaflet.blocks.blockquote':
320 return <LeafletBlockquoteBlockView block={block as LeafletBlockquoteBlock} colorScheme={colorScheme} alignment={alignment} />;
321 case 'pub.leaflet.blocks.image':
322 return <LeafletImageBlockView block={block as LeafletImageBlock} documentDid={documentDid} colorScheme={colorScheme} alignment={alignment} />;
323 default:
324 return <LeafletTextBlockView block={block as LeafletTextBlock} colorScheme={colorScheme} alignment={alignment} />;
325 }
326};
327
328const LeafletWebsiteBlockView: React.FC<{ block: LeafletWebsiteBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
329 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
330 const previewCid = block.previewImage?.ref?.$link ?? block.previewImage?.cid;
331 const { url, loading, error } = useBlob(documentDid, previewCid);
332
333 return (
334 <a href={block.src} target="_blank" rel="noopener noreferrer" style={{ ...base.linkCard, ...palette.linkCard, ...(alignment ? { textAlign: alignment } : undefined) }}>
335 {url && !error ? (
336 <img src={url} alt={block.title ?? 'Website preview'} style={{ ...base.linkPreview, ...palette.linkPreview }} />
337 ) : (
338 <div style={{ ...base.linkPreviewPlaceholder, ...palette.linkPreviewPlaceholder }}>
339 {loading ? 'Loading preview…' : 'Open link'}
340 </div>
341 )}
342 <div style={base.linkContent}>
343 {block.title && <strong style={palette.linkTitle}>{block.title}</strong>}
344 {block.description && <p style={palette.linkDescription}>{block.description}</p>}
345 <span style={palette.linkUrl}>{block.src}</span>
346 </div>
347 </a>
348 );
349};
350
351const LeafletIframeBlockView: React.FC<{ block: LeafletIFrameBlock; alignment?: React.CSSProperties['textAlign'] }> = ({ block, alignment }) => {
352 return (
353 <div style={{ ...(alignment ? { textAlign: alignment } : undefined) }}>
354 <iframe
355 src={block.url}
356 title={block.url}
357 style={{ ...base.iframe, ...(block.height ? { height: Math.min(Math.max(block.height, 120), 800) } : {}) }}
358 loading="lazy"
359 allowFullScreen
360 />
361 </div>
362 );
363};
364
365const LeafletMathBlockView: React.FC<{ block: LeafletMathBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ block, alignment, colorScheme }) => {
366 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
367 return (
368 <pre style={{ ...base.math, ...palette.math, ...(alignment ? { textAlign: alignment } : undefined) }}>{block.tex}</pre>
369 );
370};
371
372const LeafletCodeBlockView: React.FC<{ block: LeafletCodeBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ block, alignment, colorScheme }) => {
373 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
374 const codeRef = useRef<HTMLElement | null>(null);
375 const langClass = block.language ? `language-${block.language.toLowerCase()}` : undefined;
376 return (
377 <pre style={{ ...base.code, ...palette.code, ...(alignment ? { textAlign: alignment } : undefined) }}>
378 <code ref={codeRef} className={langClass}>{block.plaintext}</code>
379 </pre>
380 );
381};
382
383const LeafletHorizontalRuleBlockView: React.FC<{ alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ alignment, colorScheme }) => {
384 const palette = colorScheme === 'dark' ? theme.dark : theme.light;
385 return <hr style={{ ...base.hr, ...palette.hr, marginLeft: alignment ? 'auto' : undefined, marginRight: alignment ? 'auto' : undefined }} />;
386};
387
388const LeafletBskyPostBlockView: React.FC<{ block: LeafletBskyPostBlock; colorScheme: 'light' | 'dark' }> = ({ block, colorScheme }) => {
389 const parsed = parseAtUri(block.postRef?.uri);
390 if (!parsed) {
391 return <div style={base.embedFallback}>Referenced post unavailable.</div>;
392 }
393 return <BlueskyPost did={parsed.did} rkey={parsed.rkey} colorScheme={colorScheme} iconPlacement="linkInline" />;
394};
395
396function alignmentValue(value?: LeafletAlignmentValue): React.CSSProperties['textAlign'] | undefined {
397 if (!value) return undefined;
398 let normalized = value.startsWith('#') ? value.slice(1) : value;
399 if (normalized.includes('#')) {
400 normalized = normalized.split('#').pop() ?? normalized;
401 }
402 if (normalized.startsWith('lex:')) {
403 normalized = normalized.split(':').pop() ?? normalized;
404 }
405 switch (normalized) {
406 case 'textAlignLeft':
407 return 'left';
408 case 'textAlignCenter':
409 return 'center';
410 case 'textAlignRight':
411 return 'right';
412 case 'textAlignJustify':
413 return 'justify';
414 default:
415 return undefined;
416 }
417}
418
419function useAuthorLabel(author: string | undefined, authorDid: string | undefined): string | undefined {
420 const { handle } = useDidResolution(authorDid);
421 if (!author) return undefined;
422 if (handle) return `@${handle}`;
423 if (authorDid) return formatDidForLabel(authorDid);
424 return author;
425}
426
427interface Segment {
428 text: string;
429 features: LeafletRichTextFeature[];
430}
431
432function createFacetedSegments(plaintext: string, facets?: LeafletRichTextFacet[]): Segment[] {
433 if (!facets?.length) {
434 return [{ text: plaintext, features: [] }];
435 }
436 const prefix = buildBytePrefix(plaintext);
437 const startEvents = new Map<number, LeafletRichTextFeature[]>();
438 const endEvents = new Map<number, LeafletRichTextFeature[]>();
439 const boundaries = new Set<number>([0, prefix.length - 1]);
440 for (const facet of facets) {
441 const { byteStart, byteEnd } = facet.index ?? {};
442 if (typeof byteStart !== 'number' || typeof byteEnd !== 'number' || byteStart >= byteEnd) continue;
443 const start = byteOffsetToCharIndex(prefix, byteStart);
444 const end = byteOffsetToCharIndex(prefix, byteEnd);
445 if (start >= end) continue;
446 boundaries.add(start);
447 boundaries.add(end);
448 if (facet.features?.length) {
449 startEvents.set(start, [...(startEvents.get(start) ?? []), ...facet.features]);
450 endEvents.set(end, [...(endEvents.get(end) ?? []), ...facet.features]);
451 }
452 }
453 const sortedBounds = [...boundaries].sort((a, b) => a - b);
454 const segments: Segment[] = [];
455 let active: LeafletRichTextFeature[] = [];
456 for (let i = 0; i < sortedBounds.length - 1; i++) {
457 const boundary = sortedBounds[i];
458 const next = sortedBounds[i + 1];
459 const endFeatures = endEvents.get(boundary);
460 if (endFeatures?.length) {
461 active = active.filter((feature) => !endFeatures.includes(feature));
462 }
463 const startFeatures = startEvents.get(boundary);
464 if (startFeatures?.length) {
465 active = [...active, ...startFeatures];
466 }
467 if (boundary === next) continue;
468 const text = sliceByCharRange(plaintext, boundary, next);
469 segments.push({ text, features: active.slice() });
470 }
471 return segments;
472}
473
474function buildBytePrefix(text: string): number[] {
475 const encoder = new TextEncoder();
476 const prefix: number[] = [0];
477 let byteCount = 0;
478 for (let i = 0; i < text.length;) {
479 const codePoint = text.codePointAt(i)!;
480 const char = String.fromCodePoint(codePoint);
481 const encoded = encoder.encode(char);
482 byteCount += encoded.length;
483 prefix.push(byteCount);
484 i += codePoint > 0xffff ? 2 : 1;
485 }
486 return prefix;
487}
488
489function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number {
490 for (let i = 0; i < prefix.length; i++) {
491 if (prefix[i] === byteOffset) return i;
492 if (prefix[i] > byteOffset) return Math.max(0, i - 1);
493 }
494 return prefix.length - 1;
495}
496
497function sliceByCharRange(text: string, start: number, end: number): string {
498 if (start <= 0 && end >= text.length) return text;
499 let result = '';
500 let charIndex = 0;
501 for (let i = 0; i < text.length && charIndex < end;) {
502 const codePoint = text.codePointAt(i)!;
503 const char = String.fromCodePoint(codePoint);
504 if (charIndex >= start && charIndex < end) result += char;
505 i += codePoint > 0xffff ? 2 : 1;
506 charIndex++;
507 }
508 return result;
509}
510
511function renderSegment(segment: Segment, colorScheme: 'light' | 'dark'): React.ReactNode {
512 const parts = segment.text.split('\n');
513 return parts.flatMap((part, idx) => {
514 const key = `${segment.text}-${idx}-${part.length}`;
515 const wrapped = applyFeatures(part.length ? part : '\u00a0', segment.features, key, colorScheme);
516 if (idx === parts.length - 1) return wrapped;
517 return [wrapped, <br key={`${key}-br`} />];
518 });
519}
520
521function applyFeatures(content: React.ReactNode, features: LeafletRichTextFeature[], key: string, colorScheme: 'light' | 'dark'): React.ReactNode {
522 if (!features?.length) return <React.Fragment key={key}>{content}</React.Fragment>;
523 return (
524 <React.Fragment key={key}>
525 {features.reduce<React.ReactNode>((child, feature, idx) => wrapFeature(child, feature, `${key}-feature-${idx}`, colorScheme), content)}
526 </React.Fragment>
527 );
528}
529
530function wrapFeature(child: React.ReactNode, feature: LeafletRichTextFeature, key: string, colorScheme: 'light' | 'dark'): React.ReactNode {
531 switch (feature.$type) {
532 case 'pub.leaflet.richtext.facet#link':
533 return <a key={key} href={feature.uri} target="_blank" rel="noopener noreferrer" style={linkStyles[colorScheme]}>{child}</a>;
534 case 'pub.leaflet.richtext.facet#code':
535 return <code key={key} style={inlineCodeStyles[colorScheme]}>{child}</code>;
536 case 'pub.leaflet.richtext.facet#highlight':
537 return <mark key={key} style={highlightStyles[colorScheme]}>{child}</mark>;
538 case 'pub.leaflet.richtext.facet#underline':
539 return <span key={key} style={{ textDecoration: 'underline' }}>{child}</span>;
540 case 'pub.leaflet.richtext.facet#strikethrough':
541 return <span key={key} style={{ textDecoration: 'line-through' }}>{child}</span>;
542 case 'pub.leaflet.richtext.facet#bold':
543 return <strong key={key}>{child}</strong>;
544 case 'pub.leaflet.richtext.facet#italic':
545 return <em key={key}>{child}</em>;
546 case 'pub.leaflet.richtext.facet#id':
547 return <span key={key} id={feature.id}>{child}</span>;
548 default:
549 return <span key={key}>{child}</span>;
550 }
551}
552
553const base: Record<string, React.CSSProperties> = {
554 container: {
555 display: 'flex',
556 flexDirection: 'column',
557 gap: 24,
558 padding: '24px 28px',
559 borderRadius: 20,
560 border: '1px solid transparent',
561 maxWidth: 720,
562 width: '100%',
563 fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
564 },
565 header: {
566 display: 'flex',
567 flexDirection: 'column',
568 gap: 16
569 },
570 headerContent: {
571 display: 'flex',
572 flexDirection: 'column',
573 gap: 8
574 },
575 title: {
576 fontSize: 32,
577 margin: 0,
578 lineHeight: 1.15
579 },
580 subtitle: {
581 margin: 0,
582 fontSize: 16,
583 lineHeight: 1.5
584 },
585 meta: {
586 display: 'flex',
587 flexWrap: 'wrap',
588 gap: 8,
589 alignItems: 'center',
590 fontSize: 14
591 },
592 body: {
593 display: 'flex',
594 flexDirection: 'column',
595 gap: 18
596 },
597 page: {
598 display: 'flex',
599 flexDirection: 'column',
600 gap: 18
601 },
602 paragraph: {
603 margin: '1em 0 0',
604 lineHeight: 1.65,
605 fontSize: 16
606 },
607 heading: {
608 margin: '0.5em 0 0',
609 fontWeight: 700
610 },
611 blockquote: {
612 margin: '1em 0 0',
613 padding: '0.6em 1em',
614 borderLeft: '4px solid'
615 },
616 figure: {
617 margin: '1.2em 0 0',
618 display: 'flex',
619 flexDirection: 'column',
620 gap: 12
621 },
622 imageWrapper: {
623 borderRadius: 16,
624 overflow: 'hidden',
625 width: '100%',
626 position: 'relative',
627 background: '#e2e8f0'
628 },
629 image: {
630 width: '100%',
631 height: '100%',
632 objectFit: 'cover',
633 display: 'block'
634 },
635 imagePlaceholder: {
636 width: '100%',
637 padding: '24px 16px',
638 textAlign: 'center'
639 },
640 caption: {
641 fontSize: 13,
642 lineHeight: 1.4
643 },
644 list: {
645 paddingLeft: 28,
646 margin: '1em 0 0',
647 listStyleType: 'disc',
648 listStylePosition: 'outside'
649 },
650 nestedList: {
651 paddingLeft: 20,
652 marginTop: 8,
653 listStyleType: 'circle',
654 listStylePosition: 'outside'
655 },
656 listItem: {
657 marginTop: 8,
658 display: 'list-item'
659 },
660 linkCard: {
661 borderRadius: 16,
662 border: '1px solid',
663 display: 'flex',
664 flexDirection: 'column',
665 overflow: 'hidden',
666 textDecoration: 'none'
667 },
668 linkPreview: {
669 width: '100%',
670 height: 180,
671 objectFit: 'cover'
672 },
673 linkPreviewPlaceholder: {
674 width: '100%',
675 height: 180,
676 display: 'flex',
677 alignItems: 'center',
678 justifyContent: 'center',
679 fontSize: 14
680 },
681 linkContent: {
682 display: 'flex',
683 flexDirection: 'column',
684 gap: 6,
685 padding: '16px 18px'
686 },
687 iframe: {
688 width: '100%',
689 height: 360,
690 border: '1px solid #cbd5f5',
691 borderRadius: 16
692 },
693 math: {
694 margin: '1em 0 0',
695 padding: '14px 16px',
696 borderRadius: 12,
697 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
698 overflowX: 'auto'
699 },
700 code: {
701 margin: '1em 0 0',
702 padding: '14px 16px',
703 borderRadius: 12,
704 overflowX: 'auto',
705 fontSize: 14
706 },
707 hr: {
708 border: 0,
709 borderTop: '1px solid',
710 margin: '24px 0 0'
711 },
712 embedFallback: {
713 padding: '12px 16px',
714 borderRadius: 12,
715 border: '1px solid #e2e8f0',
716 fontSize: 14
717 }
718};
719
720const theme = {
721 light: {
722 container: {
723 background: '#ffffff',
724 borderColor: '#e2e8f0',
725 color: '#0f172a',
726 boxShadow: '0 4px 18px rgba(15, 23, 42, 0.06)'
727 },
728 header: {},
729 title: {
730 color: '#0f172a'
731 },
732 subtitle: {
733 color: '#475569'
734 },
735 meta: {
736 color: '#64748b'
737 },
738 metaLink: {
739 color: '#2563eb',
740 textDecoration: 'none'
741 } satisfies React.CSSProperties,
742 metaSeparator: {
743 margin: '0 4px'
744 } satisfies React.CSSProperties,
745 paragraph: {
746 color: '#1f2937'
747 },
748 heading: {
749 1: { color: '#0f172a', fontSize: 30 },
750 2: { color: '#0f172a', fontSize: 28 },
751 3: { color: '#0f172a', fontSize: 24 },
752 4: { color: '#0f172a', fontSize: 20 },
753 5: { color: '#0f172a', fontSize: 18 },
754 6: { color: '#0f172a', fontSize: 16 }
755 } satisfies Record<number, React.CSSProperties>,
756 blockquote: {
757 background: '#f8fafc',
758 borderColor: '#cbd5f5',
759 color: '#1f2937'
760 },
761 figure: {},
762 imageWrapper: {
763 background: '#e2e8f0'
764 },
765 image: {},
766 imagePlaceholder: {
767 color: '#475569'
768 },
769 caption: {
770 color: '#475569'
771 },
772 list: {
773 color: '#1f2937'
774 },
775 linkCard: {
776 borderColor: '#e2e8f0',
777 background: '#f8fafc',
778 color: '#0f172a'
779 },
780 linkPreview: {},
781 linkPreviewPlaceholder: {
782 background: '#e2e8f0',
783 color: '#475569'
784 },
785 linkTitle: {
786 fontSize: 16,
787 color: '#0f172a'
788 } satisfies React.CSSProperties,
789 linkDescription: {
790 margin: 0,
791 fontSize: 14,
792 color: '#475569',
793 lineHeight: 1.5
794 } satisfies React.CSSProperties,
795 linkUrl: {
796 fontSize: 13,
797 color: '#2563eb',
798 wordBreak: 'break-all'
799 } satisfies React.CSSProperties,
800 math: {
801 background: '#f1f5f9',
802 color: '#1f2937',
803 border: '1px solid #e2e8f0'
804 },
805 code: {
806 background: '#0f172a',
807 color: '#e2e8f0'
808 },
809 hr: {
810 borderColor: '#e2e8f0'
811 }
812 },
813 dark: {
814 container: {
815 background: 'rgba(15, 23, 42, 0.6)',
816 borderColor: 'rgba(148, 163, 184, 0.3)',
817 color: '#e2e8f0',
818 backdropFilter: 'blur(8px)',
819 boxShadow: '0 10px 40px rgba(2, 6, 23, 0.45)'
820 },
821 header: {},
822 title: {
823 color: '#f8fafc'
824 },
825 subtitle: {
826 color: '#cbd5f5'
827 },
828 meta: {
829 color: '#94a3b8'
830 },
831 metaLink: {
832 color: '#38bdf8',
833 textDecoration: 'none'
834 } satisfies React.CSSProperties,
835 metaSeparator: {
836 margin: '0 4px'
837 } satisfies React.CSSProperties,
838 paragraph: {
839 color: '#e2e8f0'
840 },
841 heading: {
842 1: { color: '#f8fafc', fontSize: 30 },
843 2: { color: '#f8fafc', fontSize: 28 },
844 3: { color: '#f8fafc', fontSize: 24 },
845 4: { color: '#e2e8f0', fontSize: 20 },
846 5: { color: '#e2e8f0', fontSize: 18 },
847 6: { color: '#e2e8f0', fontSize: 16 }
848 } satisfies Record<number, React.CSSProperties>,
849 blockquote: {
850 background: 'rgba(30, 41, 59, 0.6)',
851 borderColor: '#38bdf8',
852 color: '#e2e8f0'
853 },
854 figure: {},
855 imageWrapper: {
856 background: '#1e293b'
857 },
858 image: {},
859 imagePlaceholder: {
860 color: '#94a3b8'
861 },
862 caption: {
863 color: '#94a3b8'
864 },
865 list: {
866 color: '#f1f5f9'
867 },
868 linkCard: {
869 borderColor: 'rgba(148, 163, 184, 0.3)',
870 background: 'rgba(15, 23, 42, 0.8)',
871 color: '#e2e8f0'
872 },
873 linkPreview: {},
874 linkPreviewPlaceholder: {
875 background: '#1e293b',
876 color: '#94a3b8'
877 },
878 linkTitle: {
879 fontSize: 16,
880 color: '#e0f2fe'
881 } satisfies React.CSSProperties,
882 linkDescription: {
883 margin: 0,
884 fontSize: 14,
885 color: '#cbd5f5',
886 lineHeight: 1.5
887 } satisfies React.CSSProperties,
888 linkUrl: {
889 fontSize: 13,
890 color: '#38bdf8',
891 wordBreak: 'break-all'
892 } satisfies React.CSSProperties,
893 math: {
894 background: 'rgba(15, 23, 42, 0.8)',
895 color: '#e2e8f0',
896 border: '1px solid rgba(148, 163, 184, 0.35)'
897 },
898 code: {
899 background: '#020617',
900 color: '#e2e8f0'
901 },
902 hr: {
903 borderColor: 'rgba(148, 163, 184, 0.3)'
904 }
905 }
906} as const;
907
908const linkStyles = {
909 light: {
910 color: '#2563eb',
911 textDecoration: 'underline'
912 } satisfies React.CSSProperties,
913 dark: {
914 color: '#38bdf8',
915 textDecoration: 'underline'
916 } satisfies React.CSSProperties
917} as const;
918
919const inlineCodeStyles = {
920 light: {
921 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
922 background: '#f1f5f9',
923 padding: '0 4px',
924 borderRadius: 4
925 } satisfies React.CSSProperties,
926 dark: {
927 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
928 background: '#1e293b',
929 padding: '0 4px',
930 borderRadius: 4
931 } satisfies React.CSSProperties
932} as const;
933
934const highlightStyles = {
935 light: {
936 background: '#fef08a'
937 } satisfies React.CSSProperties,
938 dark: {
939 background: '#facc15',
940 color: '#0f172a'
941 } satisfies React.CSSProperties
942} as const;
943
944export default LeafletDocumentRenderer;