a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

extract out facts to pages and use for email

Jared Pereira (Apr 22, 2026, 7:59 PM EDT) c6b25627 311088a8

+1003 -1040
+24 -3
actions/publications/sendPostPreview.tsx
··· 1 1 "use server"; 2 2 3 3 import { render } from "@react-email/render"; 4 + import { BlobRef } from "@atproto/lexicon"; 4 5 import { getIdentityData } from "actions/getIdentityData"; 5 6 import { supabaseServerClient } from "supabase/serverClient"; 6 7 import { getCurrentDeploymentDomain } from "src/utils/getCurrentDeploymentDomain"; 7 8 import { Ok, Err, type Result } from "src/result"; 8 9 import { EMAIL_REGEX } from "src/utils/confirmationEmail"; 9 - import { extractEmailBlocksFromFacts } from "src/utils/postToEmailBlocks"; 10 + import { processBlocksToPages } from "src/utils/factsToPagesRecord"; 10 11 import type { Fact } from "src/replicache"; 12 + import type { Attribute } from "src/replicache/attributes"; 11 13 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 14 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 12 15 import { PostEmail } from "emails/post"; 13 16 import { emailPropsFromPublication } from "emails/fromPublication"; 14 17 ··· 52 55 const { data: factsData } = await supabaseServerClient.rpc("get_facts", { 53 56 root: args.root_entity, 54 57 }); 55 - const facts = (factsData as unknown as Fact<any>[]) || []; 58 + const facts = (factsData as unknown as Fact<Attribute>[]) || []; 56 59 57 - const blocks = extractEmailBlocksFromFacts(facts, args.root_entity); 60 + // Preview runs on drafts before the document is published, so we can't 61 + // upload image blobs or poll records to the PDS here. The email template 62 + // detects an http(s) $link and uses it as a direct image URL; polls 63 + // render as "unsupported" in preview. 64 + const { pages } = await processBlocksToPages({ 65 + facts, 66 + root_entity: args.root_entity, 67 + hooks: { 68 + uploadImage: async (src) => 69 + ({ ref: { $link: src }, mimeType: "image/*", size: 0 }) as unknown as BlobRef, 70 + uploadPoll: null, 71 + }, 72 + }); 73 + const firstPage = pages[0]; 74 + const blocks = 75 + firstPage?.type === "doc" 76 + ? (firstPage.blocks as PubLeafletPagesLinearDocument.Block[]) 77 + : []; 58 78 59 79 const pubRecord = normalizePublicationRecord(publication.record); 60 80 const pubProps = emailPropsFromPublication(pubRecord); ··· 76 96 year: "numeric", 77 97 }), 78 98 blocks, 99 + did: identity.atp_did, 79 100 assetsBaseUrl, 80 101 // Omitting unsubscribeUrl triggers the "(preview)" footer branch. 81 102 }),
+40 -677
actions/publishToPublication.ts
··· 1 1 "use server"; 2 2 3 - import * as Y from "yjs"; 4 - import * as base64 from "base64-js"; 5 3 import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 6 4 import { getIdentityData } from "actions/getIdentityData"; 7 5 import { 8 6 AtpBaseClient, 9 - PubLeafletBlocksHeader, 10 - PubLeafletBlocksImage, 7 + PubLeafletBlocksBskyPost, 11 8 PubLeafletBlocksText, 12 - PubLeafletBlocksUnorderedList, 13 - PubLeafletBlocksOrderedList, 14 - PubLeafletDocument, 15 - SiteStandardDocument, 16 9 PubLeafletContent, 17 - PubLeafletPagesLinearDocument, 10 + PubLeafletDocument, 18 11 PubLeafletPagesCanvas, 12 + PubLeafletPagesLinearDocument, 19 13 PubLeafletRichtextFacet, 20 - PubLeafletBlocksWebsite, 21 - PubLeafletBlocksCode, 22 - PubLeafletBlocksMath, 23 - PubLeafletBlocksHorizontalRule, 24 - PubLeafletBlocksBskyPost, 25 - PubLeafletBlocksBlockquote, 26 - PubLeafletBlocksIframe, 27 - PubLeafletBlocksPage, 28 - PubLeafletBlocksPoll, 29 - PubLeafletBlocksButton, 30 - PubLeafletPollDefinition, 14 + SiteStandardDocument, 31 15 } from "lexicons/api"; 32 - import { Block } from "components/Blocks/Block"; 33 16 import { TID } from "@atproto/common"; 34 17 import { supabaseServerClient } from "supabase/serverClient"; 35 18 import { scanIndexLocal } from "src/replicache/utils"; 36 19 import type { Fact } from "src/replicache"; 37 20 import type { Attribute } from "src/replicache/attributes"; 38 - import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39 - import { ids } from "lexicons/api/lexicons"; 40 21 import { BlobRef } from "@atproto/lexicon"; 41 22 import { AtUri } from "@atproto/syntax"; 42 23 import { Json } from "supabase/database.types"; 43 - import { $Typed, UnicodeString } from "@atproto/api"; 44 - import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 45 - import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 46 24 import { Lock } from "src/utils/lock"; 47 25 import type { PubLeafletPublication } from "lexicons/api"; 26 + import { processBlocksToPages } from "src/utils/factsToPagesRecord"; 48 27 import { 49 28 normalizeDocumentRecord, 50 29 type NormalizedDocument, ··· 176 155 }); 177 156 let facts = (data as unknown as Fact<Attribute>[]) || []; 178 157 179 - let { pages } = await processBlocksToPages( 158 + const uploadLock = new Lock(); 159 + let { pages } = await processBlocksToPages({ 180 160 facts, 181 - agent, 182 161 root_entity, 183 - credentialSession.did!, 184 - ); 162 + hooks: { 163 + uploadImage: async (src: string) => { 164 + const data = await fetch(src); 165 + if (data.status !== 200) return; 166 + const binary = await data.blob(); 167 + return uploadLock.withLock(async () => { 168 + const blob = await agent.com.atproto.repo.uploadBlob(binary, { 169 + headers: { "Content-Type": binary.type }, 170 + }); 171 + return blob.data.blob; 172 + }); 173 + }, 174 + uploadPoll: async (entityId, record) => { 175 + // Use the entity id as the rkey so the editor can associate the poll 176 + // definition with the in-document poll block. 177 + const { data: pollResult } = await agent.com.atproto.repo.putRecord({ 178 + rkey: entityId, 179 + repo: credentialSession.did!, 180 + collection: record.$type, 181 + record, 182 + validate: false, 183 + }); 184 + console.log( 185 + await supabaseServerClient.from("atp_poll_records").upsert({ 186 + uri: pollResult.uri, 187 + cid: pollResult.cid, 188 + record: record as Json, 189 + }), 190 + ); 191 + return { uri: pollResult.uri, cid: pollResult.cid }; 192 + }, 193 + }, 194 + }); 185 195 186 196 let existingRecord: Partial<SiteStandardDocument.Record> = {}; 187 197 const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data); ··· 419 429 return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; 420 430 } 421 431 422 - async function processBlocksToPages( 423 - facts: Fact<any>[], 424 - agent: AtpBaseClient, 425 - root_entity: string, 426 - did: string, 427 - ) { 428 - let scan = scanIndexLocal(facts); 429 - let pages: { 430 - id: string; 431 - blocks: 432 - | PubLeafletPagesLinearDocument.Block[] 433 - | PubLeafletPagesCanvas.Block[]; 434 - type: "doc" | "canvas"; 435 - }[] = []; 436 - 437 - // Create a lock to serialize image uploads 438 - const uploadLock = new Lock(); 439 - 440 - let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 441 - if (!firstEntity) throw new Error("No root page"); 442 - 443 - // Check if the first page is a canvas or linear document 444 - let [pageType] = scan.eav(firstEntity.data.value, "page/type"); 445 - 446 - if (pageType?.data.value === "canvas") { 447 - // First page is a canvas 448 - let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did); 449 - pages.unshift({ 450 - id: firstEntity.data.value, 451 - blocks: canvasBlocks, 452 - type: "canvas", 453 - }); 454 - } else { 455 - // First page is a linear document 456 - let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 457 - let b = await blocksToRecord(blocks, did); 458 - pages.unshift({ 459 - id: firstEntity.data.value, 460 - blocks: b, 461 - type: "doc", 462 - }); 463 - } 464 - 465 - return { pages }; 466 - 467 - async function uploadImage(src: string) { 468 - let data = await fetch(src); 469 - if (data.status !== 200) return; 470 - let binary = await data.blob(); 471 - return uploadLock.withLock(async () => { 472 - let blob = await agent.com.atproto.repo.uploadBlob(binary, { 473 - headers: { "Content-Type": binary.type }, 474 - }); 475 - return blob.data.blob; 476 - }); 477 - } 478 - async function blocksToRecord( 479 - blocks: Block[], 480 - did: string, 481 - ): Promise<PubLeafletPagesLinearDocument.Block[]> { 482 - let parsedBlocks = parseBlocksToList(blocks); 483 - return ( 484 - await Promise.all( 485 - parsedBlocks.map(async (blockOrList) => { 486 - if (blockOrList.type === "block") { 487 - let alignmentValue = scan.eav( 488 - blockOrList.block.value, 489 - "block/text-alignment", 490 - )[0]?.data.value; 491 - let alignment: ExcludeString< 492 - PubLeafletPagesLinearDocument.Block["alignment"] 493 - > = 494 - alignmentValue === "center" 495 - ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 496 - : alignmentValue === "right" 497 - ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 498 - : alignmentValue === "justify" 499 - ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" 500 - : alignmentValue === "left" 501 - ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" 502 - : undefined; 503 - let b = await blockToRecord(blockOrList.block, did); 504 - if (!b) return []; 505 - let block: PubLeafletPagesLinearDocument.Block = { 506 - $type: "pub.leaflet.pages.linearDocument#block", 507 - block: b, 508 - }; 509 - if (alignment) block.alignment = alignment; 510 - return [block]; 511 - } else { 512 - let runs = splitListByStyle(blockOrList.children); 513 - let blocks = await Promise.all( 514 - runs.map(async (run) => { 515 - if (run.style === "ordered") { 516 - let block: PubLeafletPagesLinearDocument.Block = { 517 - $type: "pub.leaflet.pages.linearDocument#block", 518 - block: { 519 - $type: "pub.leaflet.blocks.orderedList", 520 - startIndex: 521 - run.children[0].block.listData?.listStart || 1, 522 - children: await orderedChildrenToRecord( 523 - run.children, 524 - did, 525 - ), 526 - }, 527 - }; 528 - return block; 529 - } else { 530 - let block: PubLeafletPagesLinearDocument.Block = { 531 - $type: "pub.leaflet.pages.linearDocument#block", 532 - block: { 533 - $type: "pub.leaflet.blocks.unorderedList", 534 - children: await unorderedChildrenToRecord( 535 - run.children, 536 - did, 537 - ), 538 - }, 539 - }; 540 - return block; 541 - } 542 - }), 543 - ); 544 - return blocks; 545 - } 546 - }), 547 - ) 548 - ).flat(); 549 - } 550 - 551 - function splitListByStyle(children: List[]) { 552 - let runs: { style: "ordered" | "unordered"; children: List[] }[] = []; 553 - for (let child of children) { 554 - let style: "ordered" | "unordered" = 555 - child.block.listData?.listStyle === "ordered" 556 - ? "ordered" 557 - : "unordered"; 558 - let last = runs[runs.length - 1]; 559 - if (last && last.style === style) { 560 - last.children.push(child); 561 - } else { 562 - runs.push({ style, children: [child] }); 563 - } 564 - } 565 - return runs; 566 - } 567 - 568 - async function unorderedChildrenToRecord( 569 - children: List[], 570 - did: string, 571 - ): Promise<PubLeafletBlocksUnorderedList.ListItem[]> { 572 - return ( 573 - await Promise.all( 574 - children.map(async (child) => { 575 - let content = await blockToRecord(child.block, did); 576 - if (!content) return []; 577 - let record: PubLeafletBlocksUnorderedList.ListItem = { 578 - $type: "pub.leaflet.blocks.unorderedList#listItem", 579 - content, 580 - ...(child.block.listData?.checklist && { 581 - checked: child.block.listData.checked ?? false, 582 - }), 583 - }; 584 - let sameStyle = child.children.filter( 585 - (c) => c.block.listData?.listStyle !== "ordered", 586 - ); 587 - let diffStyle = child.children.filter( 588 - (c) => c.block.listData?.listStyle === "ordered", 589 - ); 590 - if (sameStyle.length > 0) { 591 - record.children = await unorderedChildrenToRecord(sameStyle, did); 592 - } 593 - if (diffStyle.length > 0) { 594 - record.orderedListChildren = { 595 - $type: "pub.leaflet.blocks.orderedList", 596 - children: await orderedChildrenToRecord(diffStyle, did), 597 - }; 598 - } 599 - return record; 600 - }), 601 - ) 602 - ).flat(); 603 - } 604 - 605 - async function orderedChildrenToRecord( 606 - children: List[], 607 - did: string, 608 - ): Promise<PubLeafletBlocksOrderedList.ListItem[]> { 609 - return ( 610 - await Promise.all( 611 - children.map(async (child) => { 612 - let content = await blockToRecord(child.block, did); 613 - if (!content) return []; 614 - let record: PubLeafletBlocksOrderedList.ListItem = { 615 - $type: "pub.leaflet.blocks.orderedList#listItem", 616 - content, 617 - ...(child.block.listData?.checklist && { 618 - checked: child.block.listData.checked ?? false, 619 - }), 620 - }; 621 - let sameStyle = child.children.filter( 622 - (c) => c.block.listData?.listStyle === "ordered", 623 - ); 624 - let diffStyle = child.children.filter( 625 - (c) => c.block.listData?.listStyle !== "ordered", 626 - ); 627 - if (sameStyle.length > 0) { 628 - record.children = await orderedChildrenToRecord(sameStyle, did); 629 - } 630 - if (diffStyle.length > 0) { 631 - record.unorderedListChildren = { 632 - $type: "pub.leaflet.blocks.unorderedList", 633 - children: await unorderedChildrenToRecord(diffStyle, did), 634 - }; 635 - } 636 - return record; 637 - }), 638 - ) 639 - ).flat(); 640 - } 641 - async function blockToRecord(b: Block, did: string) { 642 - const footnoteContentResolver = (footnoteEntityID: string) => { 643 - let [content] = scan.eav(footnoteEntityID, "block/text"); 644 - if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[] }; 645 - let doc = new Y.Doc(); 646 - const update = base64.toByteArray(content.data.value); 647 - Y.applyUpdate(doc, update); 648 - let nodes = doc.getXmlElement("prosemirror").toArray(); 649 - let plaintext = YJSFragmentToString(nodes[0]); 650 - let { facets } = YJSFragmentToFacets(nodes[0]); 651 - return { plaintext, facets }; 652 - }; 653 - const getBlockContent = (b: string) => { 654 - let [content] = scan.eav(b, "block/text"); 655 - if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 656 - let doc = new Y.Doc(); 657 - const update = base64.toByteArray(content.data.value); 658 - Y.applyUpdate(doc, update); 659 - let nodes = doc.getXmlElement("prosemirror").toArray(); 660 - let stringValue = YJSFragmentToString(nodes[0]); 661 - let { facets } = YJSFragmentToFacets(nodes[0], 0, footnoteContentResolver); 662 - return [stringValue, facets] as const; 663 - }; 664 - if (b.type === "card") { 665 - let [page] = scan.eav(b.value, "block/card"); 666 - if (!page) return; 667 - let [pageType] = scan.eav(page.data.value, "page/type"); 668 - 669 - if (pageType?.data.value === "canvas") { 670 - let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); 671 - pages.push({ 672 - id: page.data.value, 673 - blocks: canvasBlocks, 674 - type: "canvas", 675 - }); 676 - } else { 677 - let blocks = getBlocksWithTypeLocal(facts, page.data.value); 678 - pages.push({ 679 - id: page.data.value, 680 - blocks: await blocksToRecord(blocks, did), 681 - type: "doc", 682 - }); 683 - } 684 - 685 - let block: $Typed<PubLeafletBlocksPage.Main> = { 686 - $type: "pub.leaflet.blocks.page", 687 - id: page.data.value, 688 - }; 689 - return block; 690 - } 691 - 692 - if (b.type === "bluesky-post") { 693 - let [post] = scan.eav(b.value, "block/bluesky-post"); 694 - if (!post || !post.data.value.post) return; 695 - let [hostFact] = scan.eav(b.value, "bluesky-post/host"); 696 - let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 697 - $type: ids.PubLeafletBlocksBskyPost, 698 - postRef: { 699 - uri: post.data.value.post.uri, 700 - cid: post.data.value.post.cid, 701 - }, 702 - clientHost: hostFact?.data.value, 703 - }; 704 - return block; 705 - } 706 - if (b.type === "horizontal-rule") { 707 - let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 708 - $type: ids.PubLeafletBlocksHorizontalRule, 709 - }; 710 - return block; 711 - } 712 - 713 - if (b.type === "heading") { 714 - let [headingLevel] = scan.eav(b.value, "block/heading-level"); 715 - 716 - let [stringValue, facets] = getBlockContent(b.value); 717 - let block: $Typed<PubLeafletBlocksHeader.Main> = { 718 - $type: "pub.leaflet.blocks.header", 719 - level: Math.floor(headingLevel?.data.value || 1), 720 - plaintext: stringValue, 721 - ...(facets.length > 0 && { facets }), 722 - }; 723 - return block; 724 - } 725 - 726 - if (b.type === "blockquote") { 727 - let [stringValue, facets] = getBlockContent(b.value); 728 - let block: $Typed<PubLeafletBlocksBlockquote.Main> = { 729 - $type: ids.PubLeafletBlocksBlockquote, 730 - plaintext: stringValue, 731 - ...(facets.length > 0 && { facets }), 732 - }; 733 - return block; 734 - } 735 - 736 - if (b.type == "text") { 737 - let [stringValue, facets] = getBlockContent(b.value); 738 - let [textSize] = scan.eav(b.value, "block/text-size"); 739 - let block: $Typed<PubLeafletBlocksText.Main> = { 740 - $type: ids.PubLeafletBlocksText, 741 - plaintext: stringValue, 742 - ...(facets.length > 0 && { facets }), 743 - ...(textSize && { textSize: textSize.data.value }), 744 - }; 745 - return block; 746 - } 747 - if (b.type === "embed") { 748 - let [url] = scan.eav(b.value, "embed/url"); 749 - let [height] = scan.eav(b.value, "embed/height"); 750 - let [aspectRatio] = scan.eav(b.value, "embed/aspect-ratio"); 751 - if (!url) return; 752 - let block: $Typed<PubLeafletBlocksIframe.Main> = { 753 - $type: "pub.leaflet.blocks.iframe", 754 - url: url.data.value, 755 - height: Math.floor(height?.data.value || 600), 756 - }; 757 - if (aspectRatio) { 758 - let [w, h] = aspectRatio.data.value.split("/").map(Number); 759 - if (w && h) { 760 - block.aspectRatio = { width: w, height: h }; 761 - } 762 - } 763 - return block; 764 - } 765 - if (b.type == "image") { 766 - let [image] = scan.eav(b.value, "block/image"); 767 - if (!image) return; 768 - let [altText] = scan.eav(b.value, "image/alt"); 769 - let [fullBleed] = scan.eav(b.value, "image/full-bleed"); 770 - let blobref = await uploadImage(image.data.src); 771 - if (!blobref) return; 772 - let block: $Typed<PubLeafletBlocksImage.Main> = { 773 - $type: "pub.leaflet.blocks.image", 774 - image: blobref, 775 - aspectRatio: { 776 - height: Math.floor(image.data.height), 777 - width: Math.floor(image.data.width), 778 - }, 779 - alt: altText ? altText.data.value : undefined, 780 - fullBleed: fullBleed?.data.value || undefined, 781 - }; 782 - return block; 783 - } 784 - if (b.type === "link") { 785 - let [previewImage] = scan.eav(b.value, "link/preview"); 786 - let [description] = scan.eav(b.value, "link/description"); 787 - let [src] = scan.eav(b.value, "link/url"); 788 - if (!src) return; 789 - let blobref = previewImage 790 - ? await uploadImage(previewImage?.data.src) 791 - : undefined; 792 - let [title] = scan.eav(b.value, "link/title"); 793 - let block: $Typed<PubLeafletBlocksWebsite.Main> = { 794 - $type: "pub.leaflet.blocks.website", 795 - previewImage: blobref, 796 - src: src.data.value, 797 - description: description?.data.value, 798 - title: title?.data.value, 799 - }; 800 - return block; 801 - } 802 - if (b.type === "code") { 803 - let [language] = scan.eav(b.value, "block/code-language"); 804 - let [code] = scan.eav(b.value, "block/code"); 805 - let [theme] = scan.eav(root_entity, "theme/code-theme"); 806 - let block: $Typed<PubLeafletBlocksCode.Main> = { 807 - $type: "pub.leaflet.blocks.code", 808 - language: language?.data.value, 809 - plaintext: code?.data.value || "", 810 - syntaxHighlightingTheme: theme?.data.value, 811 - }; 812 - return block; 813 - } 814 - if (b.type === "math") { 815 - let [math] = scan.eav(b.value, "block/math"); 816 - let block: $Typed<PubLeafletBlocksMath.Main> = { 817 - $type: "pub.leaflet.blocks.math", 818 - tex: math?.data.value || "", 819 - }; 820 - return block; 821 - } 822 - if (b.type === "poll") { 823 - // Get poll options from the entity 824 - let pollOptions = scan.eav(b.value, "poll/options"); 825 - let options: PubLeafletPollDefinition.Option[] = pollOptions.map( 826 - (opt) => { 827 - let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 828 - return { 829 - $type: "pub.leaflet.poll.definition#option", 830 - text: optionName?.data.value || "", 831 - }; 832 - }, 833 - ); 834 - 835 - // Create the poll definition record 836 - let pollRecord: PubLeafletPollDefinition.Record = { 837 - $type: "pub.leaflet.poll.definition", 838 - name: "Poll", // Default name, can be customized 839 - options, 840 - }; 841 - 842 - // Upload the poll record 843 - let { data: pollResult } = await agent.com.atproto.repo.putRecord({ 844 - //use the entity id as the rkey so we can associate it in the editor 845 - rkey: b.value, 846 - repo: did, 847 - collection: pollRecord.$type, 848 - record: pollRecord, 849 - validate: false, 850 - }); 851 - 852 - // Optimistically write poll definition to database 853 - console.log( 854 - await supabaseServerClient.from("atp_poll_records").upsert({ 855 - uri: pollResult.uri, 856 - cid: pollResult.cid, 857 - record: pollRecord as Json, 858 - }), 859 - ); 860 - 861 - // Return a poll block with reference to the poll record 862 - let block: $Typed<PubLeafletBlocksPoll.Main> = { 863 - $type: "pub.leaflet.blocks.poll", 864 - pollRef: { 865 - uri: pollResult.uri, 866 - cid: pollResult.cid, 867 - }, 868 - }; 869 - return block; 870 - } 871 - if (b.type === "button") { 872 - let [text] = scan.eav(b.value, "button/text"); 873 - let [url] = scan.eav(b.value, "button/url"); 874 - if (!text || !url) return; 875 - let block: $Typed<PubLeafletBlocksButton.Main> = { 876 - $type: "pub.leaflet.blocks.button", 877 - text: text.data.value, 878 - url: url.data.value, 879 - }; 880 - return block; 881 - } 882 - return; 883 - } 884 - 885 - async function canvasBlocksToRecord( 886 - pageID: string, 887 - did: string, 888 - ): Promise<PubLeafletPagesCanvas.Block[]> { 889 - let canvasBlocks = scan.eav(pageID, "canvas/block"); 890 - return ( 891 - await Promise.all( 892 - canvasBlocks.map(async (canvasBlock) => { 893 - let blockEntity = canvasBlock.data.value; 894 - let position = canvasBlock.data.position; 895 - 896 - // Get the block content 897 - let blockType = scan.eav(blockEntity, "block/type")?.[0]; 898 - if (!blockType) return null; 899 - 900 - let block: Block = { 901 - type: blockType.data.value, 902 - value: blockEntity, 903 - parent: pageID, 904 - position: "", 905 - factID: canvasBlock.id, 906 - }; 907 - 908 - let content = await blockToRecord(block, did); 909 - if (!content) return null; 910 - 911 - // Get canvas-specific properties 912 - let width = 913 - scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 914 - let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] 915 - ?.data.value; 916 - 917 - let canvasBlockRecord: PubLeafletPagesCanvas.Block = { 918 - $type: "pub.leaflet.pages.canvas#block", 919 - block: content, 920 - x: Math.floor(position.x), 921 - y: Math.floor(position.y), 922 - width: Math.floor(width), 923 - ...(rotation !== undefined && { rotation: Math.floor(rotation) }), 924 - }; 925 - 926 - return canvasBlockRecord; 927 - }), 928 - ) 929 - ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 930 - } 931 - } 932 - 933 - function YJSFragmentToFacets( 934 - node: Y.XmlElement | Y.XmlText | Y.XmlHook, 935 - byteOffset: number = 0, 936 - footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] }, 937 - ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 938 - if (node.constructor === Y.XmlElement) { 939 - // Handle footnote inline nodes 940 - if (node.nodeName === "footnote") { 941 - const footnoteEntityID = node.getAttribute("footnoteEntityID") || ""; 942 - const placeholder = "*"; 943 - const unicodestring = new UnicodeString(placeholder); 944 - let footnoteContent = footnoteContentResolver?.(footnoteEntityID); 945 - const facet: PubLeafletRichtextFacet.Main = { 946 - index: { 947 - byteStart: byteOffset, 948 - byteEnd: byteOffset + unicodestring.length, 949 - }, 950 - features: [ 951 - { 952 - $type: "pub.leaflet.richtext.facet#footnote", 953 - footnoteId: footnoteEntityID, 954 - contentPlaintext: footnoteContent?.plaintext || "", 955 - ...(footnoteContent?.facets?.length 956 - ? { contentFacets: footnoteContent.facets } 957 - : {}), 958 - }, 959 - ], 960 - }; 961 - return { facets: [facet], byteLength: unicodestring.length }; 962 - } 963 - 964 - // Handle inline mention nodes 965 - if (node.nodeName === "didMention") { 966 - const text = node.getAttribute("text") || ""; 967 - const unicodestring = new UnicodeString(text); 968 - const facet: PubLeafletRichtextFacet.Main = { 969 - index: { 970 - byteStart: byteOffset, 971 - byteEnd: byteOffset + unicodestring.length, 972 - }, 973 - features: [ 974 - { 975 - $type: "pub.leaflet.richtext.facet#didMention", 976 - did: node.getAttribute("did"), 977 - }, 978 - ], 979 - }; 980 - return { facets: [facet], byteLength: unicodestring.length }; 981 - } 982 - 983 - if (node.nodeName === "atMention") { 984 - const text = node.getAttribute("text") || ""; 985 - const unicodestring = new UnicodeString(text); 986 - const facet: PubLeafletRichtextFacet.Main = { 987 - index: { 988 - byteStart: byteOffset, 989 - byteEnd: byteOffset + unicodestring.length, 990 - }, 991 - features: [ 992 - { 993 - $type: "pub.leaflet.richtext.facet#atMention", 994 - atURI: node.getAttribute("atURI"), 995 - ...(node.getAttribute("href") 996 - ? { href: node.getAttribute("href") } 997 - : {}), 998 - }, 999 - ], 1000 - }; 1001 - return { facets: [facet], byteLength: unicodestring.length }; 1002 - } 1003 - 1004 - if (node.nodeName === "hard_break") { 1005 - const unicodestring = new UnicodeString("\n"); 1006 - return { facets: [], byteLength: unicodestring.length }; 1007 - } 1008 - 1009 - // For other elements (like paragraph), process children 1010 - let allFacets: PubLeafletRichtextFacet.Main[] = []; 1011 - let currentOffset = byteOffset; 1012 - for (const child of node.toArray()) { 1013 - const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver); 1014 - allFacets.push(...result.facets); 1015 - currentOffset += result.byteLength; 1016 - } 1017 - return { facets: allFacets, byteLength: currentOffset - byteOffset }; 1018 - } 1019 - 1020 - if (node.constructor === Y.XmlText) { 1021 - let facets: PubLeafletRichtextFacet.Main[] = []; 1022 - let delta = node.toDelta() as Delta[]; 1023 - let byteStart = byteOffset; 1024 - let totalLength = 0; 1025 - for (let d of delta) { 1026 - let unicodestring = new UnicodeString(d.insert); 1027 - let facet: PubLeafletRichtextFacet.Main = { 1028 - index: { 1029 - byteStart, 1030 - byteEnd: byteStart + unicodestring.length, 1031 - }, 1032 - features: [], 1033 - }; 1034 - 1035 - if (d.attributes?.strikethrough) 1036 - facet.features.push({ 1037 - $type: "pub.leaflet.richtext.facet#strikethrough", 1038 - }); 1039 - 1040 - if (d.attributes?.code) 1041 - facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); 1042 - if (d.attributes?.highlight) 1043 - facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); 1044 - if (d.attributes?.underline) 1045 - facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); 1046 - if (d.attributes?.strong) 1047 - facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); 1048 - if (d.attributes?.em) 1049 - facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); 1050 - if (d.attributes?.link) 1051 - facet.features.push({ 1052 - $type: "pub.leaflet.richtext.facet#link", 1053 - uri: d.attributes.link.href, 1054 - }); 1055 - if (facet.features.length > 0) facets.push(facet); 1056 - byteStart += unicodestring.length; 1057 - totalLength += unicodestring.length; 1058 - } 1059 - return { facets, byteLength: totalLength }; 1060 - } 1061 - return { facets: [], byteLength: 0 }; 1062 - } 1063 - 1064 - type ExcludeString<T> = T extends string 1065 - ? string extends T 1066 - ? never 1067 - : T /* maybe literal, not the whole `string` */ 1068 - : T; /* not a string */ 1069 432 1070 433 async function extractThemeFromFacts( 1071 434 facts: Fact<any>[],
+15 -11
app/api/inngest/functions/send_post_broadcast.ts
··· 1 1 import { render } from "@react-email/render"; 2 + import { AtUri } from "@atproto/syntax"; 2 3 import { inngest } from "../client"; 3 4 import { supabaseServerClient } from "supabase/serverClient"; 4 5 import { PostEmail } from "emails/post"; 5 6 import { emailPropsFromPublication } from "emails/fromPublication"; 6 - import { extractEmailBlocksFromFacts } from "src/utils/postToEmailBlocks"; 7 7 import { 8 + getDocumentPages, 8 9 normalizeDocumentRecord, 9 10 normalizePublicationRecord, 10 11 } from "src/utils/normalizeRecords"; 11 - import type { Fact } from "src/replicache"; 12 + import { PubLeafletPagesLinearDocument } from "lexicons/api"; 12 13 import type { Json } from "supabase/database.types"; 13 14 14 15 const BATCH_SIZE = 500; ··· 37 38 }, 38 39 { event: "newsletter/post.send.requested" }, 39 40 async ({ event, step }) => { 40 - const { publication_uri, document_uri, root_entity } = event.data; 41 + const { publication_uri, document_uri } = event.data; 41 42 42 43 const loaded = await step.run("load-pub-and-doc", async () => { 43 44 const [pubRes, docRes] = await Promise.all([ ··· 83 84 ? `${pubRecord.url.replace(/\/$/, "")}${docRecord.path}` 84 85 : pubProps.publicationUrl; 85 86 const replyToEmail = settings.reply_to_email; 87 + const did = new AtUri(document_uri).host; 88 + 89 + // The first page is the document body. Canvas pages don't map to a linear 90 + // email body — the email renders an empty postContent section and falls 91 + // back to the "See Full Post" link. 92 + const firstPage = docRecord ? getDocumentPages(docRecord)?.[0] : undefined; 93 + const blocks: PubLeafletPagesLinearDocument.Block[] = 94 + firstPage?.$type === "pub.leaflet.pages.linearDocument" 95 + ? (firstPage as PubLeafletPagesLinearDocument.Main).blocks ?? [] 96 + : []; 86 97 87 98 const subscribers = await step.run("snapshot-subscribers", async () => { 88 99 const { data } = await supabaseServerClient ··· 113 124 return { sent: 0 }; 114 125 } 115 126 116 - const blocks = await step.run("extract-blocks", async () => { 117 - const { data: factsData } = await supabaseServerClient.rpc("get_facts", { 118 - root: root_entity, 119 - }); 120 - const facts = (factsData as unknown as Fact<any>[]) || []; 121 - return extractEmailBlocksFromFacts(facts, root_entity); 122 - }); 123 - 124 127 const assetsBaseUrl = ( 125 128 process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub" 126 129 ).replace(/\/$/, ""); ··· 134 137 postDescription, 135 138 postUrl, 136 139 blocks, 140 + did, 137 141 assetsBaseUrl: `${assetsBaseUrl}/`, 138 142 unsubscribeUrl: UNSUB_PLACEHOLDER, 139 143 }),
+230 -98
emails/post.tsx
··· 18 18 import type { PrismLanguage } from "@react-email/code-block"; 19 19 import { Tailwind, pixelBasedPreset } from "@react-email/components"; 20 20 import React from "react"; 21 - import type { EmailBlock, EmailListItem } from "src/utils/postToEmailBlocks"; 21 + import { 22 + PubLeafletBlocksBlockquote, 23 + PubLeafletBlocksCode, 24 + PubLeafletBlocksHeader, 25 + PubLeafletBlocksHorizontalRule, 26 + PubLeafletBlocksImage, 27 + PubLeafletBlocksOrderedList, 28 + PubLeafletBlocksText, 29 + PubLeafletBlocksUnorderedList, 30 + PubLeafletBlocksWebsite, 31 + PubLeafletPagesLinearDocument, 32 + } from "lexicons/api"; 33 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 22 34 23 35 export type EmailTheme = { 24 36 primary: string; ··· 48 60 postUrl: string; 49 61 authorName?: string; 50 62 publishedAtLabel?: string; 51 - blocks: EmailBlock[]; 63 + blocks: PubLeafletPagesLinearDocument.Block[]; 64 + /** 65 + * Used to build absolute URLs for `pub.leaflet.blocks.image` blob refs via 66 + * the /api/atproto_images proxy. Preview renders from drafts may encode a 67 + * direct URL in the blob ref's $link instead — we pass those through. 68 + */ 69 + did: string; 52 70 /** 53 71 * If omitted the template renders a "(preview)" footer in place of the 54 72 * real unsubscribe link — used by the Phase 4 preview-send path. ··· 67 85 return `${base}${sep}interactionDrawer=${drawer}`; 68 86 }; 69 87 88 + const textBlock = (plaintext: string): PubLeafletPagesLinearDocument.Block => ({ 89 + $type: "pub.leaflet.pages.linearDocument#block", 90 + block: { $type: "pub.leaflet.blocks.text", plaintext }, 91 + }); 92 + 93 + const headingBlock = ( 94 + plaintext: string, 95 + level: number, 96 + ): PubLeafletPagesLinearDocument.Block => ({ 97 + $type: "pub.leaflet.pages.linearDocument#block", 98 + block: { $type: "pub.leaflet.blocks.header", plaintext, level }, 99 + }); 100 + 70 101 const defaultProps: PostEmailProps = { 71 102 publicationName: "Publication", 72 103 publicationUrl: "https://leaflet.pub", ··· 77 108 authorName: "author", 78 109 publishedAtLabel: "Jan 1, 2026", 79 110 assetsBaseUrl: "https://leaflet.pub/", 111 + did: "did:plc:example", 80 112 blocks: [ 113 + textBlock( 114 + "This would be the post. I'll give it a little lorem ipsum to make it look longer so i don't forget which thing is what.", 115 + ), 116 + headingBlock("This is a Title", 1), 117 + textBlock( 118 + "We'll keep it nice and separate so we can see what it looks like.", 119 + ), 120 + headingBlock("And a Header", 2), 81 121 { 82 - type: "text", 83 - plaintext: 84 - "This would be the post. I'll give it a little lorem ipsum to make it look longer so i don't forget which thing is what.", 122 + $type: "pub.leaflet.pages.linearDocument#block", 123 + block: { 124 + $type: "pub.leaflet.blocks.unorderedList", 125 + children: [ 126 + { 127 + content: { $type: "pub.leaflet.blocks.text", plaintext: "fruits" }, 128 + children: [ 129 + { 130 + content: { 131 + $type: "pub.leaflet.blocks.text", 132 + plaintext: "apple", 133 + }, 134 + }, 135 + { 136 + content: { 137 + $type: "pub.leaflet.blocks.text", 138 + plaintext: "banana", 139 + }, 140 + }, 141 + ], 142 + }, 143 + { 144 + content: { 145 + $type: "pub.leaflet.blocks.text", 146 + plaintext: "veggies", 147 + }, 148 + }, 149 + ], 150 + }, 85 151 }, 86 - { type: "heading", level: 1, plaintext: "This is a Title" }, 87 152 { 88 - type: "text", 89 - plaintext: "We'll keep it nice and separate so we can see what it looks like.", 153 + $type: "pub.leaflet.pages.linearDocument#block", 154 + block: { 155 + $type: "pub.leaflet.blocks.blockquote", 156 + plaintext: "A quote of some kind.", 157 + }, 90 158 }, 91 - { type: "heading", level: 2, plaintext: "And a Header" }, 159 + { 160 + $type: "pub.leaflet.pages.linearDocument#block", 161 + block: { 162 + $type: "pub.leaflet.blocks.code", 163 + plaintext: "const x = 1;", 164 + language: "javascript", 165 + }, 166 + }, 92 167 { 93 - type: "list", 94 - style: "unordered", 95 - items: [ 96 - { plaintext: "fruits", children: [ 97 - { type: "list", style: "unordered", items: [ 98 - { plaintext: "apple" }, 99 - { plaintext: "banana" }, 100 - ] }, 101 - ] }, 102 - { plaintext: "veggies" }, 103 - ], 168 + $type: "pub.leaflet.pages.linearDocument#block", 169 + block: { 170 + $type: "pub.leaflet.blocks.website", 171 + src: "https://example.com", 172 + title: "Link Title Here", 173 + description: "Description on the link", 174 + }, 175 + }, 176 + { 177 + $type: "pub.leaflet.pages.linearDocument#block", 178 + block: { $type: "pub.leaflet.blocks.horizontalRule" }, 104 179 }, 105 - { type: "blockquote", plaintext: "A quote of some kind." }, 106 - { type: "code", code: "const x = 1;", language: "javascript" }, 107 180 { 108 - type: "link", 109 - url: "https://example.com", 110 - title: "Link Title Here", 111 - description: "Description on the link", 181 + $type: "pub.leaflet.pages.linearDocument#block", 182 + block: { $type: "pub.leaflet.blocks.math" }, 112 183 }, 113 - { type: "horizontal-rule" }, 114 - { type: "unsupported", kind: "math" }, 115 184 ], 116 185 }; 117 186 ··· 245 314 </Section> 246 315 ) : null} 247 316 <Section className="postContent"> 248 - {p.blocks.map((block, i) => ( 249 - <BlockRenderer key={i} block={block} assetsBaseUrl={p.assetsBaseUrl} /> 317 + {p.blocks.map((b, i) => ( 318 + <BlockRenderer 319 + key={i} 320 + block={b.block} 321 + did={p.did} 322 + assetsBaseUrl={p.assetsBaseUrl} 323 + /> 250 324 ))} 251 325 </Section> 252 326 <Section className="pt-4"> ··· 289 363 290 364 const BlockRenderer = ({ 291 365 block, 366 + did, 292 367 assetsBaseUrl, 293 368 }: { 294 - block: EmailBlock; 369 + block: PubLeafletPagesLinearDocument.Block["block"]; 370 + did: string; 295 371 assetsBaseUrl: string; 296 372 }) => { 297 - switch (block.type) { 298 - case "text": 299 - return <Text>{block.plaintext || " "}</Text>; 300 - case "heading": 301 - return ( 302 - <Heading as={`h${block.level}` as "h1" | "h2" | "h3"}> 303 - {block.plaintext} 304 - </Heading> 305 - ); 306 - case "blockquote": 307 - return ( 308 - <Row className={blockPadding}> 309 - <Column className="!my-0 w-[2px] bg-border" /> 310 - <Column className="w-2" /> 311 - <Column> 312 - <Text className="!my-0.5">{block.plaintext}</Text> 313 - </Column> 314 - </Row> 315 - ); 316 - case "code": 317 - return <CodeBlock code={block.code} language={block.language} />; 318 - case "image": 319 - // Deliberately no numeric `width`/`height` HTML attributes: Outlook 320 - // honors those over CSS `max-width`, so a 1200px natural-size photo 321 - // would blow out our 28rem container. `max-width: <natural>px` keeps 322 - // smaller images from being upscaled. 323 - return ( 324 - <Img 325 - src={block.src} 326 - alt={block.alt ?? ""} 327 - className={`${blockPadding} mx-auto`} 328 - style={{ 329 - display: "block", 330 - width: "100%", 331 - maxWidth: block.width ? `${block.width}px` : "100%", 332 - height: "auto", 333 - }} 334 - /> 335 - ); 336 - case "link": 337 - return ( 338 - <LinkBlock 339 - url={block.url} 340 - title={block.title} 341 - description={block.description} 342 - previewSrc={block.previewSrc} 343 - /> 344 - ); 345 - case "horizontal-rule": 346 - return <Hr className="border-border-light my-3" />; 347 - case "list": 348 - return <List items={block.items} style={block.style} />; 349 - case "unsupported": 350 - return <BlockNotSupported />; 373 + if (PubLeafletBlocksText.isMain(block)) { 374 + return <Text>{block.plaintext || " "}</Text>; 375 + } 376 + if (PubLeafletBlocksHeader.isMain(block)) { 377 + const raw = Math.floor(block.level ?? 1); 378 + const clamped = (raw < 1 ? 1 : raw > 3 ? 3 : raw) as 1 | 2 | 3; 379 + return <Heading as={`h${clamped}`}>{block.plaintext}</Heading>; 351 380 } 381 + if (PubLeafletBlocksBlockquote.isMain(block)) { 382 + return ( 383 + <Row className={blockPadding}> 384 + <Column className="!my-0 w-[2px] bg-border" /> 385 + <Column className="w-2" /> 386 + <Column> 387 + <Text className="!my-0.5">{block.plaintext}</Text> 388 + </Column> 389 + </Row> 390 + ); 391 + } 392 + if (PubLeafletBlocksCode.isMain(block)) { 393 + return <CodeBlock code={block.plaintext} language={block.language} />; 394 + } 395 + if (PubLeafletBlocksImage.isMain(block)) { 396 + const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl); 397 + // Deliberately no numeric `width`/`height` HTML attributes: Outlook 398 + // honors those over CSS `max-width`, so a 1200px natural-size photo 399 + // would blow out our 28rem container. `max-width: <natural>px` keeps 400 + // smaller images from being upscaled. 401 + const naturalWidth = block.aspectRatio?.width; 402 + return ( 403 + <Img 404 + src={src} 405 + alt={block.alt ?? ""} 406 + className={`${blockPadding} mx-auto`} 407 + style={{ 408 + display: "block", 409 + width: "100%", 410 + maxWidth: naturalWidth ? `${naturalWidth}px` : "100%", 411 + height: "auto", 412 + }} 413 + /> 414 + ); 415 + } 416 + if (PubLeafletBlocksWebsite.isMain(block)) { 417 + const previewSrc = block.previewImage 418 + ? blobRefToSrc(block.previewImage.ref, did, assetsBaseUrl) 419 + : undefined; 420 + return ( 421 + <LinkBlock 422 + url={block.src} 423 + title={block.title} 424 + description={block.description} 425 + previewSrc={previewSrc} 426 + /> 427 + ); 428 + } 429 + if (PubLeafletBlocksHorizontalRule.isMain(block)) { 430 + return <Hr className="border-border-light my-3" />; 431 + } 432 + if (PubLeafletBlocksUnorderedList.isMain(block)) { 433 + return ( 434 + <List items={block.children} style="unordered" did={did} assetsBaseUrl={assetsBaseUrl} /> 435 + ); 436 + } 437 + if (PubLeafletBlocksOrderedList.isMain(block)) { 438 + return ( 439 + <List items={block.children} style="ordered" did={did} assetsBaseUrl={assetsBaseUrl} /> 440 + ); 441 + } 442 + return <BlockNotSupported />; 352 443 }; 353 444 354 445 export const LeafletWatermark = ({ ··· 412 503 ); 413 504 }; 414 505 506 + type ListItem = 507 + | PubLeafletBlocksUnorderedList.ListItem 508 + | PubLeafletBlocksOrderedList.ListItem; 509 + 510 + const listItemPlaintext = (item: ListItem): string => { 511 + const content = item.content; 512 + if (PubLeafletBlocksText.isMain(content)) return content.plaintext; 513 + if (PubLeafletBlocksHeader.isMain(content)) return content.plaintext; 514 + return ""; 515 + }; 516 + 415 517 export const List = ({ 416 518 items, 417 519 style, 520 + did, 521 + assetsBaseUrl, 418 522 }: { 419 - items: EmailListItem[]; 523 + items: ListItem[]; 420 524 style: "ordered" | "unordered"; 525 + did: string; 526 + assetsBaseUrl: string; 421 527 }) => { 422 528 const listClass = `my-0 !pl-6`; 423 529 const listItemClass = `${headingPadding} !ml-2`; ··· 425 531 return ( 426 532 <Section className={`${blockPadding} !-mt-1`}> 427 533 <Tag className={listClass}> 428 - {items.map((item, i) => ( 429 - <React.Fragment key={i}> 430 - <li className={listItemClass}> 431 - {typeof item.checked === "boolean" 432 - ? `${item.checked ? "☑ " : "☐ "}${item.plaintext}` 433 - : item.plaintext} 434 - </li> 435 - {item.children?.map((child, j) => ( 436 - <BlockRenderer key={j} block={child} assetsBaseUrl="" /> 437 - ))} 438 - </React.Fragment> 439 - ))} 534 + {items.map((item, i) => { 535 + const plaintext = listItemPlaintext(item); 536 + const nestedUnordered = 537 + item.children && style === "unordered" 538 + ? (item.children as PubLeafletBlocksUnorderedList.ListItem[]) 539 + : (item as PubLeafletBlocksOrderedList.ListItem) 540 + .unorderedListChildren?.children; 541 + const nestedOrdered = 542 + item.children && style === "ordered" 543 + ? (item.children as PubLeafletBlocksOrderedList.ListItem[]) 544 + : (item as PubLeafletBlocksUnorderedList.ListItem) 545 + .orderedListChildren?.children; 546 + return ( 547 + <React.Fragment key={i}> 548 + <li className={listItemClass}> 549 + {typeof item.checked === "boolean" 550 + ? `${item.checked ? "☑ " : "☐ "}${plaintext}` 551 + : plaintext} 552 + </li> 553 + {nestedUnordered && nestedUnordered.length > 0 ? ( 554 + <List 555 + items={nestedUnordered} 556 + style="unordered" 557 + did={did} 558 + assetsBaseUrl={assetsBaseUrl} 559 + /> 560 + ) : null} 561 + {nestedOrdered && nestedOrdered.length > 0 ? ( 562 + <List 563 + items={nestedOrdered} 564 + style="ordered" 565 + did={did} 566 + assetsBaseUrl={assetsBaseUrl} 567 + /> 568 + ) : null} 569 + </React.Fragment> 570 + ); 571 + })} 440 572 </Tag> 441 573 </Section> 442 574 );
+16 -2
src/utils/blobRefToSrc.ts
··· 1 1 import { BlobRef } from "@atproto/lexicon"; 2 2 3 - export const blobRefToSrc = (b: BlobRef["ref"], did: string) => 4 - `/api/atproto_images?did=${did}&cid=${(b as unknown as { $link: string })["$link"]}`; 3 + // `baseUrl` produces an absolute URL (needed for outbound email where the proxy 4 + // path can't resolve against the document origin). 5 + // 6 + // If `$link` is already an http(s) URL we return it untouched — the email-preview 7 + // path stuffs a direct draft-image URL into `$link` since drafts aren't uploaded 8 + // to a PDS yet. 9 + export const blobRefToSrc = ( 10 + b: BlobRef["ref"], 11 + did: string, 12 + baseUrl?: string, 13 + ) => { 14 + const link = (b as unknown as { $link: string })["$link"]; 15 + if (link.startsWith("http://") || link.startsWith("https://")) return link; 16 + const prefix = baseUrl ? baseUrl.replace(/\/$/, "") : ""; 17 + return `${prefix}/api/atproto_images?did=${did}&cid=${link}`; 18 + };
+678
src/utils/factsToPagesRecord.ts
··· 1 + import * as Y from "yjs"; 2 + import * as base64 from "base64-js"; 3 + import { $Typed, UnicodeString } from "@atproto/api"; 4 + import { BlobRef } from "@atproto/lexicon"; 5 + 6 + import { 7 + PubLeafletBlocksBlockquote, 8 + PubLeafletBlocksBskyPost, 9 + PubLeafletBlocksButton, 10 + PubLeafletBlocksCode, 11 + PubLeafletBlocksHeader, 12 + PubLeafletBlocksHorizontalRule, 13 + PubLeafletBlocksIframe, 14 + PubLeafletBlocksImage, 15 + PubLeafletBlocksMath, 16 + PubLeafletBlocksOrderedList, 17 + PubLeafletBlocksPage, 18 + PubLeafletBlocksPoll, 19 + PubLeafletBlocksText, 20 + PubLeafletBlocksUnorderedList, 21 + PubLeafletBlocksWebsite, 22 + PubLeafletPagesCanvas, 23 + PubLeafletPagesLinearDocument, 24 + PubLeafletPollDefinition, 25 + PubLeafletRichtextFacet, 26 + } from "lexicons/api"; 27 + import { ids } from "lexicons/api/lexicons"; 28 + 29 + import { Block } from "components/Blocks/Block"; 30 + import type { Fact } from "src/replicache"; 31 + import type { Attribute } from "src/replicache/attributes"; 32 + import { scanIndexLocal } from "src/replicache/utils"; 33 + import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 34 + import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 35 + import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString"; 36 + 37 + type ExcludeString<T> = T extends string 38 + ? string extends T 39 + ? never 40 + : T 41 + : T; 42 + 43 + export type ProcessBlocksToPagesHooks = { 44 + /** 45 + * Resolve an image URL to a BlobRef. For publish, this uploads the blob to 46 + * the user's PDS. For preview, this synthesizes a BlobRef that carries the 47 + * URL through to the email template (no PDS side-effects). 48 + * Returning undefined causes the image block to be dropped. 49 + */ 50 + uploadImage: (src: string) => Promise<BlobRef | undefined>; 51 + 52 + /** 53 + * Persist a poll definition record and return its at-uri / CID. For 54 + * publish, this writes to the PDS and (optionally) Supabase. For preview, 55 + * pass null to skip poll blocks entirely — they'll be rendered as 56 + * "unsupported" in the email. 57 + */ 58 + uploadPoll: 59 + | (( 60 + entityId: string, 61 + record: PubLeafletPollDefinition.Record, 62 + ) => Promise<{ uri: string; cid: string } | undefined>) 63 + | null; 64 + }; 65 + 66 + export type ProcessBlocksToPagesResult = { 67 + pages: { 68 + id: string; 69 + blocks: 70 + | PubLeafletPagesLinearDocument.Block[] 71 + | PubLeafletPagesCanvas.Block[]; 72 + type: "doc" | "canvas"; 73 + }[]; 74 + }; 75 + 76 + export async function processBlocksToPages(opts: { 77 + facts: Fact<Attribute>[]; 78 + root_entity: string; 79 + hooks: ProcessBlocksToPagesHooks; 80 + }): Promise<ProcessBlocksToPagesResult> { 81 + const { facts, root_entity, hooks } = opts; 82 + const scan = scanIndexLocal(facts); 83 + const pages: ProcessBlocksToPagesResult["pages"] = []; 84 + 85 + const firstEntity = scan.eav(root_entity, "root/page")?.[0]; 86 + if (!firstEntity) throw new Error("No root page"); 87 + 88 + const [pageType] = scan.eav(firstEntity.data.value, "page/type"); 89 + 90 + if (pageType?.data.value === "canvas") { 91 + const canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value); 92 + pages.unshift({ 93 + id: firstEntity.data.value, 94 + blocks: canvasBlocks, 95 + type: "canvas", 96 + }); 97 + } else { 98 + const blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 99 + const b = await blocksToRecord(blocks); 100 + pages.unshift({ 101 + id: firstEntity.data.value, 102 + blocks: b, 103 + type: "doc", 104 + }); 105 + } 106 + 107 + return { pages }; 108 + 109 + async function blocksToRecord( 110 + blocks: Block[], 111 + ): Promise<PubLeafletPagesLinearDocument.Block[]> { 112 + const parsedBlocks = parseBlocksToList(blocks); 113 + return ( 114 + await Promise.all( 115 + parsedBlocks.map(async (blockOrList) => { 116 + if (blockOrList.type === "block") { 117 + const alignmentValue = scan.eav( 118 + blockOrList.block.value, 119 + "block/text-alignment", 120 + )[0]?.data.value; 121 + const alignment: ExcludeString< 122 + PubLeafletPagesLinearDocument.Block["alignment"] 123 + > = 124 + alignmentValue === "center" 125 + ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 126 + : alignmentValue === "right" 127 + ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 128 + : alignmentValue === "justify" 129 + ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" 130 + : alignmentValue === "left" 131 + ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" 132 + : undefined; 133 + const b = await blockToRecord(blockOrList.block); 134 + if (!b) return []; 135 + const block: PubLeafletPagesLinearDocument.Block = { 136 + $type: "pub.leaflet.pages.linearDocument#block", 137 + block: b, 138 + }; 139 + if (alignment) block.alignment = alignment; 140 + return [block]; 141 + } else { 142 + const runs = splitListByStyle(blockOrList.children); 143 + const out = await Promise.all( 144 + runs.map(async (run) => { 145 + if (run.style === "ordered") { 146 + const block: PubLeafletPagesLinearDocument.Block = { 147 + $type: "pub.leaflet.pages.linearDocument#block", 148 + block: { 149 + $type: "pub.leaflet.blocks.orderedList", 150 + startIndex: 151 + run.children[0].block.listData?.listStart || 1, 152 + children: await orderedChildrenToRecord(run.children), 153 + }, 154 + }; 155 + return block; 156 + } else { 157 + const block: PubLeafletPagesLinearDocument.Block = { 158 + $type: "pub.leaflet.pages.linearDocument#block", 159 + block: { 160 + $type: "pub.leaflet.blocks.unorderedList", 161 + children: await unorderedChildrenToRecord(run.children), 162 + }, 163 + }; 164 + return block; 165 + } 166 + }), 167 + ); 168 + return out; 169 + } 170 + }), 171 + ) 172 + ).flat(); 173 + } 174 + 175 + function splitListByStyle(children: List[]) { 176 + const runs: { style: "ordered" | "unordered"; children: List[] }[] = []; 177 + for (const child of children) { 178 + const style: "ordered" | "unordered" = 179 + child.block.listData?.listStyle === "ordered" 180 + ? "ordered" 181 + : "unordered"; 182 + const last = runs[runs.length - 1]; 183 + if (last && last.style === style) { 184 + last.children.push(child); 185 + } else { 186 + runs.push({ style, children: [child] }); 187 + } 188 + } 189 + return runs; 190 + } 191 + 192 + async function unorderedChildrenToRecord( 193 + children: List[], 194 + ): Promise<PubLeafletBlocksUnorderedList.ListItem[]> { 195 + return ( 196 + await Promise.all( 197 + children.map(async (child) => { 198 + const content = await blockToRecord(child.block); 199 + if (!content) return []; 200 + const record: PubLeafletBlocksUnorderedList.ListItem = { 201 + $type: "pub.leaflet.blocks.unorderedList#listItem", 202 + content, 203 + ...(child.block.listData?.checklist && { 204 + checked: child.block.listData.checked ?? false, 205 + }), 206 + }; 207 + const sameStyle = child.children.filter( 208 + (c) => c.block.listData?.listStyle !== "ordered", 209 + ); 210 + const diffStyle = child.children.filter( 211 + (c) => c.block.listData?.listStyle === "ordered", 212 + ); 213 + if (sameStyle.length > 0) { 214 + record.children = await unorderedChildrenToRecord(sameStyle); 215 + } 216 + if (diffStyle.length > 0) { 217 + record.orderedListChildren = { 218 + $type: "pub.leaflet.blocks.orderedList", 219 + children: await orderedChildrenToRecord(diffStyle), 220 + }; 221 + } 222 + return record; 223 + }), 224 + ) 225 + ).flat(); 226 + } 227 + 228 + async function orderedChildrenToRecord( 229 + children: List[], 230 + ): Promise<PubLeafletBlocksOrderedList.ListItem[]> { 231 + return ( 232 + await Promise.all( 233 + children.map(async (child) => { 234 + const content = await blockToRecord(child.block); 235 + if (!content) return []; 236 + const record: PubLeafletBlocksOrderedList.ListItem = { 237 + $type: "pub.leaflet.blocks.orderedList#listItem", 238 + content, 239 + ...(child.block.listData?.checklist && { 240 + checked: child.block.listData.checked ?? false, 241 + }), 242 + }; 243 + const sameStyle = child.children.filter( 244 + (c) => c.block.listData?.listStyle === "ordered", 245 + ); 246 + const diffStyle = child.children.filter( 247 + (c) => c.block.listData?.listStyle !== "ordered", 248 + ); 249 + if (sameStyle.length > 0) { 250 + record.children = await orderedChildrenToRecord(sameStyle); 251 + } 252 + if (diffStyle.length > 0) { 253 + record.unorderedListChildren = { 254 + $type: "pub.leaflet.blocks.unorderedList", 255 + children: await unorderedChildrenToRecord(diffStyle), 256 + }; 257 + } 258 + return record; 259 + }), 260 + ) 261 + ).flat(); 262 + } 263 + 264 + async function blockToRecord(b: Block) { 265 + const footnoteContentResolver = (footnoteEntityID: string) => { 266 + const [content] = scan.eav(footnoteEntityID, "block/text"); 267 + if (!content) 268 + return { 269 + plaintext: "", 270 + facets: [] as PubLeafletRichtextFacet.Main[], 271 + }; 272 + const doc = new Y.Doc(); 273 + const update = base64.toByteArray(content.data.value); 274 + Y.applyUpdate(doc, update); 275 + const nodes = doc.getXmlElement("prosemirror").toArray(); 276 + const plaintext = YJSFragmentToString(nodes[0]); 277 + const { facets } = YJSFragmentToFacets(nodes[0]); 278 + return { plaintext, facets }; 279 + }; 280 + const getBlockContent = (b: string) => { 281 + const [content] = scan.eav(b, "block/text"); 282 + if (!content) 283 + return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 284 + const doc = new Y.Doc(); 285 + const update = base64.toByteArray(content.data.value); 286 + Y.applyUpdate(doc, update); 287 + const nodes = doc.getXmlElement("prosemirror").toArray(); 288 + const stringValue = YJSFragmentToString(nodes[0]); 289 + const { facets } = YJSFragmentToFacets( 290 + nodes[0], 291 + 0, 292 + footnoteContentResolver, 293 + ); 294 + return [stringValue, facets] as const; 295 + }; 296 + if (b.type === "card") { 297 + const [page] = scan.eav(b.value, "block/card"); 298 + if (!page) return; 299 + const [pageType] = scan.eav(page.data.value, "page/type"); 300 + 301 + if (pageType?.data.value === "canvas") { 302 + const canvasBlocks = await canvasBlocksToRecord(page.data.value); 303 + pages.push({ 304 + id: page.data.value, 305 + blocks: canvasBlocks, 306 + type: "canvas", 307 + }); 308 + } else { 309 + const blocks = getBlocksWithTypeLocal(facts, page.data.value); 310 + pages.push({ 311 + id: page.data.value, 312 + blocks: await blocksToRecord(blocks), 313 + type: "doc", 314 + }); 315 + } 316 + 317 + const block: $Typed<PubLeafletBlocksPage.Main> = { 318 + $type: "pub.leaflet.blocks.page", 319 + id: page.data.value, 320 + }; 321 + return block; 322 + } 323 + 324 + if (b.type === "bluesky-post") { 325 + const [post] = scan.eav(b.value, "block/bluesky-post"); 326 + if (!post || !post.data.value.post) return; 327 + const [hostFact] = scan.eav(b.value, "bluesky-post/host"); 328 + const block: $Typed<PubLeafletBlocksBskyPost.Main> = { 329 + $type: ids.PubLeafletBlocksBskyPost, 330 + postRef: { 331 + uri: post.data.value.post.uri, 332 + cid: post.data.value.post.cid, 333 + }, 334 + clientHost: hostFact?.data.value, 335 + }; 336 + return block; 337 + } 338 + if (b.type === "horizontal-rule") { 339 + const block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 340 + $type: ids.PubLeafletBlocksHorizontalRule, 341 + }; 342 + return block; 343 + } 344 + 345 + if (b.type === "heading") { 346 + const [headingLevel] = scan.eav(b.value, "block/heading-level"); 347 + 348 + const [stringValue, facets] = getBlockContent(b.value); 349 + const block: $Typed<PubLeafletBlocksHeader.Main> = { 350 + $type: "pub.leaflet.blocks.header", 351 + level: Math.floor(headingLevel?.data.value || 1), 352 + plaintext: stringValue, 353 + ...(facets.length > 0 && { facets }), 354 + }; 355 + return block; 356 + } 357 + 358 + if (b.type === "blockquote") { 359 + const [stringValue, facets] = getBlockContent(b.value); 360 + const block: $Typed<PubLeafletBlocksBlockquote.Main> = { 361 + $type: ids.PubLeafletBlocksBlockquote, 362 + plaintext: stringValue, 363 + ...(facets.length > 0 && { facets }), 364 + }; 365 + return block; 366 + } 367 + 368 + if (b.type == "text") { 369 + const [stringValue, facets] = getBlockContent(b.value); 370 + const [textSize] = scan.eav(b.value, "block/text-size"); 371 + const block: $Typed<PubLeafletBlocksText.Main> = { 372 + $type: ids.PubLeafletBlocksText, 373 + plaintext: stringValue, 374 + ...(facets.length > 0 && { facets }), 375 + ...(textSize && { textSize: textSize.data.value }), 376 + }; 377 + return block; 378 + } 379 + if (b.type === "embed") { 380 + const [url] = scan.eav(b.value, "embed/url"); 381 + const [height] = scan.eav(b.value, "embed/height"); 382 + const [aspectRatio] = scan.eav(b.value, "embed/aspect-ratio"); 383 + if (!url) return; 384 + const block: $Typed<PubLeafletBlocksIframe.Main> = { 385 + $type: "pub.leaflet.blocks.iframe", 386 + url: url.data.value, 387 + height: Math.floor(height?.data.value || 600), 388 + }; 389 + if (aspectRatio) { 390 + const [w, h] = aspectRatio.data.value.split("/").map(Number); 391 + if (w && h) { 392 + block.aspectRatio = { width: w, height: h }; 393 + } 394 + } 395 + return block; 396 + } 397 + if (b.type == "image") { 398 + const [image] = scan.eav(b.value, "block/image"); 399 + if (!image) return; 400 + const [altText] = scan.eav(b.value, "image/alt"); 401 + const [fullBleed] = scan.eav(b.value, "image/full-bleed"); 402 + const blobref = await hooks.uploadImage(image.data.src); 403 + if (!blobref) return; 404 + const block: $Typed<PubLeafletBlocksImage.Main> = { 405 + $type: "pub.leaflet.blocks.image", 406 + image: blobref, 407 + aspectRatio: { 408 + height: Math.floor(image.data.height), 409 + width: Math.floor(image.data.width), 410 + }, 411 + alt: altText ? altText.data.value : undefined, 412 + fullBleed: fullBleed?.data.value || undefined, 413 + }; 414 + return block; 415 + } 416 + if (b.type === "link") { 417 + const [previewImage] = scan.eav(b.value, "link/preview"); 418 + const [description] = scan.eav(b.value, "link/description"); 419 + const [src] = scan.eav(b.value, "link/url"); 420 + if (!src) return; 421 + const blobref = previewImage 422 + ? await hooks.uploadImage(previewImage.data.src) 423 + : undefined; 424 + const [title] = scan.eav(b.value, "link/title"); 425 + const block: $Typed<PubLeafletBlocksWebsite.Main> = { 426 + $type: "pub.leaflet.blocks.website", 427 + previewImage: blobref, 428 + src: src.data.value, 429 + description: description?.data.value, 430 + title: title?.data.value, 431 + }; 432 + return block; 433 + } 434 + if (b.type === "code") { 435 + const [language] = scan.eav(b.value, "block/code-language"); 436 + const [code] = scan.eav(b.value, "block/code"); 437 + const [theme] = scan.eav(root_entity, "theme/code-theme"); 438 + const block: $Typed<PubLeafletBlocksCode.Main> = { 439 + $type: "pub.leaflet.blocks.code", 440 + language: language?.data.value, 441 + plaintext: code?.data.value || "", 442 + syntaxHighlightingTheme: theme?.data.value, 443 + }; 444 + return block; 445 + } 446 + if (b.type === "math") { 447 + const [math] = scan.eav(b.value, "block/math"); 448 + const block: $Typed<PubLeafletBlocksMath.Main> = { 449 + $type: "pub.leaflet.blocks.math", 450 + tex: math?.data.value || "", 451 + }; 452 + return block; 453 + } 454 + if (b.type === "poll") { 455 + if (!hooks.uploadPoll) return; 456 + 457 + const pollOptions = scan.eav(b.value, "poll/options"); 458 + const options: PubLeafletPollDefinition.Option[] = pollOptions.map( 459 + (opt) => { 460 + const optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 461 + return { 462 + $type: "pub.leaflet.poll.definition#option", 463 + text: optionName?.data.value || "", 464 + }; 465 + }, 466 + ); 467 + 468 + const pollRecord: PubLeafletPollDefinition.Record = { 469 + $type: "pub.leaflet.poll.definition", 470 + name: "Poll", 471 + options, 472 + }; 473 + 474 + const result = await hooks.uploadPoll(b.value, pollRecord); 475 + if (!result) return; 476 + 477 + const block: $Typed<PubLeafletBlocksPoll.Main> = { 478 + $type: "pub.leaflet.blocks.poll", 479 + pollRef: { 480 + uri: result.uri, 481 + cid: result.cid, 482 + }, 483 + }; 484 + return block; 485 + } 486 + if (b.type === "button") { 487 + const [text] = scan.eav(b.value, "button/text"); 488 + const [url] = scan.eav(b.value, "button/url"); 489 + if (!text || !url) return; 490 + const block: $Typed<PubLeafletBlocksButton.Main> = { 491 + $type: "pub.leaflet.blocks.button", 492 + text: text.data.value, 493 + url: url.data.value, 494 + }; 495 + return block; 496 + } 497 + return; 498 + } 499 + 500 + async function canvasBlocksToRecord( 501 + pageID: string, 502 + ): Promise<PubLeafletPagesCanvas.Block[]> { 503 + const canvasBlocks = scan.eav(pageID, "canvas/block"); 504 + return ( 505 + await Promise.all( 506 + canvasBlocks.map(async (canvasBlock) => { 507 + const blockEntity = canvasBlock.data.value; 508 + const position = canvasBlock.data.position; 509 + 510 + const blockType = scan.eav(blockEntity, "block/type")?.[0]; 511 + if (!blockType) return null; 512 + 513 + const block: Block = { 514 + type: blockType.data.value, 515 + value: blockEntity, 516 + parent: pageID, 517 + position: "", 518 + factID: canvasBlock.id, 519 + }; 520 + 521 + const content = await blockToRecord(block); 522 + if (!content) return null; 523 + 524 + const width = 525 + scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 526 + const rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] 527 + ?.data.value; 528 + 529 + const canvasBlockRecord: PubLeafletPagesCanvas.Block = { 530 + $type: "pub.leaflet.pages.canvas#block", 531 + block: content, 532 + x: Math.floor(position.x), 533 + y: Math.floor(position.y), 534 + width: Math.floor(width), 535 + ...(rotation !== undefined && { rotation: Math.floor(rotation) }), 536 + }; 537 + 538 + return canvasBlockRecord; 539 + }), 540 + ) 541 + ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 542 + } 543 + } 544 + 545 + export function YJSFragmentToFacets( 546 + node: Y.XmlElement | Y.XmlText | Y.XmlHook, 547 + byteOffset: number = 0, 548 + footnoteContentResolver?: (footnoteEntityID: string) => { 549 + plaintext: string; 550 + facets: PubLeafletRichtextFacet.Main[]; 551 + }, 552 + ): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } { 553 + if (node.constructor === Y.XmlElement) { 554 + if (node.nodeName === "footnote") { 555 + const footnoteEntityID = node.getAttribute("footnoteEntityID") || ""; 556 + const placeholder = "*"; 557 + const unicodestring = new UnicodeString(placeholder); 558 + const footnoteContent = footnoteContentResolver?.(footnoteEntityID); 559 + const facet: PubLeafletRichtextFacet.Main = { 560 + index: { 561 + byteStart: byteOffset, 562 + byteEnd: byteOffset + unicodestring.length, 563 + }, 564 + features: [ 565 + { 566 + $type: "pub.leaflet.richtext.facet#footnote", 567 + footnoteId: footnoteEntityID, 568 + contentPlaintext: footnoteContent?.plaintext || "", 569 + ...(footnoteContent?.facets?.length 570 + ? { contentFacets: footnoteContent.facets } 571 + : {}), 572 + }, 573 + ], 574 + }; 575 + return { facets: [facet], byteLength: unicodestring.length }; 576 + } 577 + 578 + if (node.nodeName === "didMention") { 579 + const text = node.getAttribute("text") || ""; 580 + const unicodestring = new UnicodeString(text); 581 + const facet: PubLeafletRichtextFacet.Main = { 582 + index: { 583 + byteStart: byteOffset, 584 + byteEnd: byteOffset + unicodestring.length, 585 + }, 586 + features: [ 587 + { 588 + $type: "pub.leaflet.richtext.facet#didMention", 589 + did: node.getAttribute("did"), 590 + }, 591 + ], 592 + }; 593 + return { facets: [facet], byteLength: unicodestring.length }; 594 + } 595 + 596 + if (node.nodeName === "atMention") { 597 + const text = node.getAttribute("text") || ""; 598 + const unicodestring = new UnicodeString(text); 599 + const facet: PubLeafletRichtextFacet.Main = { 600 + index: { 601 + byteStart: byteOffset, 602 + byteEnd: byteOffset + unicodestring.length, 603 + }, 604 + features: [ 605 + { 606 + $type: "pub.leaflet.richtext.facet#atMention", 607 + atURI: node.getAttribute("atURI"), 608 + ...(node.getAttribute("href") 609 + ? { href: node.getAttribute("href") } 610 + : {}), 611 + }, 612 + ], 613 + }; 614 + return { facets: [facet], byteLength: unicodestring.length }; 615 + } 616 + 617 + if (node.nodeName === "hard_break") { 618 + const unicodestring = new UnicodeString("\n"); 619 + return { facets: [], byteLength: unicodestring.length }; 620 + } 621 + 622 + const allFacets: PubLeafletRichtextFacet.Main[] = []; 623 + let currentOffset = byteOffset; 624 + for (const child of node.toArray()) { 625 + const result = YJSFragmentToFacets( 626 + child, 627 + currentOffset, 628 + footnoteContentResolver, 629 + ); 630 + allFacets.push(...result.facets); 631 + currentOffset += result.byteLength; 632 + } 633 + return { facets: allFacets, byteLength: currentOffset - byteOffset }; 634 + } 635 + 636 + if (node.constructor === Y.XmlText) { 637 + const facets: PubLeafletRichtextFacet.Main[] = []; 638 + const delta = node.toDelta() as Delta[]; 639 + let byteStart = byteOffset; 640 + let totalLength = 0; 641 + for (const d of delta) { 642 + const unicodestring = new UnicodeString(d.insert); 643 + const facet: PubLeafletRichtextFacet.Main = { 644 + index: { 645 + byteStart, 646 + byteEnd: byteStart + unicodestring.length, 647 + }, 648 + features: [], 649 + }; 650 + 651 + if (d.attributes?.strikethrough) 652 + facet.features.push({ 653 + $type: "pub.leaflet.richtext.facet#strikethrough", 654 + }); 655 + 656 + if (d.attributes?.code) 657 + facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); 658 + if (d.attributes?.highlight) 659 + facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); 660 + if (d.attributes?.underline) 661 + facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); 662 + if (d.attributes?.strong) 663 + facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); 664 + if (d.attributes?.em) 665 + facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); 666 + if (d.attributes?.link) 667 + facet.features.push({ 668 + $type: "pub.leaflet.richtext.facet#link", 669 + uri: d.attributes.link.href, 670 + }); 671 + if (facet.features.length > 0) facets.push(facet); 672 + byteStart += unicodestring.length; 673 + totalLength += unicodestring.length; 674 + } 675 + return { facets, byteLength: totalLength }; 676 + } 677 + return { facets: [], byteLength: 0 }; 678 + }
-249
src/utils/postToEmailBlocks.ts
··· 1 - import * as Y from "yjs"; 2 - import * as base64 from "base64-js"; 3 - import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 4 - import type { Fact } from "src/replicache"; 5 - import { scanIndexLocal } from "src/replicache/utils"; 6 - import { getBlocksWithTypeLocal } from "src/replicache/getBlocks"; 7 - import type { Block } from "components/Blocks/Block"; 8 - 9 - export type EmailBlock = 10 - | { 11 - type: "text"; 12 - plaintext: string; 13 - textSize?: "small" | "default" | "large"; 14 - } 15 - | { type: "heading"; level: 1 | 2 | 3; plaintext: string } 16 - | { type: "blockquote"; plaintext: string } 17 - | { type: "code"; code: string; language?: string } 18 - | { type: "image"; src: string; alt?: string; width?: number; height?: number } 19 - | { 20 - type: "link"; 21 - url: string; 22 - title?: string; 23 - description?: string; 24 - previewSrc?: string; 25 - } 26 - | { type: "horizontal-rule" } 27 - | { type: "list"; style: "ordered" | "unordered"; items: EmailListItem[] } 28 - | { type: "unsupported"; kind: string }; 29 - 30 - export type EmailListItem = { 31 - plaintext: string; 32 - checked?: boolean; 33 - children?: EmailBlock[]; 34 - }; 35 - 36 - function decodeYjsText(base64Value: string): string { 37 - const doc = new Y.Doc(); 38 - Y.applyUpdate(doc, base64.toByteArray(base64Value)); 39 - const [node] = doc.getXmlElement("prosemirror").toArray(); 40 - if (!node) return ""; 41 - return YJSFragmentToString(node); 42 - } 43 - 44 - function getPlaintext( 45 - scan: ReturnType<typeof scanIndexLocal>, 46 - entity: string, 47 - ): string { 48 - const [content] = scan.eav(entity, "block/text"); 49 - if (!content) return ""; 50 - return decodeYjsText(content.data.value); 51 - } 52 - 53 - function renderBlock( 54 - scan: ReturnType<typeof scanIndexLocal>, 55 - block: Block, 56 - ): EmailBlock | null { 57 - switch (block.type) { 58 - case "text": { 59 - const [textSize] = scan.eav(block.value, "block/text-size"); 60 - const out: EmailBlock = { 61 - type: "text", 62 - plaintext: getPlaintext(scan, block.value), 63 - }; 64 - if (textSize) out.textSize = textSize.data.value; 65 - return out; 66 - } 67 - case "heading": { 68 - const [level] = scan.eav(block.value, "block/heading-level"); 69 - const rawLevel = Math.floor(level?.data.value || 1); 70 - const clamped = (rawLevel < 1 ? 1 : rawLevel > 3 ? 3 : rawLevel) as 71 - | 1 72 - | 2 73 - | 3; 74 - return { 75 - type: "heading", 76 - level: clamped, 77 - plaintext: getPlaintext(scan, block.value), 78 - }; 79 - } 80 - case "blockquote": 81 - return { 82 - type: "blockquote", 83 - plaintext: getPlaintext(scan, block.value), 84 - }; 85 - case "code": { 86 - const [code] = scan.eav(block.value, "block/code"); 87 - const [language] = scan.eav(block.value, "block/code-language"); 88 - return { 89 - type: "code", 90 - code: code?.data.value || "", 91 - language: language?.data.value, 92 - }; 93 - } 94 - case "image": { 95 - const [image] = scan.eav(block.value, "block/image"); 96 - if (!image) return null; 97 - const [alt] = scan.eav(block.value, "image/alt"); 98 - return { 99 - type: "image", 100 - src: image.data.src, 101 - alt: alt?.data.value, 102 - width: image.data.width, 103 - height: image.data.height, 104 - }; 105 - } 106 - case "link": { 107 - const [url] = scan.eav(block.value, "link/url"); 108 - if (!url) return null; 109 - const [title] = scan.eav(block.value, "link/title"); 110 - const [description] = scan.eav(block.value, "link/description"); 111 - const [preview] = scan.eav(block.value, "link/preview"); 112 - return { 113 - type: "link", 114 - url: url.data.value, 115 - title: title?.data.value, 116 - description: description?.data.value, 117 - previewSrc: preview?.data.src, 118 - }; 119 - } 120 - case "horizontal-rule": 121 - return { type: "horizontal-rule" }; 122 - default: 123 - return { type: "unsupported", kind: block.type }; 124 - } 125 - } 126 - 127 - type ListNode = { 128 - block: Block; 129 - children: ListNode[]; 130 - }; 131 - 132 - function buildListTree(blocks: Block[]): ListNode[] { 133 - // Build a nested tree from the flattened list output of getBlocksWithTypeLocal, 134 - // using listData.depth to re-nest. 135 - const roots: ListNode[] = []; 136 - const stack: ListNode[] = []; 137 - for (const b of blocks) { 138 - const depth = b.listData?.depth ?? 1; 139 - const node: ListNode = { block: b, children: [] }; 140 - while (stack.length && (stack[stack.length - 1].block.listData?.depth ?? 1) >= depth) { 141 - stack.pop(); 142 - } 143 - if (stack.length === 0) roots.push(node); 144 - else stack[stack.length - 1].children.push(node); 145 - stack.push(node); 146 - } 147 - return roots; 148 - } 149 - 150 - function nodeToListItem( 151 - scan: ReturnType<typeof scanIndexLocal>, 152 - node: ListNode, 153 - ): EmailListItem { 154 - const item: EmailListItem = { 155 - plaintext: getPlaintext(scan, node.block.value), 156 - }; 157 - if (node.block.listData?.checklist) { 158 - item.checked = node.block.listData.checked ?? false; 159 - } 160 - if (node.children.length > 0) { 161 - // Split children by list style into runs, each a nested list block 162 - const childBlocks: EmailBlock[] = []; 163 - let run: ListNode[] = []; 164 - let runStyle: "ordered" | "unordered" | null = null; 165 - const flushRun = () => { 166 - if (run.length === 0 || !runStyle) return; 167 - childBlocks.push({ 168 - type: "list", 169 - style: runStyle, 170 - items: run.map((n) => nodeToListItem(scan, n)), 171 - }); 172 - run = []; 173 - runStyle = null; 174 - }; 175 - for (const child of node.children) { 176 - const style = 177 - child.block.listData?.listStyle === "ordered" ? "ordered" : "unordered"; 178 - if (runStyle && runStyle !== style) flushRun(); 179 - runStyle = style; 180 - run.push(child); 181 - } 182 - flushRun(); 183 - item.children = childBlocks; 184 - } 185 - return item; 186 - } 187 - 188 - export function extractEmailBlocksFromFacts( 189 - facts: Fact<any>[], 190 - rootEntity: string, 191 - ): EmailBlock[] { 192 - const scan = scanIndexLocal(facts); 193 - const [firstPage] = scan.eav(rootEntity, "root/page"); 194 - if (!firstPage) return []; 195 - 196 - const [pageType] = scan.eav(firstPage.data.value, "page/type"); 197 - if (pageType?.data.value === "canvas") { 198 - // Canvas pages aren't sensibly mappable to a linear email; fall back. 199 - return [{ type: "unsupported", kind: "canvas-page" }]; 200 - } 201 - 202 - const rawBlocks = getBlocksWithTypeLocal(facts, firstPage.data.value); 203 - 204 - // Walk the flattened blocks. Group consecutive list blocks into list runs. 205 - const out: EmailBlock[] = []; 206 - let listBuf: Block[] = []; 207 - 208 - const flushList = () => { 209 - if (listBuf.length === 0) return; 210 - const tree = buildListTree(listBuf); 211 - // Split roots by list style into separate list blocks 212 - let run: ListNode[] = []; 213 - let runStyle: "ordered" | "unordered" | null = null; 214 - const flushRun = () => { 215 - if (run.length === 0 || !runStyle) return; 216 - out.push({ 217 - type: "list", 218 - style: runStyle, 219 - items: run.map((n) => nodeToListItem(scan, n)), 220 - }); 221 - run = []; 222 - runStyle = null; 223 - }; 224 - for (const rootNode of tree) { 225 - const style = 226 - rootNode.block.listData?.listStyle === "ordered" 227 - ? "ordered" 228 - : "unordered"; 229 - if (runStyle && runStyle !== style) flushRun(); 230 - runStyle = style; 231 - run.push(rootNode); 232 - } 233 - flushRun(); 234 - listBuf = []; 235 - }; 236 - 237 - for (const block of rawBlocks) { 238 - if (block.listData) { 239 - listBuf.push(block); 240 - continue; 241 - } 242 - flushList(); 243 - const rendered = renderBlock(scan, block); 244 - if (rendered) out.push(rendered); 245 - } 246 - flushList(); 247 - 248 - return out; 249 - }