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