about summary refs log tree commit diff
path: root/bskyembed
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-04-13 03:58:40 +0100
committerGitHub <noreply@github.com>2024-04-13 03:58:40 +0100
commit4b3ec5573241b9c71504dfd0bd5f181cbde19a49 (patch)
tree698c2463b389cdf6e14536610e8f96f200ddaaa3 /bskyembed
parent8e29b1f63309ef9ac2da21f62e03b66d477244e9 (diff)
downloadvoidsky-4b3ec5573241b9c71504dfd0bd5f181cbde19a49.tar.zst
[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>
Diffstat (limited to 'bskyembed')
-rw-r--r--bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg1
-rw-r--r--bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg2
-rw-r--r--bskyembed/index.html2
-rw-r--r--bskyembed/package.json1
-rw-r--r--bskyembed/post.html19
-rw-r--r--bskyembed/snippet/embed.ts90
-rw-r--r--bskyembed/src/components/container.tsx55
-rw-r--r--bskyembed/src/components/embed.tsx (renamed from bskyembed/src/embed.tsx)4
-rw-r--r--bskyembed/src/components/link.tsx (renamed from bskyembed/src/link.tsx)0
-rw-r--r--bskyembed/src/components/post.tsx (renamed from bskyembed/src/post.tsx)14
-rw-r--r--bskyembed/src/container.tsx32
-rw-r--r--bskyembed/src/screens/landing.tsx266
-rw-r--r--bskyembed/src/screens/post.tsx (renamed from bskyembed/src/main.tsx)20
-rw-r--r--bskyembed/src/utils.ts5
-rw-r--r--bskyembed/tsconfig.snippet.json10
-rw-r--r--bskyembed/vite.config.ts10
16 files changed, 476 insertions, 55 deletions
diff --git a/bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg b/bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..afb8f245f
--- /dev/null
+++ b/bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg
@@ -0,0 +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>
\ No newline at end of file
diff --git a/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg b/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg
index 36db9a88a..9962a20bd 100644
--- a/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg
+++ b/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg
@@ -1 +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>
+<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>
diff --git a/bskyembed/index.html b/bskyembed/index.html
index aa9335e8c..61d0c7d17 100644
--- a/bskyembed/index.html
+++ b/bskyembed/index.html
@@ -14,6 +14,6 @@
 </head>
 <body>
   <div id="app"></div>
-  <script type="module" src="/src/main.tsx"></script>
+  <script type="module" src="/src/screens/landing.tsx"></script>
 </body>
 </html>
diff --git a/bskyembed/package.json b/bskyembed/package.json
index 6fb919c9e..f610e8c06 100644
--- a/bskyembed/package.json
+++ b/bskyembed/package.json
@@ -5,6 +5,7 @@
   "scripts": {
     "dev": "vite",
     "build": "tsc && vite build",
+    "build-snippet": "tsc --project tsconfig.snippet.json",
     "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
   },
   "dependencies": {
diff --git a/bskyembed/post.html b/bskyembed/post.html
new file mode 100644
index 000000000..5f550495f
--- /dev/null
+++ b/bskyembed/post.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Bluesky Embed</title>
+  <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
+  <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
+  <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
+  <link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
+  <meta name="theme-color">
+  <meta name="application-name" content="Bluesky">
+  <meta name="generator" content="bskyweb">
+</head>
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/screens/post.tsx"></script>
+</body>
+</html>
diff --git a/bskyembed/snippet/embed.ts b/bskyembed/snippet/embed.ts
new file mode 100644
index 000000000..f2b9b442e
--- /dev/null
+++ b/bskyembed/snippet/embed.ts
@@ -0,0 +1,90 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+interface Window {
+  bluesky: {
+    scan: (element?: Pick<Element, 'querySelectorAll'>) => void
+  }
+}
+
+const EMBED_URL = 'https://embed.bsky.app'
+
+window.bluesky = window.bluesky || {
+  scan,
+}
+
+/**
+ * Listen for messages from the Bluesky embed iframe and adjust the height of
+ * the iframe accordingly.
+ */
+window.addEventListener('message', event => {
+  if (event.origin !== EMBED_URL) {
+    return
+  }
+
+  const id = (event.data as {id: string}).id
+  if (!id) {
+    return
+  }
+
+  const embed = document.querySelector<HTMLIFrameElement>(
+    `[data-bluesky-id="${id}"]`,
+  )
+
+  if (!embed) {
+    return
+  }
+
+  const height = (event.data as {height: number}).height
+  if (height) {
+    embed.style.height = `${height}px`
+  }
+})
+
+/**
+ * Scan the document for all elements with the data-bluesky-aturi attribute,
+ * and initialize them as Bluesky embeds.
+ *
+ * @param element Only scan this specific element @default document @optional
+ * @returns
+ */
+function scan(node = document) {
+  const embeds = node.querySelectorAll('[data-bluesky-uri]')
+
+  for (let i = 0; i < embeds.length; i++) {
+    const id = String(Math.random()).slice(2)
+
+    const embed = embeds[i]
+    const aturi = embed.getAttribute('data-bluesky-uri')
+
+    if (!aturi) {
+      continue
+    }
+
+    const iframe = document.createElement('iframe')
+    iframe.setAttribute('data-bluesky-id', id)
+    iframe.src = `${EMBED_URL}/embed/${aturi.slice('at://'.length)}?id=${id}`
+    iframe.width = '100%'
+    iframe.style.border = 'none'
+    iframe.style.display = 'block'
+    iframe.style.flexGrow = '1'
+    iframe.frameBorder = '0'
+    iframe.scrolling = 'no'
+
+    const container = document.createElement('div')
+    container.style.maxWidth = '600px'
+    container.style.width = '100%'
+    container.style.marginTop = '10px'
+    container.style.marginBottom = '10px'
+    container.style.display = 'flex'
+    container.className = 'bluesky-embed'
+
+    container.appendChild(iframe)
+
+    embed.replaceWith(container)
+  }
+}
+
+if (['interactive', 'complete'].indexOf(document.readyState) !== -1) {
+  scan()
+} else {
+  document.addEventListener('DOMContentLoaded', () => scan())
+}
diff --git a/bskyembed/src/components/container.tsx b/bskyembed/src/components/container.tsx
new file mode 100644
index 000000000..a96addc8c
--- /dev/null
+++ b/bskyembed/src/components/container.tsx
@@ -0,0 +1,55 @@
+import {ComponentChildren, h} from 'preact'
+import {useEffect, useRef} from 'preact/hooks'
+
+import {Link} from './link'
+
+export function Container({
+  children,
+  href,
+}: {
+  children: ComponentChildren
+  href: string
+}) {
+  const ref = useRef<HTMLDivElement>(null)
+  const prevHeight = useRef(0)
+
+  useEffect(() => {
+    if (ref.current) {
+      const observer = new ResizeObserver(entries => {
+        const entry = entries[0]
+        if (!entry) return
+
+        let {height} = entry.contentRect
+        height += 2 // border top and bottom
+        if (height !== prevHeight.current) {
+          prevHeight.current = height
+          window.parent.postMessage(
+            {height, id: new URLSearchParams(window.location.search).get('id')},
+            '*',
+          )
+        }
+      })
+      observer.observe(ref.current)
+      return () => observer.disconnect()
+    }
+  }, [])
+
+  return (
+    <div
+      ref={ref}
+      className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
+      onClick={() => {
+        if (ref.current) {
+          // forwardRef requires preact/compat - let's keep it simple
+          // to keep the bundle size down
+          const anchor = ref.current.querySelector('a')
+          if (anchor) {
+            anchor.click()
+          }
+        }
+      }}>
+      <Link href={href} />
+      <div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
+    </div>
+  )
+}
diff --git a/bskyembed/src/embed.tsx b/bskyembed/src/components/embed.tsx
index 0980c5e7f..2f9f6b3cd 100644
--- a/bskyembed/src/embed.tsx
+++ b/bskyembed/src/components/embed.tsx
@@ -10,9 +10,9 @@ import {
 } from '@atproto/api'
 import {ComponentChildren, h} from 'preact'
 
-import infoIcon from '../assets/circleInfo_stroke2_corner0_rounded.svg'
+import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
+import {getRkey} from '../utils'
 import {Link} from './link'
-import {getRkey} from './utils'
 
 export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) {
   if (!content) return null
diff --git a/bskyembed/src/link.tsx b/bskyembed/src/components/link.tsx
index 7226ecf3d..7226ecf3d 100644
--- a/bskyembed/src/link.tsx
+++ b/bskyembed/src/components/link.tsx
diff --git a/bskyembed/src/post.tsx b/bskyembed/src/components/post.tsx
index e10a502d2..dcbf3e336 100644
--- a/bskyembed/src/post.tsx
+++ b/bskyembed/src/components/post.tsx
@@ -1,14 +1,14 @@
 import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
 import {h} from 'preact'
 
-import replyIcon from '../assets/bubble_filled_stroke2_corner2_rounded.svg'
-import likeIcon from '../assets/heart2_filled_stroke2_corner0_rounded.svg'
-import logo from '../assets/logo.svg'
-import repostIcon from '../assets/repost_stroke2_corner2_rounded.svg'
+import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg'
+import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
+import logo from '../../assets/logo.svg'
+import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
+import {getRkey, niceDate} from '../utils'
 import {Container} from './container'
 import {Embed} from './embed'
 import {Link} from './link'
-import {getRkey, niceDate} from './utils'
 
 interface Props {
   thread: AppBskyFeedDefs.ThreadViewPost
@@ -25,7 +25,7 @@ export function Post({thread}: Props) {
   const href = `/profile/${post.author.did}/post/${getRkey(post)}`
   return (
     <Container href={href}>
-      <div className="flex-1 flex-col flex gap-2">
+      <div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
         <div className="flex gap-2.5 items-center">
           <Link href={`/profile/${post.author.did}`} className="rounded-full">
             <img
@@ -143,7 +143,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
   }
 
   return (
-    <p className="text-lg leading-6 break-word break-words whitespace-pre-wrap">
+    <p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
       {richText}
     </p>
   )
diff --git a/bskyembed/src/container.tsx b/bskyembed/src/container.tsx
deleted file mode 100644
index 0d120e1b7..000000000
--- a/bskyembed/src/container.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import {ComponentChildren, h} from 'preact'
-import {useRef} from 'preact/hooks'
-
-import {Link} from './link'
-
-export function Container({
-  children,
-  href,
-}: {
-  children: ComponentChildren
-  href: string
-}) {
-  const ref = useRef<HTMLDivElement>(null)
-  return (
-    <div
-      ref={ref}
-      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"
-      onClick={() => {
-        if (ref.current) {
-          // forwardRef requires preact/compat - let's keep it simple
-          // to keep the bundle size down
-          const anchor = ref.current.querySelector('a')
-          if (anchor) {
-            anchor.click()
-          }
-        }
-      }}>
-      <Link href={href} />
-      {children}
-    </div>
-  )
-}
diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx
new file mode 100644
index 000000000..88e84ffb6
--- /dev/null
+++ b/bskyembed/src/screens/landing.tsx
@@ -0,0 +1,266 @@
+import '../index.css'
+
+import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
+import {Fragment, h, render} from 'preact'
+import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
+
+import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
+import logo from '../../assets/logo.svg'
+import {Container} from '../components/container'
+import {Link} from '../components/link'
+import {Post} from '../components/post'
+import {niceDate} from '../utils'
+
+const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y'
+const DEFAULT_URI =
+  'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
+
+export const EMBED_SERVICE = 'https://embed.bsky.app'
+export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
+
+const root = document.getElementById('app')
+if (!root) throw new Error('No root element')
+
+const agent = new BskyAgent({
+  service: 'https://public.api.bsky.app',
+})
+
+render(<LandingPage />, root)
+
+function LandingPage() {
+  const [uri, setUri] = useState('')
+  const [error, setError] = useState<string | null>(null)
+  const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
+    null,
+  )
+
+  useEffect(() => {
+    void (async () => {
+      setError(null)
+      try {
+        let atUri = DEFAULT_URI
+
+        if (uri) {
+          if (uri.startsWith('at://')) {
+            atUri = uri
+          } else {
+            try {
+              const urlp = new URL(uri)
+              if (!urlp.hostname.endsWith('bsky.app')) {
+                throw new Error('Invalid hostname')
+              }
+              const split = urlp.pathname.slice(1).split('/')
+              if (split.length < 4) {
+                throw new Error('Invalid pathname')
+              }
+              const [profile, didOrHandle, type, rkey] = split
+              if (profile !== 'profile' || type !== 'post') {
+                throw new Error('Invalid profile or type')
+              }
+
+              let did = didOrHandle
+              if (!didOrHandle.startsWith('did:')) {
+                const resolution = await agent.resolveHandle({
+                  handle: didOrHandle,
+                })
+                if (!resolution.data.did) {
+                  throw new Error('No DID found')
+                }
+                did = resolution.data.did
+              }
+
+              atUri = `at://${did}/app.bsky.feed.post/${rkey}`
+            } catch (err) {
+              console.log(err)
+              throw new Error('Invalid Bluesky URL')
+            }
+          }
+        }
+
+        const {data} = await agent.getPostThread({
+          uri: atUri,
+          depth: 0,
+          parentHeight: 0,
+        })
+
+        if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
+          throw new Error('Post not found')
+        }
+
+        setThread(data.thread)
+      } catch (err) {
+        console.error(err)
+        setError(err instanceof Error ? err.message : 'Invalid Bluesky URL')
+      }
+    })()
+  }, [uri])
+
+  return (
+    <main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32">
+      <Link
+        href="https://bsky.social/about"
+        className="transition-transform hover:scale-110">
+        <img src={logo as string} className="h-10" />
+      </Link>
+
+      <h1 className="text-4xl font-bold">Embed a Bluesky Post</h1>
+
+      <div className="w-full max-w-[600px] flex flex-col gap-2">
+        <input
+          type="text"
+          value={uri}
+          onInput={e => setUri(e.currentTarget.value)}
+          className="border rounded-lg py-3 w-full max-w-[600px] px-4"
+          placeholder={DEFAULT_POST}
+        />
+        <p className={`text-red-500 ${error ? '' : 'invisible'}`}>{error}</p>
+      </div>
+
+      <img src={arrowBottom as string} className="w-6" />
+
+      <div className="w-full max-w-[600px] gap-8 flex flex-col">
+        {uri && !error && thread && <Snippet thread={thread} />}
+
+        {thread ? (
+          <Post thread={thread} key={thread.post.uri} />
+        ) : (
+          <Container href="https://bsky.social/about">
+            <Link
+              href="https://bsky.social/about"
+              className="transition-transform hover:scale-110 absolute top-4 right-4">
+              <img src={logo as string} className="h-8" />
+            </Link>
+            <div className="h-32" />
+          </Container>
+        )}
+      </div>
+    </main>
+  )
+}
+
+function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
+  const ref = useRef<HTMLInputElement>(null)
+  const [copied, setCopied] = useState(false)
+
+  // reset copied state after 2 seconds
+  useEffect(() => {
+    if (copied) {
+      const timeout = setTimeout(() => {
+        setCopied(false)
+      }, 2000)
+      return () => clearTimeout(timeout)
+    }
+  }, [copied])
+
+  const snippet = useMemo(() => {
+    const record = thread.post.record
+
+    if (!AppBskyFeedPost.isRecord(record)) {
+      return ''
+    }
+
+    const profileHref = toShareUrl(
+      ['/profile', thread.post.author.did].join('/'),
+    )
+    const urip = new AtUri(thread.post.uri)
+    const href = toShareUrl(
+      ['/profile', thread.post.author.did, 'post', urip.rkey].join('/'),
+    )
+
+    const lang = record.langs ? record.langs[0] : ''
+
+    // 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
+    // DO NOT ADD ANY NEW INTERPOLATIOONS BELOW WITHOUT ESCAPING THEM!
+    // 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
+    return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
+      thread.post.uri,
+    )}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
+      lang,
+    )}">${escapeHtml(record.text)}${
+      record.embed
+        ? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
+        : ''
+    }</p>&mdash; ${escapeHtml(
+      thread.post.author.displayName || thread.post.author.handle,
+    )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
+      thread.post.author.handle,
+    )}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
+      niceDate(thread.post.indexedAt),
+    )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
+  }, [thread])
+
+  return (
+    <div className="flex gap-2 w-full">
+      <input
+        ref={ref}
+        type="text"
+        value={snippet}
+        className="border rounded-lg py-3 w-full px-4"
+        readOnly
+        autoFocus
+      />
+      <button
+        className="rounded-lg bg-brand text-white color-white py-3 px-4 whitespace-nowrap min-w-28"
+        onClick={() => {
+          ref.current?.focus()
+          ref.current?.select()
+          void navigator.clipboard.writeText(snippet)
+          setCopied(true)
+        }}>
+        {copied ? 'Copied!' : 'Copy code'}
+      </button>
+    </div>
+  )
+}
+
+function toShareUrl(path: string) {
+  return `https://bsky.app${path}`
+}
+
+/**
+ * Based on a snippet of code from React, which itself was based on the escape-html library.
+ * Copyright (c) Meta Platforms, Inc. and affiliates
+ * Copyright (c) 2012-2013 TJ Holowaychuk
+ * Copyright (c) 2015 Andreas Lubbe
+ * Copyright (c) 2015 Tiancheng "Timothy" Gu
+ * Licensed as MIT.
+ */
+const matchHtmlRegExp = /["'&<>]/
+function escapeHtml(string: string) {
+  const str = String(string)
+  const match = matchHtmlRegExp.exec(str)
+  if (!match) {
+    return str
+  }
+  let escape
+  let html = ''
+  let index
+  let lastIndex = 0
+  for (index = match.index; index < str.length; index++) {
+    switch (str.charCodeAt(index)) {
+      case 34: // "
+        escape = '&quot;'
+        break
+      case 38: // &
+        escape = '&amp;'
+        break
+      case 39: // '
+        escape = '&#x27;'
+        break
+      case 60: // <
+        escape = '&lt;'
+        break
+      case 62: // >
+        escape = '&gt;'
+        break
+      default:
+        continue
+    }
+    if (lastIndex !== index) {
+      html += str.slice(lastIndex, index)
+    }
+    lastIndex = index + 1
+    html += escape
+  }
+  return lastIndex !== index ? html + str.slice(lastIndex, index) : html
+}
diff --git a/bskyembed/src/main.tsx b/bskyembed/src/screens/post.tsx
index 895675434..76c921540 100644
--- a/bskyembed/src/main.tsx
+++ b/bskyembed/src/screens/post.tsx
@@ -1,27 +1,27 @@
-import './index.css'
+import '../index.css'
 
 import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
 import {h, render} from 'preact'
 
-import logo from '../assets/logo.svg'
-import {Container} from './container'
-import {Link} from './link'
-import {Post} from './post'
-import {getRkey} from './utils'
+import logo from '../../assets/logo.svg'
+import {Container} from '../components/container'
+import {Link} from '../components/link'
+import {Post} from '../components/post'
+import {getRkey} from '../utils'
 
 const root = document.getElementById('app')
 if (!root) throw new Error('No root element')
 
-const searchParams = new URLSearchParams(window.location.search)
-
 const agent = new BskyAgent({
   service: 'https://public.api.bsky.app',
 })
 
-const uri = searchParams.get('uri')
+const uri = `at://${window.location.pathname.slice('/embed/'.length)}`
+
+console.log(uri)
 
 if (!uri) {
-  throw new Error('No uri in query string')
+  throw new Error('No uri in path')
 }
 
 agent
diff --git a/bskyembed/src/utils.ts b/bskyembed/src/utils.ts
index 3408fcd97..1f6fd5061 100644
--- a/bskyembed/src/utils.ts
+++ b/bskyembed/src/utils.ts
@@ -1,3 +1,5 @@
+import {AtUri} from '@atproto/api'
+
 export function niceDate(date: number | string | Date) {
   const d = new Date(date)
   return `${d.toLocaleDateString('en-us', {
@@ -11,5 +13,6 @@ export function niceDate(date: number | string | Date) {
 }
 
 export function getRkey({uri}: {uri: string}): string {
-  return uri.split('/').pop() as string
+  const at = new AtUri(uri)
+  return at.rkey
 }
diff --git a/bskyembed/tsconfig.snippet.json b/bskyembed/tsconfig.snippet.json
new file mode 100644
index 000000000..a6b6071dd
--- /dev/null
+++ b/bskyembed/tsconfig.snippet.json
@@ -0,0 +1,10 @@
+
+{
+  "compilerOptions": {
+    "target": "ES5",
+    "lib": ["DOM", "DOM.Iterable", "ESNext"],
+    "strict": true,
+    "outDir": "dist"
+  },
+  "include": ["snippet"],
+}
diff --git a/bskyembed/vite.config.ts b/bskyembed/vite.config.ts
index 8d0b92071..9acc9d5ee 100644
--- a/bskyembed/vite.config.ts
+++ b/bskyembed/vite.config.ts
@@ -1,3 +1,5 @@
+import {resolve} from 'node:path'
+
 import preact from '@preact/preset-vite'
 import legacy from '@vitejs/plugin-legacy'
 import type {UserConfig} from 'vite'
@@ -12,7 +14,13 @@ const config: UserConfig = {
     }),
   ],
   build: {
-    assetsDir: 'static/embed/assets',
+    assetsDir: 'static',
+    rollupOptions: {
+      input: {
+        index: resolve(__dirname, 'index.html'),
+        post: resolve(__dirname, 'post.html'),
+      },
+    },
   },
 }