deer social fork for personal usage. but you might see a use idk. github mirror

[Embeds] Embed subdomain landing page (#3501)

* add build output to web build

* simplify post-build step by copying everything at once

* make script that converts placeholder -> iframe

* dynamically resize iframe based on inner content

Requires the iframe content to `postMessage` its height back up to the parent

* add lang to embed

* svg explicit height -> viewBox

* add build output to web build

* simplify post-build step by copying everything at once

* attempt to fix go embed issue

* rm changes to bskyweb

* remove another bskyweb change

* embed landing page

* Drop xl breakpoint, too far down

* Remove pointer enter behavior

* Avoid button width jump

* Escape HTML

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by samuel.fm Dan Abramov and committed by GitHub 4b3ec557 8e29b1f6

+1 -1
assets/icons/bubble_filled_stroke2_corner2_rounded.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>
+1 -1
assets/icons/peopleRemove2_stroke2_corner0_rounded.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
+1
bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>
+1 -1
bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="rgb(10,122,255)" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(10,122,255)" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>
+1 -1
bskyembed/index.html
··· 14 14 </head> 15 15 <body> 16 16 <div id="app"></div> 17 - <script type="module" src="/src/main.tsx"></script> 17 + <script type="module" src="/src/screens/landing.tsx"></script> 18 18 </body> 19 19 </html>
+1
bskyembed/package.json
··· 5 5 "scripts": { 6 6 "dev": "vite", 7 7 "build": "tsc && vite build", 8 + "build-snippet": "tsc --project tsconfig.snippet.json", 8 9 "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src" 9 10 }, 10 11 "dependencies": {
+19
bskyembed/post.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Bluesky Embed</title> 7 + <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> 8 + <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> 9 + <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> 10 + <link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe"> 11 + <meta name="theme-color"> 12 + <meta name="application-name" content="Bluesky"> 13 + <meta name="generator" content="bskyweb"> 14 + </head> 15 + <body> 16 + <div id="app"></div> 17 + <script type="module" src="/src/screens/post.tsx"></script> 18 + </body> 19 + </html>
+90
bskyembed/snippet/embed.ts
··· 1 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 + interface Window { 3 + bluesky: { 4 + scan: (element?: Pick<Element, 'querySelectorAll'>) => void 5 + } 6 + } 7 + 8 + const EMBED_URL = 'https://embed.bsky.app' 9 + 10 + window.bluesky = window.bluesky || { 11 + scan, 12 + } 13 + 14 + /** 15 + * Listen for messages from the Bluesky embed iframe and adjust the height of 16 + * the iframe accordingly. 17 + */ 18 + window.addEventListener('message', event => { 19 + if (event.origin !== EMBED_URL) { 20 + return 21 + } 22 + 23 + const id = (event.data as {id: string}).id 24 + if (!id) { 25 + return 26 + } 27 + 28 + const embed = document.querySelector<HTMLIFrameElement>( 29 + `[data-bluesky-id="${id}"]`, 30 + ) 31 + 32 + if (!embed) { 33 + return 34 + } 35 + 36 + const height = (event.data as {height: number}).height 37 + if (height) { 38 + embed.style.height = `${height}px` 39 + } 40 + }) 41 + 42 + /** 43 + * Scan the document for all elements with the data-bluesky-aturi attribute, 44 + * and initialize them as Bluesky embeds. 45 + * 46 + * @param element Only scan this specific element @default document @optional 47 + * @returns 48 + */ 49 + function scan(node = document) { 50 + const embeds = node.querySelectorAll('[data-bluesky-uri]') 51 + 52 + for (let i = 0; i < embeds.length; i++) { 53 + const id = String(Math.random()).slice(2) 54 + 55 + const embed = embeds[i] 56 + const aturi = embed.getAttribute('data-bluesky-uri') 57 + 58 + if (!aturi) { 59 + continue 60 + } 61 + 62 + const iframe = document.createElement('iframe') 63 + iframe.setAttribute('data-bluesky-id', id) 64 + iframe.src = `${EMBED_URL}/embed/${aturi.slice('at://'.length)}?id=${id}` 65 + iframe.width = '100%' 66 + iframe.style.border = 'none' 67 + iframe.style.display = 'block' 68 + iframe.style.flexGrow = '1' 69 + iframe.frameBorder = '0' 70 + iframe.scrolling = 'no' 71 + 72 + const container = document.createElement('div') 73 + container.style.maxWidth = '600px' 74 + container.style.width = '100%' 75 + container.style.marginTop = '10px' 76 + container.style.marginBottom = '10px' 77 + container.style.display = 'flex' 78 + container.className = 'bluesky-embed' 79 + 80 + container.appendChild(iframe) 81 + 82 + embed.replaceWith(container) 83 + } 84 + } 85 + 86 + if (['interactive', 'complete'].indexOf(document.readyState) !== -1) { 87 + scan() 88 + } else { 89 + document.addEventListener('DOMContentLoaded', () => scan()) 90 + }
+55
bskyembed/src/components/container.tsx
··· 1 + import {ComponentChildren, h} from 'preact' 2 + import {useEffect, useRef} from 'preact/hooks' 3 + 4 + import {Link} from './link' 5 + 6 + export function Container({ 7 + children, 8 + href, 9 + }: { 10 + children: ComponentChildren 11 + href: string 12 + }) { 13 + const ref = useRef<HTMLDivElement>(null) 14 + const prevHeight = useRef(0) 15 + 16 + useEffect(() => { 17 + if (ref.current) { 18 + const observer = new ResizeObserver(entries => { 19 + const entry = entries[0] 20 + if (!entry) return 21 + 22 + let {height} = entry.contentRect 23 + height += 2 // border top and bottom 24 + if (height !== prevHeight.current) { 25 + prevHeight.current = height 26 + window.parent.postMessage( 27 + {height, id: new URLSearchParams(window.location.search).get('id')}, 28 + '*', 29 + ) 30 + } 31 + }) 32 + observer.observe(ref.current) 33 + return () => observer.disconnect() 34 + } 35 + }, []) 36 + 37 + return ( 38 + <div 39 + ref={ref} 40 + className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl" 41 + onClick={() => { 42 + if (ref.current) { 43 + // forwardRef requires preact/compat - let's keep it simple 44 + // to keep the bundle size down 45 + const anchor = ref.current.querySelector('a') 46 + if (anchor) { 47 + anchor.click() 48 + } 49 + } 50 + }}> 51 + <Link href={href} /> 52 + <div className="flex-1 px-4 pt-3 pb-2.5">{children}</div> 53 + </div> 54 + ) 55 + }
-32
bskyembed/src/container.tsx
··· 1 - import {ComponentChildren, h} from 'preact' 2 - import {useRef} from 'preact/hooks' 3 - 4 - import {Link} from './link' 5 - 6 - export function Container({ 7 - children, 8 - href, 9 - }: { 10 - children: ComponentChildren 11 - href: string 12 - }) { 13 - const ref = useRef<HTMLDivElement>(null) 14 - return ( 15 - <div 16 - ref={ref} 17 - className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[550px] min-w-[300px] flex border rounded-xl px-4 pt-3 pb-2.5" 18 - onClick={() => { 19 - if (ref.current) { 20 - // forwardRef requires preact/compat - let's keep it simple 21 - // to keep the bundle size down 22 - const anchor = ref.current.querySelector('a') 23 - if (anchor) { 24 - anchor.click() 25 - } 26 - } 27 - }}> 28 - <Link href={href} /> 29 - {children} 30 - </div> 31 - ) 32 - }
+2 -2
bskyembed/src/embed.tsx bskyembed/src/components/embed.tsx
··· 10 10 } from '@atproto/api' 11 11 import {ComponentChildren, h} from 'preact' 12 12 13 - import infoIcon from '../assets/circleInfo_stroke2_corner0_rounded.svg' 13 + import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' 14 + import {getRkey} from '../utils' 14 15 import {Link} from './link' 15 - import {getRkey} from './utils' 16 16 17 17 export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) { 18 18 if (!content) return null
bskyembed/src/link.tsx bskyembed/src/components/link.tsx
+10 -10
bskyembed/src/main.tsx bskyembed/src/screens/post.tsx
··· 1 - import './index.css' 1 + import '../index.css' 2 2 3 3 import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' 4 4 import {h, render} from 'preact' 5 5 6 - import logo from '../assets/logo.svg' 7 - import {Container} from './container' 8 - import {Link} from './link' 9 - import {Post} from './post' 10 - import {getRkey} from './utils' 6 + import logo from '../../assets/logo.svg' 7 + import {Container} from '../components/container' 8 + import {Link} from '../components/link' 9 + import {Post} from '../components/post' 10 + import {getRkey} from '../utils' 11 11 12 12 const root = document.getElementById('app') 13 13 if (!root) throw new Error('No root element') 14 14 15 - const searchParams = new URLSearchParams(window.location.search) 16 - 17 15 const agent = new BskyAgent({ 18 16 service: 'https://public.api.bsky.app', 19 17 }) 20 18 21 - const uri = searchParams.get('uri') 19 + const uri = `at://${window.location.pathname.slice('/embed/'.length)}` 20 + 21 + console.log(uri) 22 22 23 23 if (!uri) { 24 - throw new Error('No uri in query string') 24 + throw new Error('No uri in path') 25 25 } 26 26 27 27 agent
+7 -7
bskyembed/src/post.tsx bskyembed/src/components/post.tsx
··· 1 1 import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api' 2 2 import {h} from 'preact' 3 3 4 - import replyIcon from '../assets/bubble_filled_stroke2_corner2_rounded.svg' 5 - import likeIcon from '../assets/heart2_filled_stroke2_corner0_rounded.svg' 6 - import logo from '../assets/logo.svg' 7 - import repostIcon from '../assets/repost_stroke2_corner2_rounded.svg' 4 + import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg' 5 + import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg' 6 + import logo from '../../assets/logo.svg' 7 + import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg' 8 + import {getRkey, niceDate} from '../utils' 8 9 import {Container} from './container' 9 10 import {Embed} from './embed' 10 11 import {Link} from './link' 11 - import {getRkey, niceDate} from './utils' 12 12 13 13 interface Props { 14 14 thread: AppBskyFeedDefs.ThreadViewPost ··· 25 25 const href = `/profile/${post.author.did}/post/${getRkey(post)}` 26 26 return ( 27 27 <Container href={href}> 28 - <div className="flex-1 flex-col flex gap-2"> 28 + <div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}> 29 29 <div className="flex gap-2.5 items-center"> 30 30 <Link href={`/profile/${post.author.did}`} className="rounded-full"> 31 31 <img ··· 143 143 } 144 144 145 145 return ( 146 - <p className="text-lg leading-6 break-word break-words whitespace-pre-wrap"> 146 + <p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap"> 147 147 {richText} 148 148 </p> 149 149 )
+266
bskyembed/src/screens/landing.tsx
··· 1 + import '../index.css' 2 + 3 + import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api' 4 + import {Fragment, h, render} from 'preact' 5 + import {useEffect, useMemo, useRef, useState} from 'preact/hooks' 6 + 7 + import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg' 8 + import logo from '../../assets/logo.svg' 9 + import {Container} from '../components/container' 10 + import {Link} from '../components/link' 11 + import {Post} from '../components/post' 12 + import {niceDate} from '../utils' 13 + 14 + const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y' 15 + const DEFAULT_URI = 16 + 'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y' 17 + 18 + export const EMBED_SERVICE = 'https://embed.bsky.app' 19 + export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` 20 + 21 + const root = document.getElementById('app') 22 + if (!root) throw new Error('No root element') 23 + 24 + const agent = new BskyAgent({ 25 + service: 'https://public.api.bsky.app', 26 + }) 27 + 28 + render(<LandingPage />, root) 29 + 30 + function LandingPage() { 31 + const [uri, setUri] = useState('') 32 + const [error, setError] = useState<string | null>(null) 33 + const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>( 34 + null, 35 + ) 36 + 37 + useEffect(() => { 38 + void (async () => { 39 + setError(null) 40 + try { 41 + let atUri = DEFAULT_URI 42 + 43 + if (uri) { 44 + if (uri.startsWith('at://')) { 45 + atUri = uri 46 + } else { 47 + try { 48 + const urlp = new URL(uri) 49 + if (!urlp.hostname.endsWith('bsky.app')) { 50 + throw new Error('Invalid hostname') 51 + } 52 + const split = urlp.pathname.slice(1).split('/') 53 + if (split.length < 4) { 54 + throw new Error('Invalid pathname') 55 + } 56 + const [profile, didOrHandle, type, rkey] = split 57 + if (profile !== 'profile' || type !== 'post') { 58 + throw new Error('Invalid profile or type') 59 + } 60 + 61 + let did = didOrHandle 62 + if (!didOrHandle.startsWith('did:')) { 63 + const resolution = await agent.resolveHandle({ 64 + handle: didOrHandle, 65 + }) 66 + if (!resolution.data.did) { 67 + throw new Error('No DID found') 68 + } 69 + did = resolution.data.did 70 + } 71 + 72 + atUri = `at://${did}/app.bsky.feed.post/${rkey}` 73 + } catch (err) { 74 + console.log(err) 75 + throw new Error('Invalid Bluesky URL') 76 + } 77 + } 78 + } 79 + 80 + const {data} = await agent.getPostThread({ 81 + uri: atUri, 82 + depth: 0, 83 + parentHeight: 0, 84 + }) 85 + 86 + if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { 87 + throw new Error('Post not found') 88 + } 89 + 90 + setThread(data.thread) 91 + } catch (err) { 92 + console.error(err) 93 + setError(err instanceof Error ? err.message : 'Invalid Bluesky URL') 94 + } 95 + })() 96 + }, [uri]) 97 + 98 + return ( 99 + <main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32"> 100 + <Link 101 + href="https://bsky.social/about" 102 + className="transition-transform hover:scale-110"> 103 + <img src={logo as string} className="h-10" /> 104 + </Link> 105 + 106 + <h1 className="text-4xl font-bold">Embed a Bluesky Post</h1> 107 + 108 + <div className="w-full max-w-[600px] flex flex-col gap-2"> 109 + <input 110 + type="text" 111 + value={uri} 112 + onInput={e => setUri(e.currentTarget.value)} 113 + className="border rounded-lg py-3 w-full max-w-[600px] px-4" 114 + placeholder={DEFAULT_POST} 115 + /> 116 + <p className={`text-red-500 ${error ? '' : 'invisible'}`}>{error}</p> 117 + </div> 118 + 119 + <img src={arrowBottom as string} className="w-6" /> 120 + 121 + <div className="w-full max-w-[600px] gap-8 flex flex-col"> 122 + {uri && !error && thread && <Snippet thread={thread} />} 123 + 124 + {thread ? ( 125 + <Post thread={thread} key={thread.post.uri} /> 126 + ) : ( 127 + <Container href="https://bsky.social/about"> 128 + <Link 129 + href="https://bsky.social/about" 130 + className="transition-transform hover:scale-110 absolute top-4 right-4"> 131 + <img src={logo as string} className="h-8" /> 132 + </Link> 133 + <div className="h-32" /> 134 + </Container> 135 + )} 136 + </div> 137 + </main> 138 + ) 139 + } 140 + 141 + function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) { 142 + const ref = useRef<HTMLInputElement>(null) 143 + const [copied, setCopied] = useState(false) 144 + 145 + // reset copied state after 2 seconds 146 + useEffect(() => { 147 + if (copied) { 148 + const timeout = setTimeout(() => { 149 + setCopied(false) 150 + }, 2000) 151 + return () => clearTimeout(timeout) 152 + } 153 + }, [copied]) 154 + 155 + const snippet = useMemo(() => { 156 + const record = thread.post.record 157 + 158 + if (!AppBskyFeedPost.isRecord(record)) { 159 + return '' 160 + } 161 + 162 + const profileHref = toShareUrl( 163 + ['/profile', thread.post.author.did].join('/'), 164 + ) 165 + const urip = new AtUri(thread.post.uri) 166 + const href = toShareUrl( 167 + ['/profile', thread.post.author.did, 'post', urip.rkey].join('/'), 168 + ) 169 + 170 + const lang = record.langs ? record.langs[0] : '' 171 + 172 + // x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x 173 + // DO NOT ADD ANY NEW INTERPOLATIOONS BELOW WITHOUT ESCAPING THEM! 174 + // x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x 175 + return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml( 176 + thread.post.uri, 177 + )}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml( 178 + lang, 179 + )}">${escapeHtml(record.text)}${ 180 + record.embed 181 + ? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>` 182 + : '' 183 + }</p>&mdash; ${escapeHtml( 184 + thread.post.author.displayName || thread.post.author.handle, 185 + )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml( 186 + thread.post.author.handle, 187 + )}</a>) <a href="${escapeHtml(href)}">${escapeHtml( 188 + niceDate(thread.post.indexedAt), 189 + )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` 190 + }, [thread]) 191 + 192 + return ( 193 + <div className="flex gap-2 w-full"> 194 + <input 195 + ref={ref} 196 + type="text" 197 + value={snippet} 198 + className="border rounded-lg py-3 w-full px-4" 199 + readOnly 200 + autoFocus 201 + /> 202 + <button 203 + className="rounded-lg bg-brand text-white color-white py-3 px-4 whitespace-nowrap min-w-28" 204 + onClick={() => { 205 + ref.current?.focus() 206 + ref.current?.select() 207 + void navigator.clipboard.writeText(snippet) 208 + setCopied(true) 209 + }}> 210 + {copied ? 'Copied!' : 'Copy code'} 211 + </button> 212 + </div> 213 + ) 214 + } 215 + 216 + function toShareUrl(path: string) { 217 + return `https://bsky.app${path}` 218 + } 219 + 220 + /** 221 + * Based on a snippet of code from React, which itself was based on the escape-html library. 222 + * Copyright (c) Meta Platforms, Inc. and affiliates 223 + * Copyright (c) 2012-2013 TJ Holowaychuk 224 + * Copyright (c) 2015 Andreas Lubbe 225 + * Copyright (c) 2015 Tiancheng "Timothy" Gu 226 + * Licensed as MIT. 227 + */ 228 + const matchHtmlRegExp = /["'&<>]/ 229 + function escapeHtml(string: string) { 230 + const str = String(string) 231 + const match = matchHtmlRegExp.exec(str) 232 + if (!match) { 233 + return str 234 + } 235 + let escape 236 + let html = '' 237 + let index 238 + let lastIndex = 0 239 + for (index = match.index; index < str.length; index++) { 240 + switch (str.charCodeAt(index)) { 241 + case 34: // " 242 + escape = '&quot;' 243 + break 244 + case 38: // & 245 + escape = '&amp;' 246 + break 247 + case 39: // ' 248 + escape = '&#x27;' 249 + break 250 + case 60: // < 251 + escape = '&lt;' 252 + break 253 + case 62: // > 254 + escape = '&gt;' 255 + break 256 + default: 257 + continue 258 + } 259 + if (lastIndex !== index) { 260 + html += str.slice(lastIndex, index) 261 + } 262 + lastIndex = index + 1 263 + html += escape 264 + } 265 + return lastIndex !== index ? html + str.slice(lastIndex, index) : html 266 + }
+4 -1
bskyembed/src/utils.ts
··· 1 + import {AtUri} from '@atproto/api' 2 + 1 3 export function niceDate(date: number | string | Date) { 2 4 const d = new Date(date) 3 5 return `${d.toLocaleDateString('en-us', { ··· 11 13 } 12 14 13 15 export function getRkey({uri}: {uri: string}): string { 14 - return uri.split('/').pop() as string 16 + const at = new AtUri(uri) 17 + return at.rkey 15 18 }
+10
bskyembed/tsconfig.snippet.json
··· 1 + 2 + { 3 + "compilerOptions": { 4 + "target": "ES5", 5 + "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 + "strict": true, 7 + "outDir": "dist" 8 + }, 9 + "include": ["snippet"], 10 + }
+9 -1
bskyembed/vite.config.ts
··· 1 + import {resolve} from 'node:path' 2 + 1 3 import preact from '@preact/preset-vite' 2 4 import legacy from '@vitejs/plugin-legacy' 3 5 import type {UserConfig} from 'vite' ··· 12 14 }), 13 15 ], 14 16 build: { 15 - assetsDir: 'static/embed/assets', 17 + assetsDir: 'static', 18 + rollupOptions: { 19 + input: { 20 + index: resolve(__dirname, 'index.html'), 21 + post: resolve(__dirname, 'post.html'), 22 + }, 23 + }, 16 24 }, 17 25 } 18 26
+4
bskyweb/.gitignore
··· 9 9 static/js/*.map 10 10 static/js/*.js.LICENSE.txt 11 11 templates/scripts.html 12 + templates/*-embed.html 13 + static/embed/*.html 14 + static/embed/assets/*.js 15 + static/embed/assets/*.css 12 16 13 17 # Don't ignore this file 14 18 !.gitignore