Something went wrong. Try again.
leaflet fork for copy edit / bug fixes forked from leaflet.pub/leaflet
Something went wrong. Try again.
TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926import { Body, Column, Heading as ReactEmailHeading, Hr, Html, Img, Link, Text as ReactEmailText, Section, Row, CodeBlock as ReactEmailCodeBlock, dracula,} from "@react-email/components";import type { PrismLanguage } from "@react-email/code-block";import { UnicodeString, type AppBskyFeedDefs } from "@atproto/api";import React, { Fragment, type CSSProperties } from "react";import { PubLeafletBlocksBlockquote, PubLeafletBlocksBskyPost, PubLeafletBlocksButton, PubLeafletBlocksCode, PubLeafletBlocksHeader, PubLeafletBlocksHorizontalRule, PubLeafletBlocksImage, PubLeafletBlocksImageGallery, PubLeafletBlocksMembersOnlyDelimiter, PubLeafletBlocksOrderedList, PubLeafletBlocksStandardSitePost, PubLeafletBlocksStandardSitePublication, PubLeafletBlocksText, PubLeafletBlocksUnorderedList, PubLeafletBlocksWebsite, PubLeafletPagesCanvas, PubLeafletPagesLinearDocument, PubLeafletRichtextFacet,} from "lexicons/api";import { blobRefToSrc } from "src/utils/blobRefToSrc";import { matchBlock, type BlockHandlers } from "src/utils/blockDispatch";import { atUriToUrl, didToBlueskyUrl } from "src/utils/mentionUtils";import { normalizePublicationRecord } from "src/utils/normalizeRecords";import { emailPropsFromPublication } from "./fromPublication";import { bgcolorAttr, defaultEmailTheme, LeafletWatermark, MailHead, makeEmailIconUrl, makeStaticUrl, mixRgb, renderTextWithBreaks, resolveColors, type EmailTheme, type ResolvedColors,} from "./shared";import { BskyPostEmailBlock } from "./bskyPost";import { StandardSitePostEmailBlock, StandardSitePublicationEmailBlock,} from "./standardSiteBlocks";import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts";import type { StandardSitePublicationData } from "app/api/rpc/[command]/get_standard_site_publications";import { supabaseServerClient } from "supabase/serverClient";
/** * A document page in the shape both send paths already have on hand: the * broadcast's lexicon record pages and the preview's processBlocksToPages * output both carry an id plus a typed block list. */export type PostEmailPage = { id: string; type: "doc" | "canvas"; blocks: | PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[];};
type PostEmailProps = { publicationName: string; publicationUrl: string; postTitle: string; postDescription?: string; postUrl: string; authorName?: string; publishedAtLabel?: string; blocks: PubLeafletPagesLinearDocument.Block[]; /** * Used to build absolute URLs for `pub.leaflet.blocks.image` blob refs via * the /api/atproto_images proxy. Preview renders from drafts may encode a * direct URL in the blob ref's $link instead — we pass those through. */ did: string; /** * Every page of the document, so `pub.leaflet.blocks.page` blocks can show * the linked sub-page's opening lines. Blocks whose page is missing render * nothing, matching the published web page. */ pages?: PostEmailPage[]; /** * If omitted the template renders a "(preview)" footer in place of the * real unsubscribe link — used by the Phase 4 preview-send path. */ unsubscribeUrl?: string; manageUrl?: string; /** * Absolute URL used to resolve bundled /email-assets/* images. Postmark * fetches `<img src>` verbatim so these must be absolute in outgoing mail. */ assetsBaseUrl: string; theme?: EmailTheme; /** * Set for non-member recipients of a gated post: renders a "subscribe to * see the full content" box after the (truncated) blocks. Email can't open * the site's join modal, so it always links out to the join page. */ membersUpsell?: { joinUrl: string; cheapestMonthlyCents?: number | null; }; /** * Hydrated Bluesky posts for `pub.leaflet.blocks.bskyPost` blocks, keyed * by post URI. Blocks whose post is missing (hydration failed, post * deleted/blocked) fall back to the "not supported" card. */ bskyPosts?: Record<string, AppBskyFeedDefs.PostView>; /** * Hydrated data for `pub.leaflet.blocks.standardSitePost` / * `standardSitePublication` blocks, keyed by AT-URI. Blocks whose reference * is missing render the same "not found" notice as the published web page. */ standardSitePosts?: Record<string, StandardSitePostData>; standardSitePublications?: Record<string, StandardSitePublicationData>; /** * AT-URI of the publication this email is sent from. A standardSitePost * block referencing a post in this same publication hides its publication * footer, matching the web renderer. */ currentPublicationUri?: string;};
const drawerUrl = (base: string, drawer: "quotes" | "comments") => { const sep = base.includes("?") ? "&" : "?"; return `${base}${sep}interactionDrawer=${drawer}`;};
// The same ?page= deep link the published post's page-link anchors use; the// post page reads it on load to open that sub-page next to the body.const pageUrl = (base: string, pageId: string) => { const sep = base.includes("?") ? "&" : "?"; return `${base}${sep}page=${encodeURIComponent(pageId)}`;};
const textBlock = ( plaintext: string, facets?: PubLeafletRichtextFacet.Main[],): PubLeafletPagesLinearDocument.Block => ({ $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.text", plaintext, facets },});
const headingBlock = ( plaintext: string, level: number,): PubLeafletPagesLinearDocument.Block => ({ $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.header", plaintext, level },});
const facet = ( byteStart: number, byteEnd: number, features: PubLeafletRichtextFacet.Main["features"],): PubLeafletRichtextFacet.Main => ({ $type: "pub.leaflet.richtext.facet", index: { byteStart, byteEnd }, features,});
const bskyPostBlock = (rkey: string): PubLeafletPagesLinearDocument.Block => ({ $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.bskyPost", postRef: { uri: `at://did:plc:preview/app.bsky.feed.post/${rkey}`, cid: "preview", }, },});
const galleryImage = ( width: number, height: number, label: string,): PubLeafletBlocksImageGallery.Image => ({ $type: "pub.leaflet.blocks.imageGallery#image", image: { ref: { $link: `https://placehold.co/${width}x${height}/png?text=${label}` }, } as unknown as PubLeafletBlocksImageGallery.Image["image"], aspectRatio: { width, height }, alt: `placeholder ${label}`,});
const galleryBlock = ( format: "grid" | "carousel" | "strip", images: PubLeafletBlocksImageGallery.Image[],): PubLeafletPagesLinearDocument.Block => ({ $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.imageGallery", format, images },});
const previewBskyAuthor = { did: "did:plc:preview", handle: "leaflet.pub", displayName: "Leaflet Preview", avatar: "https://placehold.co/80x80/png",};
const previewBskyPost = ( rkey: string, post: Partial<AppBskyFeedDefs.PostView>,): [string, AppBskyFeedDefs.PostView] => { const uri = `at://did:plc:preview/app.bsky.feed.post/${rkey}`; return [ uri, { $type: "app.bsky.feed.defs#postView", uri, cid: "preview", author: previewBskyAuthor, record: { $type: "app.bsky.feed.post", text: "A plain preview post.", createdAt: "2026-01-05T17:00:00.000Z", }, indexedAt: "2026-01-05T17:00:00.000Z", ...post, }, ];};
// Fixture posts for `npm run email:dev`, covering each embed degradation.// Production sends look posts up by their real at:// URIs, so these preview// URIs can never collide with sent content.const previewBskyPosts: Record<string, AppBskyFeedDefs.PostView> = Object.fromEntries([ previewBskyPost("text", { record: { $type: "app.bsky.feed.post", text: "Check out https://leaflet.pub for more.\nNewlines become breaks.", facets: [ { index: { byteStart: 10, byteEnd: 29 }, features: [ { $type: "app.bsky.richtext.facet#link", uri: "https://leaflet.pub", }, ], }, ], createdAt: "2026-01-05T17:00:00.000Z", }, replyCount: 3, quoteCount: 2, }), previewBskyPost("image", { record: { $type: "app.bsky.feed.post", text: "A post with a single image.", createdAt: "2026-01-05T17:00:00.000Z", }, embed: { $type: "app.bsky.embed.images#view", images: [ { thumb: "https://placehold.co/600x400/png", fullsize: "https://placehold.co/1200x800/png", alt: "placeholder", }, ], }, }), previewBskyPost("multiimage", { record: { $type: "app.bsky.feed.post", text: "A post with four images.", createdAt: "2026-01-05T17:00:00.000Z", }, embed: { $type: "app.bsky.embed.images#view", images: Array.from({ length: 4 }, (_, i) => ({ thumb: `https://placehold.co/600x400/png?text=${i + 1}`, fullsize: `https://placehold.co/1200x800/png?text=${i + 1}`, alt: `placeholder ${i + 1}`, })), }, }), previewBskyPost("external", { record: { $type: "app.bsky.feed.post", text: "A post with a link card.", createdAt: "2026-01-05T17:00:00.000Z", }, embed: { $type: "app.bsky.embed.external#view", external: { uri: "https://leaflet.pub", title: "Leaflet", description: "Shared writing and social publishing, with a description long enough to exercise the two-line clamp on the external card.", thumb: "https://placehold.co/240x240/png", }, }, }), previewBskyPost("quote", { record: { $type: "app.bsky.feed.post", text: "Quoting another post.", createdAt: "2026-01-05T17:00:00.000Z", }, embed: { $type: "app.bsky.embed.record#view", record: { $type: "app.bsky.embed.record#viewRecord", uri: "at://did:plc:preview/app.bsky.feed.post/quoted", cid: "preview", author: previewBskyAuthor, value: { $type: "app.bsky.feed.post", text: "The quoted post's text.", createdAt: "2026-01-04T17:00:00.000Z", }, indexedAt: "2026-01-04T17:00:00.000Z", }, }, }), previewBskyPost("video", { record: { $type: "app.bsky.feed.post", text: "A post with a video.", createdAt: "2026-01-05T17:00:00.000Z", }, embed: { $type: "app.bsky.embed.video#view", cid: "preview", playlist: "https://example.com/playlist.m3u8", thumbnail: "https://placehold.co/640x360/png", alt: "video placeholder", }, }), previewBskyPost("labeled", { record: { $type: "app.bsky.feed.post", text: "A labeled post — the media is replaced with an info box.", createdAt: "2026-01-05T17:00:00.000Z", }, labels: [ { src: "did:plc:moderation", uri: "at://did:plc:preview/app.bsky.feed.post/labeled", val: "graphic-media", cts: "2026-01-05T17:00:00.000Z", }, ], embed: { $type: "app.bsky.embed.images#view", images: [ { thumb: "https://placehold.co/600x400/png", fullsize: "https://placehold.co/1200x800/png", alt: "placeholder", }, ], }, }), previewBskyPost("optout", { author: { ...previewBskyAuthor, labels: [ { src: "did:plc:preview", uri: "at://did:plc:preview/app.bsky.actor.profile/self", val: "!no-unauthenticated", cts: "2026-01-05T17:00:00.000Z", }, ], }, record: { $type: "app.bsky.feed.post", text: "This text should not render — the author opted out.", createdAt: "2026-01-05T17:00:00.000Z", }, }), ]);
const standardSitePostBlock = ( rkey: string, size?: "large" | "medium" | "small",): PubLeafletPagesLinearDocument.Block => ({ $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.standardSitePost", uri: `at://did:plc:preview/site.standard.document/${rkey}`, size, },});
const previewStandardSitePublication: StandardSitePublicationData = { uri: "at://did:plc:preview/site.standard.publication/3previewpub", record: { $type: "site.standard.publication", name: "Example Publication", url: "https://example.leaflet.pub", description: "A publication about examples, with enough description text that it wraps onto several lines and exercises the three-line clamp on the card.", }, author: { did: "did:plc:preview", handle: "example.leaflet.pub", displayName: "Example Author", },};
const previewCoverImage = { ref: { $link: "https://placehold.co/800x800/png" },} as unknown as NonNullable<StandardSitePostData["record"]["coverImage"]>;
const previewStandardSitePost = ( rkey: string, record: Partial<StandardSitePostData["record"]>,): [string, StandardSitePostData] => { const uri = `at://did:plc:preview/site.standard.document/${rkey}`; return [ uri, { uri, record: { $type: "site.standard.document", title: "A referenced post", description: "The description of the referenced post, long enough to wrap onto a few lines so the clamp is visible in the preview.", publishedAt: "2026-01-05T17:00:00.000Z", site: "https://example.leaflet.pub", path: `/${rkey}`, ...record, }, publication: { uri: previewStandardSitePublication.uri, record: previewStandardSitePublication.record, }, author: previewStandardSitePublication.author, contributors: [], commentsCount: 0, mentionsCount: 0, recommendsCount: 0, }, ];};
// Fixture posts for `npm run email:dev`, one per block size. Production sends// look posts up by their real at:// URIs, so these can never collide.const previewStandardSitePosts: Record<string, StandardSitePostData> = Object.fromEntries([ previewStandardSitePost("small", { title: "A small referenced post" }), previewStandardSitePost("medium", { title: "A medium post with a cover image", coverImage: previewCoverImage, }), previewStandardSitePost("large", { title: "A large post with a cover image", coverImage: previewCoverImage, }), ]);
const defaultProps: PostEmailProps = { publicationName: "Publication", publicationUrl: "https://leaflet.pub", postTitle: "Post Title Here", postDescription: "Hello this is a description of everything that is to come", postUrl: "https://leaflet.pub", authorName: "author", publishedAtLabel: "Jan 1, 2026", assetsBaseUrl: "https://leaflet.pub/", did: "did:plc:example", blocks: [ textBlock( "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.", ), headingBlock("This is a Title", 1), textBlock( "We'll keep it nice and separate so we can see what it looks like. Also bold, italic, underlined, struck, code, a link, and a mention to @leaflet.pub.", [ facet(71, 75, [{ $type: "pub.leaflet.richtext.facet#bold" }]), facet(77, 83, [{ $type: "pub.leaflet.richtext.facet#italic" }]), facet(85, 95, [{ $type: "pub.leaflet.richtext.facet#underline" }]), facet(97, 103, [{ $type: "pub.leaflet.richtext.facet#strikethrough" }]), facet(105, 109, [{ $type: "pub.leaflet.richtext.facet#code" }]), facet(111, 117, [ { $type: "pub.leaflet.richtext.facet#link", uri: "https://leaflet.pub", }, ]), facet(136, 148, [ { $type: "pub.leaflet.richtext.facet#didMention", did: "did:plc:x2xmijn2egk5g67u3cwkddzy", }, ]), ], ), headingBlock("And a Header", 2), { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.unorderedList", children: [ { content: { $type: "pub.leaflet.blocks.text", plaintext: "fruits" }, children: [ { content: { $type: "pub.leaflet.blocks.text", plaintext: "apple", }, }, { content: { $type: "pub.leaflet.blocks.text", plaintext: "banana", }, }, ], }, { content: { $type: "pub.leaflet.blocks.text", plaintext: "veggies", }, }, ], }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.blockquote", plaintext: "A quote of some kind.", }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.code", plaintext: "const x = 1;", language: "javascript", }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.website", src: "https://example.com", title: "Link Title Here", description: "Description on the link", }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.page", id: "preview-subpage" }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.page", id: "preview-canvas" }, }, // No fixture page for this id — renders nothing, like the web. { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.page", id: "preview-missing" }, }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.button", text: "Click me", url: "https://leaflet.pub", }, }, { $type: "pub.leaflet.pages.linearDocument#block", alignment: "lex:pub.leaflet.pages.linearDocument#textAlignLeft", block: { $type: "pub.leaflet.blocks.button", text: "Aligned left", url: "https://leaflet.pub", }, }, { $type: "pub.leaflet.pages.linearDocument#block", alignment: "lex:pub.leaflet.pages.linearDocument#textAlignRight", block: { $type: "pub.leaflet.blocks.button", text: "Aligned right", url: "https://leaflet.pub", }, }, { ...textBlock("This text is centered."), alignment: "lex:pub.leaflet.pages.linearDocument#textAlignCenter", }, { ...textBlock("This text is right-aligned."), alignment: "lex:pub.leaflet.pages.linearDocument#textAlignRight", }, { ...headingBlock("Centered heading", 2), alignment: "lex:pub.leaflet.pages.linearDocument#textAlignCenter", }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.horizontalRule" }, }, bskyPostBlock("text"), bskyPostBlock("image"), bskyPostBlock("multiimage"), bskyPostBlock("external"), bskyPostBlock("quote"), bskyPostBlock("video"), bskyPostBlock("labeled"), bskyPostBlock("optout"), // No fixture post for this URI — exercises the deleted/unfetched fallback. bskyPostBlock("deleted"), standardSitePostBlock("small", "small"), standardSitePostBlock("medium", "medium"), standardSitePostBlock("large", "large"), // No fixture for this URI — exercises the "Post not found." fallback. standardSitePostBlock("missing"), { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.standardSitePublication", uri: previewStandardSitePublication.uri, }, }, galleryBlock("grid", [ galleryImage(600, 400, "1"), galleryImage(400, 600, "2"), galleryImage(500, 500, "3"), galleryImage(600, 400, "4"), galleryImage(600, 300, "5"), ]), galleryBlock("strip", [ galleryImage(1200, 800, "1"), galleryImage(1200, 600, "2"), galleryImage(1200, 900, "3"), ]), galleryBlock("carousel", [ galleryImage(1200, 800, "1"), galleryImage(1200, 800, "2"), galleryImage(1200, 800, "3"), galleryImage(1200, 800, "4"), ]), // Single-image carousel — no "+N more" indicator. galleryBlock("carousel", [galleryImage(1200, 800, "solo")]), { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.math" }, }, ], pages: [ { id: "preview-subpage", type: "doc", blocks: [ headingBlock("A sub-page with a title", 2), textBlock( "The first few lines of the page show up in the card, with bold and a link kept intact.", [ facet(58, 62, [{ $type: "pub.leaflet.richtext.facet#bold" }]), facet(69, 73, [ { $type: "pub.leaflet.richtext.facet#link", uri: "https://leaflet.pub", }, ]), ], ), textBlock("A third line, and then it stops."), textBlock("This fourth line is never shown."), ], }, { id: "preview-canvas", type: "canvas", blocks: [ { $type: "pub.leaflet.pages.canvas#block", x: 0, y: 0, width: 200, block: { $type: "pub.leaflet.blocks.text", plaintext: "A note on a canvas page", }, }, ], }, ], bskyPosts: previewBskyPosts, standardSitePosts: previewStandardSitePosts, standardSitePublications: { [previewStandardSitePublication.uri]: previewStandardSitePublication, },};
const BLOCK_MARGIN_TOP = 4;const BLOCK_MARGIN_BOTTOM = 16;const BLOCK_MARGIN = `${BLOCK_MARGIN_TOP}px 0 ${BLOCK_MARGIN_BOTTOM}px`;const IMAGE_MARGIN = `${BLOCK_MARGIN_TOP}px auto ${BLOCK_MARGIN_BOTTOM}px`;const HEADING_MARGIN = `${BLOCK_MARGIN_TOP}px 0 0`;const HEADING_FONT_SIZE_PX: Record<1 | 2 | 3, number> = { 1: 26, 2: 18, 3: 16,};
export const PostEmail = (props: Partial<PostEmailProps> = {}) => { const p: PostEmailProps = { ...defaultProps, ...props }; const theme = p.theme ?? defaultEmailTheme; const c = resolveColors(theme); const staticUrl = makeStaticUrl(p.assetsBaseUrl); const byline = [p.authorName, p.publishedAtLabel].filter(Boolean).join(" | ");
const accentLink: CSSProperties = { color: theme.accentBackground, textDecoration: "none", fontFamily: theme.bodyFont, };
return ( <Html> <MailHead> {/* Mobile-only padding tightening. Apple Mail, iOS Mail, Gmail web, and most other clients honor @media in <head>; Outlook desktop ignores it and keeps the wider inline padding, which is fine (it's a desktop client). !important is required so the media-query rule beats the inline `style.padding`. */} <style>{` @media only screen and (max-width: 480px) { .email-page-pad { padding: 0 !important; } .email-card-pad { padding: 12px !important; } } `}</style> </MailHead> <Body style={{ backgroundColor: theme.backgroundColor, color: theme.primary, fontFamily: theme.bodyFont, margin: 0, padding: 0, }} > {/* Page-background wrapper. Gmail strips/replaces <body> styling, so we paint the background on this table's <td> using both the bgcolor HTML attribute (Outlook/Gmail bulletproof) and a backgroundColor style (everything else). */} <table role="presentation" width="100%" cellPadding={0} cellSpacing={0} border={0} bgcolor={theme.backgroundColor} style={{ width: "100%", backgroundColor: theme.backgroundColor, }} > <tbody> <tr> <td align="center" className="email-page-pad" {...bgcolorAttr(theme.backgroundColor)} style={{ backgroundColor: theme.backgroundColor, padding: "24px 16px", }} > {/* Responsive width: `width="100%"` lets the card fill its container, and `max-width: pageWidth` caps it on wider viewports. This keeps it readable at the publication's page width on desktop while shrinking gracefully when the email is viewed in a narrow window (e.g. Apple Mail desktop resized below the page width). Outlook desktop ignores `max-width` and will render full-width — that's a known tradeoff for the simpler markup. */} <table role="presentation" align="center" className="email-card-table" width="100%" cellPadding={0} cellSpacing={0} border={0} style={{ width: "100%", maxWidth: theme.pageWidth, margin: "0 auto", }} > <tbody> <tr> <td className="email-card-pad" {...(theme.showPageBackground ? bgcolorAttr(theme.pageBackground) : {})} style={{ backgroundColor: theme.showPageBackground ? theme.pageBackground : undefined, border: theme.showPageBackground ? `1px solid ${c.border}` : undefined, borderRadius: 8, padding: "20px 24px", }} > <Link href={p.publicationUrl} style={{ ...accentLink, fontWeight: "bold", fontSize: 16, }} > {p.publicationName} </Link>
<ReactEmailHeading as="h1" style={{ color: theme.primary, fontFamily: theme.headingFont, fontWeight: "bold", fontSize: 26, lineHeight: 1.2, margin: "8px 0 0", }} > <Link href={p.postUrl} style={{ color: theme.primary, fontFamily: theme.headingFont, textDecoration: "none", }} > {p.postTitle} </Link> </ReactEmailHeading>
{p.postDescription ? ( <ReactEmailText style={{ color: c.secondary, fontFamily: theme.bodyFont, fontSize: 16, fontStyle: "italic", lineHeight: 1.4, margin: "4px 0 0", }} > {p.postDescription} </ReactEmailText> ) : null}
{byline ? ( <Section style={{ margin: "12px 0 28px", minWidth: "100%" }} > <Row style={{ minWidth: "100%" }}> <Column style={{ verticalAlign: "middle" }}> <ReactEmailText style={{ color: c.tertiary, fontFamily: theme.bodyFont, fontSize: 14, lineHeight: 1.4, margin: 0, }} > {byline} </ReactEmailText> </Column> <Column style={{ width: 12 }} /> <Column style={{ width: 16, verticalAlign: "middle" }} > <Link href={drawerUrl(p.postUrl, "quotes")} style={accentLink} > <Img width={16} height={16} src={makeEmailIconUrl( p.assetsBaseUrl, "quote", theme.accentBackground, )} alt="See quotes" /> </Link> </Column> <Column style={{ width: 8 }} /> <Column style={{ width: 16, verticalAlign: "middle" }} > <Link href={drawerUrl(p.postUrl, "comments")} style={accentLink} > <Img width={16} height={16} src={makeEmailIconUrl( p.assetsBaseUrl, "comment", theme.accentBackground, )} alt="See comments" /> </Link> </Column> </Row> </Section> ) : null}
{p.blocks.map((b, i) => ( <BlockRenderer key={i} block={b.block} alignment={b.alignment} // Matches the published page's block anchor id // (PostContent renders each root-page block with // id={index}), so emails can deep-link to a block. blockUrl={`${p.postUrl}#${i}`} did={p.did} assetsBaseUrl={p.assetsBaseUrl} theme={theme} colors={c} postUrl={p.postUrl} pages={p.pages} bskyPosts={p.bskyPosts} standardSitePosts={p.standardSitePosts} standardSitePublications={ p.standardSitePublications } currentPublicationUri={p.currentPublicationUri} /> ))}
{p.membersUpsell ? ( <MembersUpsell publicationName={p.publicationName} joinUrl={p.membersUpsell.joinUrl} cheapestMonthlyCents={ p.membersUpsell.cheapestMonthlyCents } theme={theme} colors={c} /> ) : null}
{/* Footer: Gmail won't reliably cascade `text-align` from a wrapping <table>, so each centered row is its own <td align="center"> — the bulletproof email-centering pattern. `min-width: 100%` keeps Gmail iOS from shrink-wrapping the table around the short link text. */} <table role="presentation" width="100%" cellPadding={0} cellSpacing={0} border={0} style={{ width: "100%", minWidth: "100%" }} > <tbody> <tr> <td align="center" style={{ paddingTop: 16 }}> <Link href={p.postUrl} style={{ ...accentLink, fontWeight: "bold", fontSize: 14, lineHeight: "20px", }} > Read in Browser </Link> </td> </tr> <tr> <td align="center" style={{ color: c.tertiary, fontFamily: theme.bodyFont, fontSize: 14, lineHeight: "20px", paddingTop: 8, }} > {p.unsubscribeUrl ? ( [ p.manageUrl && { href: p.manageUrl, label: "Manage subscription", }, { href: p.unsubscribeUrl, label: "Unsubscribe", }, ] .filter((l) => !!l) .map((l, i) => ( <Fragment key={l.label}> {i > 0 ? " · " : null} <Link href={l.href} style={{ color: c.tertiary, fontSize: 14, lineHeight: "20px", textDecoration: "underline", }} > {l.label} </Link> </Fragment> )) ) : ( <span style={{ fontStyle: "italic" }}> (preview — not sent to subscribers) </span> )} </td> </tr>
{/* Spacer */} <tr> <td style={{ fontSize: 0, height: 16, lineHeight: "16px", }} > </td> </tr>
{/* Horizontal rule above watermark. <hr> margins are flaky in Gmail, so we use a 1px-tall <td> with border-top instead. */} <tr> <td style={{ borderTop: `1px solid ${c.borderLight}`, fontSize: 0, height: 1, lineHeight: "1px", }} > </td> </tr>
<tr> <td style={{ fontSize: 0, height: 12, lineHeight: "12px", }} > </td> </tr>
<tr> <td align="center"> <LeafletWatermark theme={theme} staticUrl={staticUrl} /> </td> </tr> </tbody> </table> </td> </tr> </tbody> </table> </td> </tr> </tbody> </table> </Body> </Html> );};// `npm run email:dev` renders this default export. Set// `PREVIEW_PUBLICATION_URI` below to an `at://did:plc:.../pub.leaflet.publication/<rkey>`// to preview the post template with that publication's name, url, and theme —// matching what `send_post_broadcast` and `sendPostPreview` resolve at send time.// Leave it null to render with the static default props.const PREVIEW_PUBLICATION_URI: string | null = null;const PostEmailPreview = async () => { if (!PREVIEW_PUBLICATION_URI) return <PostEmail />;
const { data: publication, error } = await supabaseServerClient .from("publications") .select("record") .eq("uri", PREVIEW_PUBLICATION_URI) .maybeSingle();
if (error || !publication) { console.warn( `[email preview] could not load publication ${PREVIEW_PUBLICATION_URI}:`, error?.message ?? "not found", ); return <PostEmail />; }
const pubProps = emailPropsFromPublication( normalizePublicationRecord(publication.record), ); return <PostEmail {...pubProps} />;};
export default PostEmailPreview;
// Map the lexicon's alignment token to a CSS `text-align` value for// text-like blocks (text, header, blockquote, list). Returns `undefined`// when no alignment is set so the element inherits the default (left).const resolveTextAlignment = ( alignment: string | undefined,): CSSProperties["textAlign"] => { switch (alignment) { case "lex:pub.leaflet.pages.linearDocument#textAlignRight": return "right"; case "lex:pub.leaflet.pages.linearDocument#textAlignCenter": return "center"; case "lex:pub.leaflet.pages.linearDocument#textAlignJustify": return "justify"; case "lex:pub.leaflet.pages.linearDocument#textAlignLeft": return "left"; default: return undefined; }};
// Map the lexicon's alignment token to a simple left/center/right value// usable as an HTML `align` attribute. `justify` falls through to `left`// to match published web behavior (`justify-start` flex). For buttons and// images we default to `center` when alignment is unset, matching// PostContent.tsx.const resolveBlockAlignment = ( alignment: string | undefined,): "left" | "center" | "right" => { switch (alignment) { case "lex:pub.leaflet.pages.linearDocument#textAlignRight": return "right"; case "lex:pub.leaflet.pages.linearDocument#textAlignLeft": case "lex:pub.leaflet.pages.linearDocument#textAlignJustify": return "left"; case "lex:pub.leaflet.pages.linearDocument#textAlignCenter": default: return "center"; }};
const BlockRenderer = ({ block, alignment, did, assetsBaseUrl, theme, colors, postUrl, blockUrl, pages, bskyPosts, standardSitePosts, standardSitePublications, currentPublicationUri,}: { block: PubLeafletPagesLinearDocument.Block["block"]; alignment?: string; did: string; assetsBaseUrl: string; theme: EmailTheme; colors: ResolvedColors; postUrl: string; blockUrl?: string; pages?: PostEmailPage[]; bskyPosts?: Record<string, AppBskyFeedDefs.PostView>; standardSitePosts?: Record<string, StandardSitePostData>; standardSitePublications?: Record<string, StandardSitePublicationData>; currentPublicationUri?: string;}) => { const notSupported = () => ( <BlockNotSupported theme={theme} colors={colors} postUrl={postUrl} /> ); const handlers: BlockHandlers<React.ReactNode> = { "pub.leaflet.blocks.text": (block) => ( <ReactEmailText style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.5, margin: BLOCK_MARGIN, textAlign: resolveTextAlignment(alignment), }} > {block.plaintext ? ( <RichTextSpans plaintext={block.plaintext} facets={block.facets} theme={theme} assetsBaseUrl={assetsBaseUrl} /> ) : ( " " )} </ReactEmailText> ), "pub.leaflet.blocks.header": (block) => { const level = Math.min(3, Math.max(1, Math.floor(block.level ?? 1))) as | 1 | 2 | 3; return ( <ReactEmailHeading as={`h${level}`} style={{ color: level === 3 ? colors.secondary : theme.primary, fontFamily: theme.headingFont, fontWeight: "bold", fontSize: HEADING_FONT_SIZE_PX[level], lineHeight: 1.25, margin: HEADING_MARGIN, textAlign: resolveTextAlignment(alignment), }} > <RichTextSpans plaintext={block.plaintext} facets={block.facets} theme={theme} assetsBaseUrl={assetsBaseUrl} /> </ReactEmailHeading> ); }, "pub.leaflet.blocks.blockquote": (block) => ( <Section style={{ margin: BLOCK_MARGIN }}> <Row> <Column style={{ width: 2, backgroundColor: colors.border }} /> <Column style={{ width: 8 }} /> <Column> <ReactEmailText style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.5, margin: "2px 0", textAlign: resolveTextAlignment(alignment), }} > <RichTextSpans plaintext={block.plaintext} facets={block.facets} theme={theme} assetsBaseUrl={assetsBaseUrl} /> </ReactEmailText> </Column> </Row> </Section> ), "pub.leaflet.blocks.code": (block) => ( <CodeBlock code={block.plaintext} language={block.language} borderColor={colors.borderLight} /> ), "pub.leaflet.blocks.image": (block) => { const src = blobRefToSrc(block.image.ref, did, assetsBaseUrl, { // The email body is ~600px wide, so the 1200 rung covers it at retina // density; the full-resolution blob would be pure wasted bytes. width: 1200, format: "email", }); // Deliberately no numeric `width`/`height` HTML attributes: Outlook // honors those over CSS `max-width`, so a 1200px natural-size photo // would blow out our 28rem container. `max-width: <natural>px` keeps // smaller images from being upscaled. const naturalWidth = block.aspectRatio?.width; return ( <ImageBlock src={src} alt={block.alt ?? ""} naturalWidth={naturalWidth} align={resolveBlockAlignment(alignment)} /> ); }, "pub.leaflet.blocks.website": (block) => { const previewSrc = block.previewImage ? blobRefToSrc(block.previewImage.ref, did, assetsBaseUrl, { width: 360, format: "email", }) : undefined; return ( <LinkBlock url={block.src} title={block.title} description={block.description} previewSrc={previewSrc} theme={theme} colors={colors} /> ); }, "pub.leaflet.blocks.button": (block) => ( <ButtonBlock text={block.text} url={block.url} align={resolveBlockAlignment(alignment)} theme={theme} /> ), "pub.leaflet.blocks.horizontalRule": () => ( <Hr style={{ border: "none", borderTop: `1px solid ${colors.borderLight}`, margin: "12px 0", width: "100%", }} /> ), "pub.leaflet.blocks.unorderedList": (block) => ( <List items={block.children} style="unordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} textAlign={resolveTextAlignment(alignment)} /> ), "pub.leaflet.blocks.orderedList": (block) => ( <List items={block.children} style="ordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} textAlign={resolveTextAlignment(alignment)} /> ), "pub.leaflet.blocks.bskyPost": (block) => { const post = bskyPosts?.[block.postRef.uri]; if (!post) { return ( <BlockNotSupported theme={theme} colors={colors} postUrl={postUrl} /> ); } return ( <BskyPostEmailBlock post={post} clientHost={block.clientHost} theme={theme} colors={colors} assetsBaseUrl={assetsBaseUrl} /> ); }, "pub.leaflet.blocks.standardSitePost": (block) => { const post = standardSitePosts?.[block.uri]; if (!post) { return ( <BlockDataNotFound label="Post not found." theme={theme} colors={colors} /> ); } // default to "medium" to match the draft (StandardSitePostBlock), // since the publish step omits size when it hasn't been explicitly set const size = block.size === "large" ? "large" : block.size === "small" ? "small" : "medium"; return ( <StandardSitePostEmailBlock post={post} size={size} showPublicationTheme={block.showPublicationTheme !== false} currentPublicationUri={currentPublicationUri} theme={theme} assetsBaseUrl={assetsBaseUrl} /> ); }, "pub.leaflet.blocks.imageGallery": (block) => ( <ImageGalleryEmailBlock block={block} did={did} assetsBaseUrl={assetsBaseUrl} href={blockUrl ?? postUrl} theme={theme} colors={colors} /> ), // The delimiter renders as nothing but must stay in the block array so // later blocks keep their record indices (and thus their #index anchors). "pub.leaflet.blocks.membersOnlyDelimiter": () => null, "pub.leaflet.blocks.standardSitePublication": (block) => { const publication = standardSitePublications?.[block.uri]; if (!publication) { return ( <BlockDataNotFound label="Publication not found." theme={theme} colors={colors} bordered /> ); } return ( <StandardSitePublicationEmailBlock publication={publication} showPublicationTheme={block.showPublicationTheme !== false} theme={theme} assetsBaseUrl={assetsBaseUrl} /> ); }, "pub.leaflet.blocks.iframe": notSupported, "pub.leaflet.blocks.html": notSupported, "pub.leaflet.blocks.math": notSupported, "pub.leaflet.blocks.page": (block) => { const page = pages?.find((p) => p.id === block.id); if (!page) return null; return ( <PageLinkEmailBlock page={page} href={pageUrl(postUrl, block.id)} did={did} theme={theme} colors={colors} assetsBaseUrl={assetsBaseUrl} /> ); }, "pub.leaflet.blocks.poll": notSupported, "pub.leaflet.blocks.postsList": notSupported, "pub.leaflet.blocks.signup": notSupported, }; return matchBlock(block, handlers, notSupported);};
// Matches the published web renderer's notice when a referenced standard-site// post/publication isn't in the database. On the web the publication notice// keeps its bordered card wrapper while the post notice is bare text —// `bordered` mirrors that.const BlockDataNotFound = ({ label, theme, colors, bordered,}: { label: string; theme: EmailTheme; colors: ResolvedColors; bordered?: boolean;}) => { const text = ( <ReactEmailText style={{ color: colors.tertiary, fontFamily: theme.bodyFont, fontSize: 14, fontStyle: "italic", lineHeight: 1.4, margin: bordered ? 0 : BLOCK_MARGIN, }} > {label} </ReactEmailText> ); if (!bordered) return text; return ( <Section style={{ border: `1px solid ${colors.borderLight}`, borderRadius: 8, margin: BLOCK_MARGIN, padding: 16, }} > {text} </Section> );};
// Helpers used by the confirm-email templates inside their <Tailwind>// context. Layout (margin/font-size/line-height) is inline so they render// without Tailwind too; color and font-family are left to the caller via// className or a `style` override so Tailwind classes can win.export const Text = (props: { children: React.ReactNode; noPadding?: boolean; small?: boolean; className?: string; style?: CSSProperties;}) => { const fontSize = props.small ? 14 : 16; return ( <ReactEmailText className={props.className} style={{ fontSize, lineHeight: 1.5, margin: props.noPadding ? 0 : BLOCK_MARGIN, ...props.style, }} > {props.children} </ReactEmailText> );};
export const Heading = (props: { children: React.ReactNode; noPadding?: boolean; as: "h1" | "h2" | "h3"; className?: string; style?: CSSProperties;}) => { const fontSize = props.as === "h1" ? 26 : props.as === "h2" ? 18 : 16; return ( <ReactEmailHeading as={props.as} className={props.className} style={{ fontWeight: "bold", fontSize, lineHeight: 1.25, margin: props.noPadding ? 0 : HEADING_MARGIN, ...props.style, }} > {props.children} </ReactEmailHeading> );};
type ListItem = | PubLeafletBlocksUnorderedList.ListItem | PubLeafletBlocksOrderedList.ListItem;
const listItemContent = ( item: ListItem,): { plaintext: string; facets?: PubLeafletRichtextFacet.Main[] } => { const content = item.content; if (PubLeafletBlocksText.isMain(content)) return { plaintext: content.plaintext, facets: content.facets }; if (PubLeafletBlocksHeader.isMain(content)) return { plaintext: content.plaintext, facets: content.facets }; return { plaintext: "" };};
const List = ({ items, style, did, assetsBaseUrl, theme = defaultEmailTheme, textAlign,}: { items: ListItem[]; style: "ordered" | "unordered"; did: string; assetsBaseUrl: string; theme?: EmailTheme; textAlign?: CSSProperties["textAlign"];}) => { const Tag = style === "ordered" ? "ol" : "ul"; return ( <Section style={{ margin: BLOCK_MARGIN }}> <Tag style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.5, margin: 0, paddingLeft: 24, textAlign, }} > {items.map((item, i) => { const { plaintext, facets } = listItemContent(item); const nestedUnordered = item.children && style === "unordered" ? (item.children as PubLeafletBlocksUnorderedList.ListItem[]) : (item as PubLeafletBlocksOrderedList.ListItem) .unorderedListChildren?.children; const nestedOrdered = item.children && style === "ordered" ? (item.children as PubLeafletBlocksOrderedList.ListItem[]) : (item as PubLeafletBlocksUnorderedList.ListItem) .orderedListChildren?.children; return ( <React.Fragment key={i}> <li style={{ margin: "2px 0", paddingLeft: 4 }}> {typeof item.checked === "boolean" ? `${item.checked ? "☑ " : "☐ "}` : null} <RichTextSpans plaintext={plaintext} facets={facets} theme={theme} assetsBaseUrl={assetsBaseUrl} /> </li> {nestedUnordered && nestedUnordered.length > 0 ? ( <List items={nestedUnordered} style="unordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} /> ) : null} {nestedOrdered && nestedOrdered.length > 0 ? ( <List items={nestedOrdered} style="ordered" did={did} assetsBaseUrl={assetsBaseUrl} theme={theme} /> ) : null} </React.Fragment> ); })} </Tag> </Section> );};
const LinkBlock = ({ url, title, description, previewSrc, theme = defaultEmailTheme, colors,}: { url?: string; title?: string; description?: string; previewSrc?: string; theme?: EmailTheme; colors?: ResolvedColors;} = {}) => { const c = colors ?? resolveColors(theme); const displayUrl = (() => { if (!url) return "www.example.com"; try { return new URL(url).hostname; } catch { return url; } })(); // Each column wraps its content in a single `display: block` <Link> so // the entire padded area — text column and image column — is clickable. // Two anchors instead of one outer anchor keeps the markup table-friendly // for Outlook (which dislikes <a> wrapping <table>). const cellLinkStyle: CSSProperties = { color: "inherit", display: "block", textDecoration: "none", }; return ( <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> <Row style={{ border: `1px solid ${theme.accentBackground}`, borderRadius: 8, minWidth: "100%", }} > <Column style={{ verticalAlign: "top", // Long URL-like titles or domains have no break opportunities // and would otherwise force the column wider than the card. wordBreak: "break-word", }} > <Link href={url} style={{ ...cellLinkStyle, padding: "8px 12px" }}> <span style={{ color: theme.primary, display: "block", fontFamily: theme.bodyFont, fontWeight: "bold", fontSize: 16, wordBreak: "break-word", }} > {title || displayUrl} </span> {description ? ( <span style={{ color: c.secondary, // 2-line clamp matches the editor's `line-clamp-2`. // -webkit-line-clamp works in Apple Mail / iOS / Gmail; // Outlook ignores it but max-height + overflow still // truncates (just without an ellipsis). display: "-webkit-box", fontFamily: theme.bodyFont, fontSize: 16, lineHeight: 1.4, marginTop: 4, maxHeight: "2.8em", overflow: "hidden", WebkitBoxOrient: "vertical", WebkitLineClamp: 2, wordBreak: "break-word", }} > {description} </span> ) : null} <span style={{ color: theme.accentBackground, display: "block", fontFamily: theme.bodyFont, fontSize: 14, fontStyle: "italic", lineHeight: 1.4, marginTop: 4, wordBreak: "break-word", }} > {displayUrl} </span> </Link> </Column> {previewSrc ? ( <Column style={{ verticalAlign: "top", width: 112, }} > <Link href={url} style={{ ...cellLinkStyle, padding: "8px 8px 8px 0" }} > <Img src={previewSrc} alt="" style={{ borderRadius: 4, display: "block", height: 88, objectFit: "cover", width: "100%", }} /> </Link> </Column> ) : null} </Row> </Section> );};
// Mirrors PublishedPageLinkBlock: a page-colored card the whole of which// links to the sub-page. Doc pages show the first three text/heading lines// beside a rotated mini-page thumbnail; canvas pages show the canvas scaled// down. The card clips at the web's fixed heights so the thumbnail pokes up// from the bottom edge the same way.const PAGE_LINK_PREVIEW_LINES = 3;const PAGE_LINK_DOC_HEIGHT = 104;const PAGE_LINK_CANVAS_HEIGHT = 200;const PAGE_LINK_THUMB_WIDTH = 120;const CANVAS_WIDTH = 1272;// Web thumbnails render the page at full width scaled by transform; email// re-renders the blocks at scaled sizes instead, so `transform`-less clients// (Gmail, Outlook) still get a thumbnail — only the 4° tilt is lost there.const PageLinkEmailBlock = ({ page, href, did, theme, colors, assetsBaseUrl,}: { page: PostEmailPage; href: string; did: string; theme: EmailTheme; colors: ResolvedColors; assetsBaseUrl: string;}) => { const isCanvas = page.type === "canvas"; const cellLinkStyle: CSSProperties = { color: "inherit", display: "block", textDecoration: "none", }; return ( <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> <div style={{ backgroundColor: theme.pageBackground, border: `1px solid ${colors.borderLight}`, borderRadius: 8, boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", height: isCanvas ? PAGE_LINK_CANVAS_HEIGHT : PAGE_LINK_DOC_HEIGHT, overflow: "hidden", width: "100%", }} > {isCanvas ? ( <Link href={href} style={{ ...cellLinkStyle, height: "100%" }}> <CanvasThumbnail blocks={page.blocks as PubLeafletPagesCanvas.Block[]} did={did} theme={theme} colors={colors} assetsBaseUrl={assetsBaseUrl} /> </Link> ) : ( <Row style={{ minWidth: "100%" }}> <Column style={{ verticalAlign: "top", wordBreak: "break-word" }}> <Link href={href} style={{ ...cellLinkStyle, height: PAGE_LINK_DOC_HEIGHT, padding: "8px 0 0 12px", }} > <DocLinkLines blocks={page.blocks as PubLeafletPagesLinearDocument.Block[]} theme={theme} assetsBaseUrl={assetsBaseUrl} /> </Link> </Column> <Column style={{ verticalAlign: "top", width: PAGE_LINK_THUMB_WIDTH + 24, }} > <Link href={href} style={{ ...cellLinkStyle, height: PAGE_LINK_DOC_HEIGHT }} > <DocThumbnail blocks={page.blocks as PubLeafletPagesLinearDocument.Block[]} did={did} theme={theme} colors={colors} assetsBaseUrl={assetsBaseUrl} /> </Link> </Column> </Row> )} </div> </Section> );};
// The lines sit inside the card's anchor, so link/mention facets must not// render their own <a>: the HTML parser closes the outer anchor at a nested// one and everything after it lands outside the card.const stripLinkFeatures = (facets?: PubLeafletRichtextFacet.Main[]) => facets?.map((f) => ({ ...f, features: f.features.filter( (feat) => !PubLeafletRichtextFacet.isLink(feat) && !PubLeafletRichtextFacet.isDidMention(feat) && !PubLeafletRichtextFacet.isAtMention(feat), ), }));
const DocLinkLines = ({ blocks, theme, assetsBaseUrl,}: { blocks: PubLeafletPagesLinearDocument.Block[]; theme: EmailTheme; assetsBaseUrl: string;}) => { const lines = blocks .map((b): unknown => b.block) .filter( (b): b is PubLeafletBlocksText.Main | PubLeafletBlocksHeader.Main => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), ) .slice(0, PAGE_LINK_PREVIEW_LINES); return ( <> {lines.map((line, i) => ( <span key={i} style={{ color: theme.primary, display: "block", fontFamily: theme.bodyFont, fontSize: 14, fontWeight: PubLeafletBlocksHeader.isMain(line) ? "bold" : "normal", lineHeight: "20px", whiteSpace: "pre-wrap", }} > <RichTextSpans plaintext={line.plaintext} facets={stripLinkFeatures(line.facets)} theme={theme} assetsBaseUrl={assetsBaseUrl} /> </span> ))} </> );};
// The web thumbnail is the page at `pageWidth` scaled to 120px, with the// published page's own padding (px-4 pt-3).const DocThumbnail = ({ blocks, did, theme, colors, assetsBaseUrl,}: { blocks: PubLeafletPagesLinearDocument.Block[]; did: string; theme: EmailTheme; colors: ResolvedColors; assetsBaseUrl: string;}) => { const scale = PAGE_LINK_THUMB_WIDTH / theme.pageWidth; return ( <div style={{ backgroundColor: theme.pageBackground, border: `1px solid ${colors.borderLight}`, borderRadius: 6, boxSizing: "border-box", height: PAGE_LINK_DOC_HEIGHT, margin: "12px 12px 0", overflow: "hidden", padding: `${12 * scale}px ${16 * scale}px 0`, transform: "rotate(4deg)", transformOrigin: "center", width: PAGE_LINK_THUMB_WIDTH, }} > {blocks.slice(0, 20).map((b, i) => ( <MiniBlock key={i} block={b.block} scale={scale} did={did} theme={theme} colors={colors} assetsBaseUrl={assetsBaseUrl} /> ))} </div> );};
// Web: the 1272px canvas scaled to fit the card width, blocks placed at their// canvas coordinates. Positioning is absolute like the web; clients without// `position` support stack the blocks in reading order instead (they're// sorted top-to-bottom, left-to-right for that reason).const CanvasThumbnail = ({ blocks, did, theme, colors, assetsBaseUrl,}: { blocks: PubLeafletPagesCanvas.Block[]; did: string; theme: EmailTheme; colors: ResolvedColors; assetsBaseUrl: string;}) => { const cardWidth = theme.pageWidth - CARD_HORIZONTAL_PADDING; const scale = (cardWidth - 36) / CANVAS_WIDTH; const sorted = [...blocks].sort((a, b) => a.y === b.y ? a.x - b.x : a.y - b.y, ); return ( <div style={{ height: PAGE_LINK_CANVAS_HEIGHT, overflow: "hidden", position: "relative", width: "100%", }} > {sorted.map((canvasBlock, i) => ( <div key={i} style={{ boxSizing: "border-box", left: canvasBlock.x * scale, padding: 12 * scale, position: "absolute", top: canvasBlock.y * scale, transform: canvasBlock.rotation ? `rotate(${canvasBlock.rotation}deg)` : undefined, width: canvasBlock.width * scale, }} > <MiniBlock block={canvasBlock.block} scale={scale} did={did} theme={theme} colors={colors} assetsBaseUrl={assetsBaseUrl} /> </div> ))} </div> );};
// A block at thumbnail scale: the email's own block metrics (font sizes,// margins) multiplied down, so the thumbnail is a miniature of the email// body the way the web thumbnail is a miniature of the page. Blocks with no// cheap miniature (embeds, polls, code) are skipped.const MiniBlock = ({ block, scale, did, theme, colors, assetsBaseUrl,}: { block: PubLeafletPagesLinearDocument.Block["block"]; scale: number; did: string; theme: EmailTheme; colors: ResolvedColors; assetsBaseUrl: string;}) => { const margin = `${BLOCK_MARGIN_TOP * scale}px 0 ${BLOCK_MARGIN_BOTTOM * scale}px`; const text = ( plaintext: string, opts: { bold?: boolean; size?: number; italic?: boolean } = {}, ) => ( <div style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: (opts.size ?? 16) * scale, fontStyle: opts.italic ? "italic" : undefined, fontWeight: opts.bold ? "bold" : "normal", lineHeight: opts.size ? 1.25 : 1.5, margin: opts.size ? `${BLOCK_MARGIN_TOP * scale}px 0 0` : margin, overflow: "hidden", whiteSpace: "pre-wrap", wordBreak: "break-word", }} > {plaintext} </div> ); const listText = (items: ListItem[], depth = 0): React.ReactNode => items.map((item, i) => ( <Fragment key={i}> <div style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16 * scale, lineHeight: 1.5, paddingLeft: 24 * scale * (depth + 1), whiteSpace: "pre-wrap", wordBreak: "break-word", }} > {"• "} {listItemContent(item).plaintext} </div> {item.children?.length ? listText(item.children, depth + 1) : null} </Fragment> )); const handlers: BlockHandlers<React.ReactNode> = { "pub.leaflet.blocks.text": (b) => text(b.plaintext), "pub.leaflet.blocks.header": (b) => { const level = (b.level ?? 1) as 1 | 2 | 3; return text(b.plaintext, { bold: true, size: HEADING_FONT_SIZE_PX[level] ?? HEADING_FONT_SIZE_PX[1], }); }, "pub.leaflet.blocks.blockquote": (b) => ( <div style={{ borderLeft: `${2 * scale}px solid ${colors.border}`, margin, paddingLeft: 12 * scale, }} > {text(b.plaintext, { italic: true })} </div> ), "pub.leaflet.blocks.unorderedList": (b) => ( <div style={{ margin }}>{listText(b.children)}</div> ), "pub.leaflet.blocks.orderedList": (b) => ( <div style={{ margin }}>{listText(b.children)}</div> ), "pub.leaflet.blocks.image": (b) => ( <Img src={blobRefToSrc(b.image.ref, did, assetsBaseUrl, { width: 360, format: "email", })} alt="" style={{ display: "block", height: "auto", margin, maxWidth: b.aspectRatio?.width ? `${b.aspectRatio.width * scale}px` : "100%", width: "100%", }} /> ), "pub.leaflet.blocks.horizontalRule": () => ( <div style={{ borderTop: `1px solid ${colors.border}`, margin: `${16 * scale}px 0`, }} /> ), "pub.leaflet.blocks.website": (b) => text(b.title || b.src, { bold: true }), "pub.leaflet.blocks.button": (b) => ( <div style={{ backgroundColor: theme.accentBackground, borderRadius: 6 * scale, color: theme.accentText, display: "inline-block", fontFamily: theme.bodyFont, fontSize: 16 * scale, fontWeight: "bold", lineHeight: 1.5, margin, padding: `${4 * scale}px ${8 * scale}px`, }} > {b.text} </div> ), "pub.leaflet.blocks.code": () => null, "pub.leaflet.blocks.math": () => null, "pub.leaflet.blocks.iframe": () => null, "pub.leaflet.blocks.html": () => null, "pub.leaflet.blocks.page": () => null, "pub.leaflet.blocks.poll": () => null, "pub.leaflet.blocks.postsList": () => null, "pub.leaflet.blocks.signup": () => null, "pub.leaflet.blocks.bskyPost": () => null, "pub.leaflet.blocks.standardSitePost": () => null, "pub.leaflet.blocks.standardSitePublication": () => null, "pub.leaflet.blocks.imageGallery": () => null, "pub.leaflet.blocks.membersOnlyDelimiter": () => null, }; return matchBlock(block, handlers, () => null);};
const ImageBlock = ({ src, alt, naturalWidth, align = "center",}: { src: string; alt?: string; naturalWidth?: number; align?: "left" | "center" | "right";}) => { // For images smaller than the column, `margin-left/right: auto` shifts // the block within the available space. For images that already fill // the column (large naturals or no natural width), the auto margins // collapse and alignment has no visible effect — which matches the // web, where a column-wide image can't be "right aligned" either. const margin = align === "left" ? `${BLOCK_MARGIN_TOP}px auto ${BLOCK_MARGIN_BOTTOM}px 0` : align === "right" ? `${BLOCK_MARGIN_TOP}px 0 ${BLOCK_MARGIN_BOTTOM}px auto` : IMAGE_MARGIN; return ( <Img src={src} alt={alt ?? ""} style={{ display: "block", height: "auto", margin, maxWidth: naturalWidth ? `${naturalWidth}px` : "100%", width: "100%", }} /> );};
// Defaults mirror components/Blocks/ImageGalleryBlock/shared.ts. Not imported// from there because that module pulls the replicache client into the email// render.const GALLERY_DEFAULT_GAP = 8;const GALLERY_DEFAULT_MAX_WIDTH = 300;// Horizontal card padding (24px per side) — what's left of theme.pageWidth for// block content. Mobile clients tighten the padding via the media query, which// only makes cells slightly wider than assumed.const CARD_HORIZONTAL_PADDING = 48;
const ImageGalleryEmailBlock = ({ block, did, assetsBaseUrl, href, theme, colors,}: { block: PubLeafletBlocksImageGallery.Main; did: string; assetsBaseUrl: string; href: string; theme: EmailTheme; colors: ResolvedColors;}) => { const images = block.images ?? []; if (images.length === 0) return null; const gap = block.gap ?? GALLERY_DEFAULT_GAP; const contentWidth = theme.pageWidth - CARD_HORIZONTAL_PADDING; const srcFor = (image: PubLeafletBlocksImageGallery.Image, width: number) => blobRefToSrc(image.image.ref, did, assetsBaseUrl, { width, format: "email", });
if (block.format === "carousel") { // Email can't page through a carousel, so show the first slide and a // "+N more" indicator; both link to the block on the post page. const first = images[0]; const more = images.length - 1; return ( <Section style={{ margin: BLOCK_MARGIN }}> <Link href={href} style={{ display: "block" }}> <Img src={srcFor(first, 1200)} alt={first.alt ?? ""} style={{ display: "block", height: "auto", margin: "0 auto", maxWidth: `${first.aspectRatio.width}px`, width: "100%", }} /> </Link> {more > 0 ? ( <table role="presentation" align="center" cellPadding={0} cellSpacing={0} border={0} style={{ borderCollapse: "separate", margin: "8px auto 0" }} > <tbody> <tr> <td align="center" style={{ border: `1px solid ${colors.border}`, borderRadius: 999, padding: "4px 12px", }} > <Link href={href} style={{ color: theme.accentBackground, display: "block", fontFamily: theme.bodyFont, fontSize: 14, fontWeight: "bold", lineHeight: "18px", textDecoration: "none", }} > +{more} more image{more === 1 ? "" : "s"} </Link> </td> </tr> </tbody> </table> ) : null} </Section> ); }
if (block.format === "strip") { return ( <Section style={{ margin: BLOCK_MARGIN }}> {images.map((image, i) => ( <Link key={i} href={href} style={{ display: "block" }}> <Img src={srcFor(image, 1200)} alt={image.alt ?? ""} style={{ display: "block", height: "auto", margin: i === 0 ? "0 auto" : `${gap}px auto 0`, maxWidth: `${image.aspectRatio.width}px`, width: "100%", }} /> </Link> ))} </Section> ); }
// Grid (the default format). The web grid cover-crops each row's cells to // the tallest image, which needs object-fit — unreliable in mail clients. // Instead each email row is "justified": every cell's width share is // proportional to its image's aspect ratio, so all images in a row render // at the same height uncropped. Column count mirrors the web's math with // the email's fixed content width standing in for the measured container. const maxWidth = block.maxWidth ?? GALLERY_DEFAULT_MAX_WIDTH; const columns = Math.max( 1, Math.min( Math.ceil((contentWidth - 1 + gap) / (maxWidth + gap)), images.length, ), ); // When fewer images than columns would fit, cap the grid's width so each // image stays near maxWidth rather than stretching — same as the web // renderer. Outlook ignores max-width and renders full-width, the card's // own tradeoff. const gridWidth = Math.min( contentWidth, columns * maxWidth + (columns - 1) * gap, ); const aspect = (image: PubLeafletBlocksImageGallery.Image) => image.aspectRatio.width / image.aspectRatio.height; const avgAspect = images.reduce((sum, i) => sum + aspect(i), 0) / images.length; const rows: PubLeafletBlocksImageGallery.Image[][] = []; for (let i = 0; i < images.length; i += columns) { rows.push(images.slice(i, i + columns)); } const gapPct = (gap / gridWidth) * 100; const imageAreaPct = 100 - gapPct * (columns - 1); return ( <Section style={{ margin: BLOCK_MARGIN }}> {rows.map((row, ri) => { // A short last row keeps roughly the height of a full one: the // missing slots are budgeted at the gallery's average aspect ratio // and left as an empty filler cell. const missing = columns - row.length; const totalAspect = row.reduce((sum, i) => sum + aspect(i), 0) + avgAspect * missing; return ( <table key={ri} role="presentation" align="center" width="100%" cellPadding={0} cellSpacing={0} border={0} style={{ borderCollapse: "collapse", margin: `${ri > 0 ? gap : 0}px auto 0`, maxWidth: gridWidth, tableLayout: "fixed", width: "100%", }} > <tbody> <tr> {row.map((image, ci) => { const pct = (aspect(image) / totalAspect) * imageAreaPct; return ( <React.Fragment key={ci}> {ci > 0 ? ( <td style={{ width: `${gapPct.toFixed(2)}%` }} /> ) : null} <td style={{ width: `${pct.toFixed(2)}%`, verticalAlign: "top", }} > <Link href={href} style={{ display: "block" }}> <Img // 2x the rendered width covers retina density. src={srcFor( image, Math.round((pct / 100) * gridWidth) * 2, )} alt={image.alt ?? ""} style={{ display: "block", height: "auto", width: "100%", }} /> </Link> </td> </React.Fragment> ); })} {missing > 0 ? <td /> : null} </tr> </tbody> </table> ); })} </Section> );};
const ButtonBlock = ({ text, url, align = "center", theme = defaultEmailTheme,}: { text: string; url: string; align?: "left" | "center" | "right"; theme?: EmailTheme;}) => { // Bulletproof button: table-based so Outlook (which ignores padding on // <a>) renders a real clickable button. The `<td>` carries the bgcolor // attribute and padding; the `<a>` is `display: block` so the entire // padded area is clickable. Alignment via the table's `align` HTML // attribute — Gmail won't reliably cascade `text-align` from a wrapping // <Section>, so we anchor on the table itself. return ( <Section style={{ margin: BLOCK_MARGIN, minWidth: "100%" }}> <table role="presentation" align={align} cellPadding={0} cellSpacing={0} border={0} style={{ borderCollapse: "separate" }} > <tbody> <tr> <td align="center" {...bgcolorAttr(theme.accentBackground)} style={{ backgroundColor: theme.accentBackground, borderRadius: 6, padding: "10px 20px", }} > <Link href={url} style={{ color: theme.accentText, display: "block", fontFamily: theme.bodyFont, fontSize: 16, fontWeight: "bold", lineHeight: "20px", textDecoration: "none", }} > {text} </Link> </td> </tr> </tbody> </table> </Section> );};
const CodeBlock = ({ code, language, borderColor,}: { code?: string; language?: string; borderColor?: string;} = {}) => { return ( <ReactEmailCodeBlock style={{ border: `1px solid ${borderColor ?? "rgba(0, 0, 0, 0.1)"}`, borderRadius: 4, boxSizing: "border-box", margin: BLOCK_MARGIN, maxWidth: "100%", // <pre> defaults to `white-space: pre`, so long lines blow past the // card on mobile. Wrap, and break long tokens (URLs, identifiers) // when nothing else fits. overflow: "hidden", padding: 8, whiteSpace: "pre-wrap", wordBreak: "break-word", }} code={code ?? ""} theme={dracula} language={(language as PrismLanguage) || "text"} /> );};
const BlockNotSupported = ({ theme = defaultEmailTheme, colors, postUrl,}: { theme?: EmailTheme; colors?: ResolvedColors; postUrl?: string;} = {}) => { const c = colors ?? resolveColors(theme); return ( <Section style={{ backgroundColor: c.borderLight, borderRadius: 4, margin: BLOCK_MARGIN, padding: "16px 12px", }} > <ReactEmailText style={{ color: c.tertiary, fontFamily: theme.bodyFont, fontSize: 14, fontStyle: "italic", lineHeight: 1.4, margin: 0, textAlign: "center", }} > This media isn't supported in email... </ReactEmailText> <ReactEmailText style={{ fontSize: 14, lineHeight: 1.4, margin: "4px 0 0", textAlign: "center", }} > <Link href={postUrl} style={{ color: theme.accentBackground, fontWeight: "bold", textDecoration: "none", }} > See full post </Link> </ReactEmailText> </Section> );};
// Paywall box for non-member recipients of a gated post, mirroring the site's// MembersOnlyPaywall. Rendered after the truncated blocks.const MembersUpsell = ({ publicationName, joinUrl, cheapestMonthlyCents, theme = defaultEmailTheme, colors,}: { publicationName: string; joinUrl: string; cheapestMonthlyCents?: number | null; theme?: EmailTheme; colors?: ResolvedColors;}) => { const c = colors ?? resolveColors(theme); const priceLabel = cheapestMonthlyCents != null ? (cheapestMonthlyCents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", minimumFractionDigits: cheapestMonthlyCents % 100 === 0 ? 0 : 2, }) : null; return ( <Section style={{ border: `1px solid ${c.border}`, borderRadius: 4, margin: BLOCK_MARGIN, padding: "20px 12px", }} > <ReactEmailText style={{ color: theme.primary, fontFamily: theme.bodyFont, fontSize: 16, fontWeight: "bold", lineHeight: 1.4, margin: 0, textAlign: "center", }} > This post is for members </ReactEmailText> <ReactEmailText style={{ color: c.secondary, fontFamily: theme.bodyFont, fontSize: 14, lineHeight: 1.4, margin: "4px 0 12px", textAlign: "center", }} > Subscribe to see the full content — become a member of {publicationName} {priceLabel ? ` starting at ${priceLabel}/month` : ""}. </ReactEmailText> {/* Bulletproof centered button — same table pattern as ButtonBlock. */} <table role="presentation" align="center" cellPadding={0} cellSpacing={0} border={0} style={{ borderCollapse: "separate", margin: "0 auto" }} > <tbody> <tr> <td align="center" {...bgcolorAttr(theme.accentBackground)} style={{ backgroundColor: theme.accentBackground, borderRadius: 6, padding: "10px 20px", }} > <Link href={joinUrl} style={{ color: theme.accentText, display: "block", fontFamily: theme.bodyFont, fontSize: 16, fontWeight: "bold", lineHeight: "20px", textDecoration: "none", }} > Become a member </Link> </td> </tr> </tbody> </table> </Section> );};
type Facet = PubLeafletRichtextFacet.Main;type FacetFeatures = Exclude<Facet["features"], { $type: string }>;
type RichTextSegment = { text: string; features?: FacetFeatures };
function* segments( plaintext: string, facets: Facet[] | undefined,): Generator<RichTextSegment, void, void> { const text = new UnicodeString(plaintext); const sorted = (facets ?? []) .filter((f) => f.index.byteStart <= f.index.byteEnd) .sort((a, b) => a.index.byteStart - b.index.byteStart); if (!sorted.length) { yield { text: text.utf16 }; return; } let textCursor = 0; let facetCursor = 0; do { const f = sorted[facetCursor]; if (textCursor < f.index.byteStart) { yield { text: text.slice(textCursor, f.index.byteStart) }; } else if (textCursor > f.index.byteStart) { facetCursor++; continue; } if (f.index.byteStart < f.index.byteEnd) { const sub = text.slice(f.index.byteStart, f.index.byteEnd); if (!sub.trim()) yield { text: sub }; else yield { text: sub, features: f.features }; } textCursor = f.index.byteEnd; facetCursor++; } while (facetCursor < sorted.length); if (textCursor < text.length) { yield { text: text.slice(textCursor, text.length) }; }}
const resolveAtMentionHref = ( feature: { atURI: string; href?: string }, assetsBaseUrl: string,): string => { if (feature.href) return feature.href; const path = atUriToUrl(feature.atURI); // atUriToUrl returns either an absolute http(s) URL or a leading-slash // path; absolutize relative paths against assetsBaseUrl so the link works // in mail clients that don't have a base. try { return new URL(path, assetsBaseUrl).toString(); } catch { return path; }};
function highlightFacetBackground( highlight: PubLeafletRichtextFacet.Highlight | undefined,): string | undefined { const color = highlight?.color; if (!color || typeof color !== "object") return undefined; const { r, g, b } = color as { r?: number; g?: number; b?: number }; if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") { return undefined; } const a = (color as { a?: number }).a; return typeof a === "number" ? `rgba(${r}, ${g}, ${b}, ${a / 100})` : `rgb(${r}, ${g}, ${b})`;}
// Mirrors the web's default --highlight-1:// color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)// Gmail strips color-mix(), so we resolve it to a literal rgb() at render time.const defaultHighlightBackground = (theme: EmailTheme): string => mixRgb(theme.accentBackground, theme.pageBackground, 75);
const RichTextSpans = ({ plaintext, facets, theme, assetsBaseUrl,}: { plaintext: string; facets?: PubLeafletRichtextFacet.Main[]; theme: EmailTheme; assetsBaseUrl: string;}) => { const nodes: React.ReactNode[] = []; let i = 0; for (const seg of segments(plaintext, facets)) { const key = `s${i++}`; const features = seg.features; const link = features?.find(PubLeafletRichtextFacet.isLink); const didMention = features?.find(PubLeafletRichtextFacet.isDidMention); const atMention = features?.find(PubLeafletRichtextFacet.isAtMention); const isBold = !!features?.find(PubLeafletRichtextFacet.isBold); const isItalic = !!features?.find(PubLeafletRichtextFacet.isItalic); const isUnderline = !!features?.find(PubLeafletRichtextFacet.isUnderline); const isStrikethrough = !!features?.find( PubLeafletRichtextFacet.isStrikethrough, ); const isCode = !!features?.find(PubLeafletRichtextFacet.isCode); const highlight = features?.find(PubLeafletRichtextFacet.isHighlight); const isHighlight = !!highlight; const highlightBackground = highlightFacetBackground(highlight) ?? defaultHighlightBackground(theme);
const decorations: string[] = []; if (isUnderline) decorations.push("underline"); if (isStrikethrough) decorations.push("line-through");
const baseStyle: CSSProperties = { fontWeight: isBold ? "bold" : undefined, fontStyle: isItalic ? "italic" : undefined, textDecoration: decorations.length ? decorations.join(" ") : undefined, backgroundColor: isCode ? "rgba(0,0,0,0.06)" : isHighlight ? highlightBackground : undefined, fontFamily: isCode ? "monospace" : undefined, padding: isCode ? "1px 4px" : undefined, borderRadius: isCode ? 3 : undefined, };
const content = renderTextWithBreaks(seg.text, key);
if (didMention) { nodes.push( <Link key={key} href={didToBlueskyUrl(didMention.did)} style={{ ...baseStyle, color: theme.accentBackground, textDecoration: baseStyle.textDecoration ?? "none", wordBreak: "break-word", overflowWrap: "break-word", }} > {content} </Link>, ); } else if (atMention) { nodes.push( <Link key={key} href={resolveAtMentionHref(atMention, assetsBaseUrl)} style={{ ...baseStyle, color: theme.accentBackground, textDecoration: baseStyle.textDecoration ?? "none", wordBreak: "break-word", overflowWrap: "break-word", }} > {content} </Link>, ); } else if (link) { nodes.push( <Link key={key} href={link.uri.trim()} style={{ ...baseStyle, color: theme.accentBackground, textDecoration: baseStyle.textDecoration ?? "underline", wordBreak: "break-word", overflowWrap: "break-word", }} > {content} </Link>, ); } else if ( isBold || isItalic || isUnderline || isStrikethrough || isCode || isHighlight ) { nodes.push( <span key={key} style={baseStyle}> {content} </span>, ); } else { nodes.push(<React.Fragment key={key}>{content}</React.Fragment>); } } return <>{nodes}</>;};