├── .changeset ├── README.md ├── config.json └── six-rabbits-admire.md ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── mise.toml ├── package.json ├── packages ├── bluesky-post-embed │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── lib │ │ ├── bluesky-post.svelte │ │ ├── core.ts │ │ └── wc.ts │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ ├── main.tsx │ │ └── styles │ │ │ ├── main.css │ │ │ └── normalize.css │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── bluesky-profile-card-embed │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── lib │ │ ├── bluesky-profile-card.svelte │ │ ├── core.ts │ │ └── wc.ts │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ ├── main.tsx │ │ └── styles │ │ │ ├── main.css │ │ │ └── normalize.css │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── bluesky-profile-feed-embed │ ├── .gitignore │ ├── README.md │ ├── lib │ │ ├── bluesky-profile-feed.svelte │ │ ├── core.ts │ │ └── wc.ts │ ├── package.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── internal │ ├── components │ │ ├── content-hider.svelte │ │ ├── embed-frame.svelte │ │ ├── embeds │ │ │ ├── embeds.svelte │ │ │ ├── external-embed.svelte │ │ │ ├── feed-embed.svelte │ │ │ ├── image-embed.svelte │ │ │ ├── list-embed.svelte │ │ │ ├── quote-embed.svelte │ │ │ ├── starterpack-embed.svelte │ │ │ └── video-embed.svelte │ │ ├── feed-post.svelte │ │ ├── highlighted-post.svelte │ │ ├── post.svelte │ │ ├── profile-card.svelte │ │ ├── profile-feed-header.svelte │ │ ├── richtext-raw-renderer.svelte │ │ └── richtext-renderer.svelte │ ├── package.json │ ├── tsconfig.json │ ├── types │ │ ├── post.ts │ │ ├── profile-card.ts │ │ └── profile-feed.ts │ └── utils │ │ ├── bsky-url.ts │ │ ├── constants.ts │ │ ├── date.ts │ │ ├── labels.ts │ │ ├── number.ts │ │ └── syntax │ │ └── at-url.ts └── svelte-site │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── scripts │ └── publish.sh │ ├── src │ ├── App.svelte │ ├── components │ │ ├── Banner.svelte │ │ ├── CircularSpinner.svelte │ │ ├── CodeBlock.svelte │ │ ├── Field.svelte │ │ ├── Lazy.svelte │ │ ├── TextInput.svelte │ │ ├── display │ │ │ ├── PostDisplay.svelte │ │ │ ├── ProfileCardDisplay.svelte │ │ │ └── ProfileFeedDisplay.svelte │ │ ├── embeds │ │ │ ├── BlueskyPost.svelte │ │ │ ├── BlueskyProfileCard.svelte │ │ │ └── BlueskyProfileFeed.svelte │ │ └── guides │ │ │ ├── Guide.svelte │ │ │ └── GuideInstructions.svelte │ ├── lib │ │ ├── component.ts │ │ ├── html.ts │ │ ├── matcher.ts │ │ └── strings.ts │ ├── main.ts │ ├── styles │ │ └── normalize.css │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── patches └── svelte.patch ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── themes ├── dark.css ├── dim.css └── light.css /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "trunk", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["svelte-site"] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/six-rabbits-admire.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'bluesky-profile-card-embed': patch 3 | 'bluesky-profile-feed-embed': patch 4 | 'bluesky-post-embed': patch 5 | 'internal': patch 6 | --- 7 | 8 | upgrade atcute dependencies 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | 4 | .npm-*.log 5 | .pnpm-*.log 6 | .yarn-*.log 7 | npm-*.log 8 | pnpm-*.log 9 | yarn-*.log 10 | 11 | *.local 12 | 13 | *.tsbuildinfo 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | 3 | dist/ 4 | 5 | /packages/svelte-site/pages/ 6 | /packages/bluesky-post-embed/themes/ 7 | /packages/bluesky-profile-card-embed/themes/ 8 | /packages/bluesky-profile-feed-embed/themes/ 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "tabWidth": 2, 5 | "printWidth": 110, 6 | "semi": true, 7 | "singleQuote": true, 8 | "bracketSpacing": true, 9 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-css-order"], 10 | "overrides": [ 11 | { 12 | "files": ["tsconfig.json", "jsconfig.json", "tsconfig.*.json"], 13 | "options": { 14 | "parser": "jsonc" 15 | } 16 | }, 17 | { 18 | "files": ["*.md"], 19 | "options": { 20 | "printWidth": 100, 21 | "proseWrap": "always" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["svelte.svelte-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <bluesky-embed> 2 | 3 | A custom element for embedding Bluesky posts. 4 | 5 | - **Lightweight**, the entire package + dependencies is only ~20 KB (~6 KB gzipped) 6 | - **Standalone**, no middleman involved, directly calls Bluesky's API 7 | - **Server-side rendering possible**, allows for no-JavaScript usage 8 | 9 | | Packages | 10 | | ------------------------------------------------------------------------------------------------ | 11 | | [`bluesky-post-embed`](./packages/bluesky-post-embed): displays a post embed | 12 | | [`bluesky-profile-card-embed`](./packages/bluesky-profile-card-embed): displays a user's profile | 13 | | [`bluesky-profile-feed-embed`](./packages/bluesky-profile-feed-embed): displays a user's feed | 14 | 15 | ![image](https://github.com/user-attachments/assets/fbe19e25-bcc8-4fd5-ac9d-568badbbb238) 16 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "latest" 3 | pnpm = "latest" 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "fmt": "prettier --cache --write ." 5 | }, 6 | "devDependencies": { 7 | "@changesets/cli": "^2.28.1", 8 | "prettier": "^3.5.3", 9 | "prettier-plugin-css-order": "^2.1.2", 10 | "prettier-plugin-svelte": "^3.3.3", 11 | "typescript": "~5.8.3" 12 | }, 13 | "pnpm": { 14 | "patchedDependencies": { 15 | "svelte": "patches/svelte.patch" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/.gitignore: -------------------------------------------------------------------------------- 1 | themes/ 2 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/README.md: -------------------------------------------------------------------------------- 1 | # <bluesky-post-embed> 2 | 3 | A custom element for embedding Bluesky posts. 4 | 5 | ## Installation 6 | 7 | ### via npm 8 | 9 | ``` 10 | npm install bluesky-post-embed 11 | ``` 12 | 13 | then, import the package on your app. 14 | 15 | ```js 16 | import 'bluesky-post-embed'; 17 | 18 | import 'bluesky-post-embed/style.css'; 19 | import 'bluesky-post-embed/themes/light.css'; 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```html 25 | 26 |
27 |

angel mode

28 | — Paul Frazee (@pfrazee.com) 29 | January 16, 2024 at 9:11 AM 32 |
33 |
34 | ``` 35 | 36 | ### Attributes 37 | 38 | - `src` **Required** 39 | AT-URI of the post record 40 | - `contextless` **Optional** 41 | Whether to show the post without any context (no parent reply) 42 | - `allow-unauthenticated` **Optional** 43 | Whether to allow unauthenticated viewing 44 | - `service-uri` **Optional** 45 | URL to an AppView service, defaults to `https://public.api.bsky.app` 46 | 47 | ### Events 48 | 49 | - `loaded` 50 | Fired when the embed has successfully loaded the post 51 | - `error` 52 | Fired when the embed fails to load the post 53 | 54 | ## SSR usage 55 | 56 | The embeds are powered by a static HTML renderer, this renderer can be used directly in your 57 | server-rendering framework of choice for a zero-JS experience. 58 | 59 | ```tsx 60 | import { fetchPost, renderPost } from 'bluesky-post-embed/core'; 61 | 62 | import 'bluesky-post-embed/style.css'; 63 | import 'bluesky-post-embed/themes/light.css'; 64 | 65 | // fetch the post 66 | const controller = new AbortController(); 67 | const data = await fetchPost({ 68 | src: `at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3kj2umze7zj2n`, 69 | signal: controller.signal, 70 | }); 71 | 72 | // render the post 73 | const html = renderPost(data); 74 | return ( 75 | 79 | ); 80 | ``` 81 | 82 | Check out examples for [Astro](https://github.com/mary-ext/bluesky-embed-astro) and 83 | [SvelteKit](https://github.com/mary-ext/bluesky-embed-sveltekit). 84 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bluesky post embed 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/lib/bluesky-post.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 | 60 | 61 | {#if thread === null} 62 | {@render Message(`The post can't be found, it may have been deleted.`)} 63 | {:else if isPwiForbidden} 64 | {@render Message(`The author has requested for their posts to not be displayed on external sites.`)} 65 | {:else} 66 | {@const posts = unwrapPostThread(thread, contextless, allowUnauthenticated)} 67 | 68 | 69 | {#each posts as { post, parent }, idx} 70 | {@const hasPrevious = idx !== 0} 71 | 72 | {#if idx === posts.length - 1} 73 | 74 | {:else} 75 | 76 | {/if} 77 | {/each} 78 | 79 | {/if} 80 | 81 | {#snippet Message(msg: string)} 82 | 83 |
{msg}
84 |
85 | {/snippet} 86 | 87 | 96 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/lib/core.ts: -------------------------------------------------------------------------------- 1 | import '@atcute/bluesky/lexicons'; 2 | 3 | import { simpleFetchHandler, XRPC, XRPCError } from '@atcute/client'; 4 | import type { At } from '@atcute/client/lexicons'; 5 | import { render } from 'svelte/server'; 6 | 7 | import type { PostData } from 'internal/types/post.js'; 8 | import { DEFAULT_APPVIEW_URL } from 'internal/utils/constants.js'; 9 | 10 | import BlueskyPost from './bluesky-post.svelte'; 11 | 12 | export type { PostData }; 13 | 14 | export interface PostFetchOptions { 15 | /** 16 | * AT-URI of the post in question 17 | */ 18 | uri: string; 19 | /** 20 | * Abort signal to cancel the request 21 | */ 22 | signal?: AbortSignal; 23 | /** 24 | * Whether to fetch post without context (no parent replies) 25 | * @default false 26 | */ 27 | contextless?: boolean; 28 | /** 29 | * Whether to allow unauthenticated viewing 30 | * @default false 31 | */ 32 | allowUnauthenticated?: boolean; 33 | /** 34 | * AppView service to use 35 | * @default "https://public.api.bsky.app" 36 | */ 37 | serviceUri?: string; 38 | } 39 | 40 | export const fetchPost = async (opts: PostFetchOptions): Promise => { 41 | const rpc = new XRPC({ handler: simpleFetchHandler({ service: opts.serviceUri ?? DEFAULT_APPVIEW_URL }) }); 42 | const contextless = opts.contextless ?? false; 43 | 44 | const { data } = await rpc 45 | .get('app.bsky.feed.getPostThread', { 46 | signal: opts.signal, 47 | params: { 48 | uri: opts.uri as At.ResourceUri, 49 | parentHeight: !contextless ? 2 : 1, 50 | depth: 0, 51 | }, 52 | }) 53 | .catch((err) => { 54 | if (err instanceof XRPCError) { 55 | if (err.kind === 'NotFound') { 56 | return { data: null }; 57 | } 58 | } 59 | 60 | return Promise.reject(err); 61 | }); 62 | 63 | const thread = data?.thread.$type === 'app.bsky.feed.defs#threadViewPost' ? data.thread : null; 64 | 65 | return { thread, contextless, allowUnauthenticated: opts.allowUnauthenticated ?? false }; 66 | }; 67 | 68 | export const renderPost = (data: PostData): string => { 69 | return render(BlueskyPost, { props: data }).body; 70 | }; 71 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/lib/wc.ts: -------------------------------------------------------------------------------- 1 | import { fetchPost, renderPost } from './core'; 2 | 3 | export class BlueskyPost extends HTMLElement { 4 | connectedCallback() { 5 | this.load().then( 6 | () => this.dispatchEvent(new CustomEvent('loaded')), 7 | (err) => { 8 | const defaulted = this.dispatchEvent(new CustomEvent('error', { detail: err })); 9 | if (defaulted) { 10 | throw err; 11 | } 12 | }, 13 | ); 14 | } 15 | 16 | async load() { 17 | const src = this.getAttribute('src')!; 18 | const serviceUri = this.getAttribute('service-uri') || undefined; 19 | const contextless = this.getAttribute('contextless') !== null; 20 | const allowUnauthenticated = this.getAttribute('allow-unauthenticated') !== null; 21 | 22 | const data = await fetchPost({ uri: src, contextless, allowUnauthenticated, serviceUri }); 23 | const html = renderPost(data); 24 | 25 | const root = this.shadowRoot; 26 | 27 | if (!root) { 28 | this.innerHTML = html; 29 | } else { 30 | const template = document.createElement('template'); 31 | template.innerHTML = html; 32 | 33 | const fragment = template.content; 34 | const slot = root.querySelector('slot'); 35 | 36 | if (slot) { 37 | slot.replaceWith(fragment); 38 | } else { 39 | root.appendChild(fragment); 40 | } 41 | } 42 | } 43 | } 44 | 45 | customElements.define('bluesky-post', BlueskyPost); 46 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "bluesky-post-embed", 4 | "description": "Custom element for embedding Bluesky posts", 5 | "version": "1.0.5", 6 | "author": "externdefs", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/mary-ext/bluesky-embed", 11 | "directory": "packages/bluesky-post-embed" 12 | }, 13 | "files": [ 14 | "dist/", 15 | "themes/" 16 | ], 17 | "exports": { 18 | ".": "./dist/wc.js", 19 | "./core": "./dist/core.js", 20 | "./style.css": "./dist/core.css", 21 | "./themes/*": "./themes/*" 22 | }, 23 | "scripts": { 24 | "dev": "vite", 25 | "build": "pnpm run check && vite build && rsync -aHAX --delete ../../themes/ themes/", 26 | "check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json", 27 | "prepack": "pnpm run build" 28 | }, 29 | "dependencies": { 30 | "@atcute/bluesky": "^2.0.0", 31 | "@atcute/bluesky-richtext-segmenter": "^2.0.0", 32 | "@atcute/client": "^3.0.0" 33 | }, 34 | "devDependencies": { 35 | "@preact/preset-vite": "^2.10.1", 36 | "@tsconfig/svelte": "^5.0.4", 37 | "@types/node": "^22.14.0", 38 | "internal": "workspace:^", 39 | "preact": "^10.26.5", 40 | "svelte": "catalog:", 41 | "svelte-check": "^4.1.5", 42 | "vite": "^6.2.6", 43 | "vite-plugin-dts": "^4.5.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'preact/hooks'; 2 | 3 | import type { PostData } from 'internal/types/post.js'; 4 | import { fetchPost, renderPost } from '../lib/core'; 5 | 6 | const uri = `at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3kj2umze7zj2n`; 7 | 8 | const App = () => { 9 | const [state, setState] = useState<{ uri: string; data: PostData }>(); 10 | 11 | useEffect(() => { 12 | if (state && state.uri === uri) { 13 | return; 14 | } 15 | 16 | const controller = new AbortController(); 17 | const promise = fetchPost({ 18 | uri: uri, 19 | signal: controller.signal, 20 | allowUnauthenticated: true, 21 | }); 22 | 23 | promise.then((data) => { 24 | setState({ uri, data }); 25 | }); 26 | 27 | return () => { 28 | controller.abort(); 29 | }; 30 | }, [uri]); 31 | 32 | return
{state && }
; 33 | }; 34 | 35 | export default App; 36 | 37 | const BlueskyPost = ({ data }: { data: PostData }) => { 38 | const html = useMemo(() => renderPost(data), [data]); 39 | 40 | return ; 41 | }; 42 | 43 | declare module 'preact' { 44 | namespace JSX { 45 | interface BlueskyPostAttributes extends HTMLAttributes { 46 | src?: string; 47 | } 48 | 49 | interface IntrinsicElements { 50 | 'bluesky-post': BlueskyPostAttributes; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact'; 2 | 3 | import App from './app'; 4 | 5 | import '../../../themes/light.css'; 6 | import './styles/main.css'; 7 | 8 | render(, document.body); 9 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/src/styles/main.css: -------------------------------------------------------------------------------- 1 | @import './normalize.css'; 2 | 3 | .app { 4 | margin: 0 auto; 5 | padding: 36px 16px; 6 | width: 100%; 7 | max-width: calc(550px + (16 * 2px)); 8 | } 9 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/src/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */ 2 | 3 | /* 4 | Document 5 | ======== 6 | */ 7 | 8 | /** 9 | Use a better box model (opinionated). 10 | */ 11 | 12 | *, 13 | ::before, 14 | ::after { 15 | box-sizing: border-box; 16 | } 17 | 18 | html { 19 | line-height: 1.15; /* 1. Correct the line height in all browsers. */ 20 | /* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */ 21 | font-family: 'Inter', 'Roboto', ui-sans-serif, sans-serif, 'Noto Color Emoji', 'Twemoji Mozilla'; 22 | -webkit-text-size-adjust: 100%; /* 2. Prevent adjustments of font size after orientation changes in iOS. */ 23 | tab-size: 4; /* 3. Use a more readable tab size (opinionated). */ 24 | } 25 | 26 | /* 27 | Sections 28 | ======== 29 | */ 30 | 31 | body { 32 | margin: 0; /* Remove the margin in all browsers. */ 33 | } 34 | 35 | /* 36 | Text-level semantics 37 | ==================== 38 | */ 39 | 40 | /** 41 | Add the correct font weight in Chrome and Safari. 42 | */ 43 | 44 | b, 45 | strong { 46 | font-weight: bolder; 47 | } 48 | 49 | /** 50 | 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 51 | 2. Correct the odd 'em' font sizing in all browsers. 52 | */ 53 | 54 | code, 55 | kbd, 56 | samp, 57 | pre { 58 | font-size: 1em; /* 2 */ 59 | font-family: 'JetBrains Mono NL', ui-monospace, monospace; /* 1 */ 60 | } 61 | 62 | /** 63 | Add the correct font size in all browsers. 64 | */ 65 | 66 | small { 67 | font-size: 80%; 68 | } 69 | 70 | /** 71 | Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. 72 | */ 73 | 74 | sub, 75 | sup { 76 | position: relative; 77 | vertical-align: baseline; 78 | font-size: 75%; 79 | line-height: 0; 80 | } 81 | 82 | sub { 83 | bottom: -0.25em; 84 | } 85 | 86 | sup { 87 | top: -0.5em; 88 | } 89 | 90 | /* 91 | Tabular data 92 | ============ 93 | */ 94 | 95 | /** 96 | Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016) 97 | */ 98 | 99 | table { 100 | border-color: currentcolor; 101 | } 102 | 103 | /* 104 | Forms 105 | ===== 106 | */ 107 | 108 | /** 109 | 1. Change the font styles in all browsers. 110 | 2. Remove the margin in Firefox and Safari. 111 | */ 112 | 113 | button, 114 | input, 115 | optgroup, 116 | select, 117 | textarea { 118 | margin: 0; /* 2 */ 119 | font-size: 100%; /* 1 */ 120 | line-height: 1.15; /* 1 */ 121 | font-family: inherit; /* 1 */ 122 | } 123 | 124 | /** 125 | Correct the inability to style clickable types in iOS and Safari. 126 | */ 127 | 128 | button, 129 | [type='button'], 130 | [type='reset'], 131 | [type='submit'] { 132 | -webkit-appearance: button; 133 | } 134 | 135 | /** 136 | Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. 137 | */ 138 | 139 | legend { 140 | padding: 0; 141 | } 142 | 143 | /** 144 | Add the correct vertical alignment in Chrome and Firefox. 145 | */ 146 | 147 | progress { 148 | vertical-align: baseline; 149 | } 150 | 151 | /** 152 | Correct the cursor style of increment and decrement buttons in Safari. 153 | */ 154 | 155 | ::-webkit-inner-spin-button, 156 | ::-webkit-outer-spin-button { 157 | height: auto; 158 | } 159 | 160 | /** 161 | 1. Correct the odd appearance in Chrome and Safari. 162 | 2. Correct the outline style in Safari. 163 | */ 164 | 165 | [type='search'] { 166 | -webkit-appearance: textfield; /* 1 */ 167 | outline-offset: -2px; /* 2 */ 168 | } 169 | 170 | /** 171 | Remove the inner padding in Chrome and Safari on macOS. 172 | */ 173 | 174 | ::-webkit-search-decoration { 175 | -webkit-appearance: none; 176 | } 177 | 178 | /** 179 | 1. Correct the inability to style clickable types in iOS and Safari. 180 | 2. Change font properties to 'inherit' in Safari. 181 | */ 182 | 183 | ::-webkit-file-upload-button { 184 | -webkit-appearance: button; /* 1 */ 185 | font: inherit; /* 2 */ 186 | } 187 | 188 | /* 189 | Interactive 190 | =========== 191 | */ 192 | 193 | /* 194 | Add the correct display in Chrome and Safari. 195 | */ 196 | 197 | summary { 198 | display: list-item; 199 | } 200 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": [], 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "moduleDetection": "force", 11 | "noEmit": true, 12 | }, 13 | "include": ["lib"], 14 | } 15 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": [], 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "moduleDetection": "force", 11 | "noEmit": true, 12 | "jsx": "react-jsx", 13 | "jsxImportSource": "preact", 14 | }, 15 | "include": ["lib", "src"], 16 | "references": [{ "path": "./tsconfig.node.json" }], 17 | } 18 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "types": ["node"], 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "strict": true, 9 | "noEmit": true, 10 | "noUncheckedSideEffectImports": true, 11 | }, 12 | "include": ["vite.config.ts"], 13 | } 14 | -------------------------------------------------------------------------------- /packages/bluesky-post-embed/vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { compile as compileSvelte } from 'svelte/compiler'; 4 | import { type Plugin, createFilter, defineConfig } from 'vite'; 5 | 6 | import preact from '@preact/preset-vite'; 7 | import dts from 'vite-plugin-dts'; 8 | 9 | export default defineConfig({ 10 | base: './', 11 | build: { 12 | outDir: 'dist/', 13 | target: 'esnext', 14 | minify: false, 15 | cssMinify: false, 16 | cssCodeSplit: true, 17 | lib: { 18 | entry: { 19 | core: 'lib/core.ts', 20 | wc: 'lib/wc.ts', 21 | }, 22 | formats: ['es'], 23 | }, 24 | rollupOptions: { 25 | external: ['@atcute/client', '@atcute/bluesky-richtext-segmenter'], 26 | }, 27 | }, 28 | esbuild: { 29 | target: 'esnext', 30 | }, 31 | plugins: [ 32 | svelte(), 33 | preact(), 34 | dts({ 35 | rollupTypes: true, 36 | tsconfigPath: 'tsconfig.build.json', 37 | beforeWriteFile(filePath, content) { 38 | if (filePath.endsWith('/core.d.ts')) { 39 | // Make sure the relevant types are present 40 | return { content: `import '@atcute/bluesky/lexicons';\n${content}` }; 41 | } 42 | }, 43 | }), 44 | ], 45 | }); 46 | 47 | function svelte(): Plugin { 48 | const filter = createFilter('**/*.svelte'); 49 | const stylesheets = new Map(); 50 | 51 | return { 52 | name: 'svelte', 53 | resolveId(id) { 54 | return stylesheets.has(id) ? id : null; 55 | }, 56 | load(id) { 57 | const css = stylesheets.get(id); 58 | if (css !== undefined) { 59 | this.addWatchFile(id.slice(0, -4)); 60 | return { code: css }; 61 | } 62 | 63 | return null; 64 | }, 65 | transform(source, id) { 66 | if (!filter(id)) { 67 | return null; 68 | } 69 | 70 | const result = compileSvelte(source, { 71 | generate: 'server', 72 | css: 'external', 73 | cssHash({ hash, filename }) { 74 | const prefix = `github:mary-ext/bluesky-post-embed/`; 75 | return `s-` + hash(prefix + path.relative(__dirname, filename)); 76 | }, 77 | runes: true, 78 | filename: id, 79 | }); 80 | 81 | { 82 | const { js, css, warnings } = result; 83 | 84 | // nasty hacks to get smaller sizes 85 | let jsCode = js.code 86 | .replace(//g, '') 87 | .replace(/\$\$slots: {.+?},?/g, '') 88 | .replace(/\$\$payload\.out \+= ["'`]{2};|\$\.(push|pop)\(\);/g, '') 89 | .replace(/(?<=\$\$payload\.out \+= )`\${([a-zA-Z0-9_$.,()[\]\s]+?)}`(?=;)/, '$1'); 90 | 91 | if (css) { 92 | const cssId = `${id}.css`; 93 | jsCode = jsCode + `\nimport ${JSON.stringify(cssId)};\n`; 94 | stylesheets.set(cssId, css.code); 95 | } 96 | 97 | for (const warn of warnings) { 98 | this.warn(warn); 99 | } 100 | 101 | return { code: jsCode }; 102 | } 103 | }, 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/.gitignore: -------------------------------------------------------------------------------- 1 | themes/ 2 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/README.md: -------------------------------------------------------------------------------- 1 | # <bluesky-profile-card-embed> 2 | 3 | A custom element for embedding Bluesky profile cards. 4 | 5 | ## Installation 6 | 7 | ### via npm 8 | 9 | ``` 10 | npm install bluesky-profile-card-embed 11 | ``` 12 | 13 | then, import the package on your app. 14 | 15 | ```js 16 | import 'bluesky-profile-card-embed'; 17 | 18 | import 'bluesky-profile-card-embed/style.css'; 19 | import 'bluesky-profile-card-embed/themes/light.css'; 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```html 25 | 26 | 31 | @patak.dev's Bluesky profile 32 | 33 | 34 | ``` 35 | 36 | ### Attributes 37 | 38 | - `actor` **Required** 39 | DID or handle of the account 40 | - `allow-unauthenticated` **Optional** 41 | Whether to allow unauthenticated viewing 42 | - `service-uri` **Optional** 43 | URL to an AppView service, defaults to `https://public.api.bsky.app` 44 | 45 | ### Events 46 | 47 | - `loaded` 48 | Fired when the embed has successfully loaded the profile card 49 | - `error` 50 | Fired when the embed fails to load the profile card 51 | 52 | ## SSR usage 53 | 54 | The embeds are powered by a static HTML renderer, this renderer can be used directly in your 55 | server-rendering framework of choice for a zero-JS experience. 56 | 57 | ```tsx 58 | import { fetchProfileCard, renderProfileCard } from 'bluesky-profile-card-embed/core'; 59 | 60 | import 'bluesky-profile-card-embed/style.css'; 61 | import 'bluesky-profile-card-embed/themes/light.css'; 62 | 63 | // fetch the profile 64 | const controller = new AbortController(); 65 | const data = await fetchProfileCard({ 66 | actor: `did:plc:ragtjsm2j2vknwkz3zp4oxrd`, 67 | signal: controller.signal, 68 | }); 69 | 70 | // render the profile 71 | const html = renderProfileCard(data); 72 | return ( 73 | 77 | ); 78 | ``` 79 | 80 | Check out examples for [Astro](https://github.com/mary-ext/bluesky-embed-astro) and 81 | [SvelteKit](https://github.com/mary-ext/bluesky-embed-sveltekit). 82 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bluesky profile card embed 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/lib/bluesky-profile-card.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#if profile === null} 15 | {@render Message(`The profile can't be found, it may have been deleted.`)} 16 | {:else if isPwiForbidden} 17 | {@render Message(`The user has requested for their profile to not be displayed on external sites.`)} 18 | {:else} 19 | 20 | 21 | 22 | {/if} 23 | 24 | {#snippet Message(msg: string)} 25 | 26 |
{msg}
27 |
28 | {/snippet} 29 | 30 | 39 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/lib/core.ts: -------------------------------------------------------------------------------- 1 | import '@atcute/bluesky/lexicons'; 2 | 3 | import { simpleFetchHandler, XRPC, XRPCError } from '@atcute/client'; 4 | import type { At } from '@atcute/client/lexicons'; 5 | import { render } from 'svelte/server'; 6 | 7 | import type { ProfileCardData } from 'internal/types/profile-card.js'; 8 | import { DEFAULT_APPVIEW_URL } from 'internal/utils/constants.js'; 9 | 10 | import BlueskyProfileCard from './bluesky-profile-card.svelte'; 11 | 12 | export type { ProfileCardData }; 13 | 14 | export interface ProfileCardFetchOptions { 15 | /** 16 | * Handle or DID identifier of the user 17 | */ 18 | actor: string; 19 | /** 20 | * Abort signal to cancel the request 21 | */ 22 | signal?: AbortSignal; 23 | /** 24 | * Allow unauthenticated viewing 25 | * @default false 26 | */ 27 | allowUnauthenticated?: boolean; 28 | /** 29 | * AppView service to use 30 | * @default "https://public.api.bsky.app" 31 | */ 32 | serviceUri?: string; 33 | } 34 | 35 | export const fetchProfileCard = async (opts: ProfileCardFetchOptions): Promise => { 36 | const actor = opts.actor; 37 | const allowUnauthenticated = opts.allowUnauthenticated ?? false; 38 | 39 | const rpc = new XRPC({ handler: simpleFetchHandler({ service: opts.serviceUri ?? DEFAULT_APPVIEW_URL }) }); 40 | 41 | const { data: profile } = await rpc 42 | .get('app.bsky.actor.getProfile', { 43 | signal: opts.signal, 44 | params: { actor: actor as At.Identifier }, 45 | }) 46 | .catch((err) => { 47 | if (err instanceof XRPCError) { 48 | if (err.kind === 'InvalidRequest' && err.description === 'Profile not found') { 49 | return { data: null }; 50 | } 51 | } 52 | 53 | return Promise.reject(err); 54 | }); 55 | 56 | return { profile: profile, allowUnauthenticated }; 57 | }; 58 | 59 | export const renderProfileCard = (data: ProfileCardData): string => { 60 | return render(BlueskyProfileCard, { props: data }).body; 61 | }; 62 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/lib/wc.ts: -------------------------------------------------------------------------------- 1 | import { fetchProfileCard, renderProfileCard } from './core'; 2 | 3 | export class BlueskyProfileCard extends HTMLElement { 4 | connectedCallback() { 5 | this.load().then( 6 | () => this.dispatchEvent(new CustomEvent('loaded')), 7 | (err) => { 8 | const defaulted = this.dispatchEvent(new CustomEvent('error', { detail: err })); 9 | if (defaulted) { 10 | throw err; 11 | } 12 | }, 13 | ); 14 | } 15 | 16 | async load() { 17 | const actor = this.getAttribute('actor')!; 18 | const serviceUri = this.getAttribute('service-uri') || undefined; 19 | const allowUnauthenticated = this.getAttribute('allow-unauthenticated') !== null; 20 | 21 | const data = await fetchProfileCard({ actor, allowUnauthenticated, serviceUri }); 22 | const html = renderProfileCard(data); 23 | 24 | const root = this.shadowRoot; 25 | 26 | if (!root) { 27 | this.innerHTML = html; 28 | } else { 29 | const template = document.createElement('template'); 30 | template.innerHTML = html; 31 | 32 | const fragment = template.content; 33 | const slot = root.querySelector('slot'); 34 | 35 | if (slot) { 36 | slot.replaceWith(fragment); 37 | } else { 38 | root.appendChild(fragment); 39 | } 40 | } 41 | } 42 | } 43 | 44 | customElements.define('bluesky-profile-card', BlueskyProfileCard); 45 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "bluesky-profile-card-embed", 4 | "description": "Custom element for embedding Bluesky profile cards", 5 | "version": "1.0.0", 6 | "author": "externdefs", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/mary-ext/bluesky-embed", 11 | "directory": "packages/bluesky-profile-card-embed" 12 | }, 13 | "files": [ 14 | "dist/", 15 | "themes/" 16 | ], 17 | "exports": { 18 | ".": "./dist/wc.js", 19 | "./core": "./dist/core.js", 20 | "./style.css": "./dist/core.css", 21 | "./themes/*": "./themes/*" 22 | }, 23 | "scripts": { 24 | "dev": "vite", 25 | "build": "pnpm run check && vite build && rsync -aHAX --delete ../../themes/ themes/", 26 | "check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json", 27 | "prepack": "pnpm run build" 28 | }, 29 | "dependencies": { 30 | "@atcute/bluesky": "^2.0.0", 31 | "@atcute/bluesky-richtext-parser": "^1.0.7", 32 | "@atcute/client": "^3.0.0" 33 | }, 34 | "devDependencies": { 35 | "@preact/preset-vite": "^2.10.1", 36 | "@tsconfig/svelte": "^5.0.4", 37 | "@types/node": "^22.14.0", 38 | "internal": "workspace:^", 39 | "preact": "^10.26.5", 40 | "svelte": "catalog:", 41 | "svelte-check": "^4.1.5", 42 | "vite": "^6.2.6", 43 | "vite-plugin-dts": "^4.5.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'preact/hooks'; 2 | 3 | import type { ProfileCardData } from 'internal/types/profile-card.js'; 4 | import { fetchProfileCard, renderProfileCard } from '../lib/core'; 5 | 6 | const actor = `patak.dev`; 7 | 8 | const App = () => { 9 | const [state, setState] = useState<{ actor: string; data: ProfileCardData }>(); 10 | 11 | useEffect(() => { 12 | if (state && state.actor === actor) { 13 | return; 14 | } 15 | 16 | const controller = new AbortController(); 17 | const promise = fetchProfileCard({ 18 | actor: actor, 19 | signal: controller.signal, 20 | allowUnauthenticated: true, 21 | }); 22 | 23 | promise.then((data) => { 24 | setState({ actor, data }); 25 | }); 26 | 27 | return () => { 28 | controller.abort(); 29 | }; 30 | }, [actor]); 31 | 32 | return
{state && }
; 33 | }; 34 | 35 | export default App; 36 | 37 | const BlueskyProfileCard = ({ data }: { data: ProfileCardData }) => { 38 | const html = useMemo(() => renderProfileCard(data), [data, renderProfileCard]); 39 | 40 | return ( 41 | 45 | ); 46 | }; 47 | 48 | declare module 'preact' { 49 | namespace JSX { 50 | interface BlueskyProfileCardAttributes extends HTMLAttributes { 51 | actor?: string; 52 | } 53 | 54 | interface IntrinsicElements { 55 | 'bluesky-profile-card': BlueskyProfileCardAttributes; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact'; 2 | 3 | import App from './app'; 4 | 5 | import '../../../themes/light.css'; 6 | import './styles/main.css'; 7 | 8 | render(, document.body); 9 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/src/styles/main.css: -------------------------------------------------------------------------------- 1 | @import './normalize.css'; 2 | 3 | .app { 4 | margin: 0 auto; 5 | padding: 36px 16px; 6 | width: 100%; 7 | max-width: calc(550px + (16 * 2px)); 8 | } 9 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/src/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */ 2 | 3 | /* 4 | Document 5 | ======== 6 | */ 7 | 8 | /** 9 | Use a better box model (opinionated). 10 | */ 11 | 12 | *, 13 | ::before, 14 | ::after { 15 | box-sizing: border-box; 16 | } 17 | 18 | html { 19 | line-height: 1.15; /* 1. Correct the line height in all browsers. */ 20 | /* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */ 21 | font-family: 'Inter', 'Roboto', ui-sans-serif, sans-serif, 'Noto Color Emoji', 'Twemoji Mozilla'; 22 | -webkit-text-size-adjust: 100%; /* 2. Prevent adjustments of font size after orientation changes in iOS. */ 23 | tab-size: 4; /* 3. Use a more readable tab size (opinionated). */ 24 | } 25 | 26 | /* 27 | Sections 28 | ======== 29 | */ 30 | 31 | body { 32 | margin: 0; /* Remove the margin in all browsers. */ 33 | } 34 | 35 | /* 36 | Text-level semantics 37 | ==================== 38 | */ 39 | 40 | /** 41 | Add the correct font weight in Chrome and Safari. 42 | */ 43 | 44 | b, 45 | strong { 46 | font-weight: bolder; 47 | } 48 | 49 | /** 50 | 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 51 | 2. Correct the odd 'em' font sizing in all browsers. 52 | */ 53 | 54 | code, 55 | kbd, 56 | samp, 57 | pre { 58 | font-size: 1em; /* 2 */ 59 | font-family: 'JetBrains Mono NL', ui-monospace, monospace; /* 1 */ 60 | } 61 | 62 | /** 63 | Add the correct font size in all browsers. 64 | */ 65 | 66 | small { 67 | font-size: 80%; 68 | } 69 | 70 | /** 71 | Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. 72 | */ 73 | 74 | sub, 75 | sup { 76 | position: relative; 77 | vertical-align: baseline; 78 | font-size: 75%; 79 | line-height: 0; 80 | } 81 | 82 | sub { 83 | bottom: -0.25em; 84 | } 85 | 86 | sup { 87 | top: -0.5em; 88 | } 89 | 90 | /* 91 | Tabular data 92 | ============ 93 | */ 94 | 95 | /** 96 | Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016) 97 | */ 98 | 99 | table { 100 | border-color: currentcolor; 101 | } 102 | 103 | /* 104 | Forms 105 | ===== 106 | */ 107 | 108 | /** 109 | 1. Change the font styles in all browsers. 110 | 2. Remove the margin in Firefox and Safari. 111 | */ 112 | 113 | button, 114 | input, 115 | optgroup, 116 | select, 117 | textarea { 118 | margin: 0; /* 2 */ 119 | font-size: 100%; /* 1 */ 120 | line-height: 1.15; /* 1 */ 121 | font-family: inherit; /* 1 */ 122 | } 123 | 124 | /** 125 | Correct the inability to style clickable types in iOS and Safari. 126 | */ 127 | 128 | button, 129 | [type='button'], 130 | [type='reset'], 131 | [type='submit'] { 132 | -webkit-appearance: button; 133 | } 134 | 135 | /** 136 | Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. 137 | */ 138 | 139 | legend { 140 | padding: 0; 141 | } 142 | 143 | /** 144 | Add the correct vertical alignment in Chrome and Firefox. 145 | */ 146 | 147 | progress { 148 | vertical-align: baseline; 149 | } 150 | 151 | /** 152 | Correct the cursor style of increment and decrement buttons in Safari. 153 | */ 154 | 155 | ::-webkit-inner-spin-button, 156 | ::-webkit-outer-spin-button { 157 | height: auto; 158 | } 159 | 160 | /** 161 | 1. Correct the odd appearance in Chrome and Safari. 162 | 2. Correct the outline style in Safari. 163 | */ 164 | 165 | [type='search'] { 166 | -webkit-appearance: textfield; /* 1 */ 167 | outline-offset: -2px; /* 2 */ 168 | } 169 | 170 | /** 171 | Remove the inner padding in Chrome and Safari on macOS. 172 | */ 173 | 174 | ::-webkit-search-decoration { 175 | -webkit-appearance: none; 176 | } 177 | 178 | /** 179 | 1. Correct the inability to style clickable types in iOS and Safari. 180 | 2. Change font properties to 'inherit' in Safari. 181 | */ 182 | 183 | ::-webkit-file-upload-button { 184 | -webkit-appearance: button; /* 1 */ 185 | font: inherit; /* 2 */ 186 | } 187 | 188 | /* 189 | Interactive 190 | =========== 191 | */ 192 | 193 | /* 194 | Add the correct display in Chrome and Safari. 195 | */ 196 | 197 | summary { 198 | display: list-item; 199 | } 200 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": [], 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "moduleDetection": "force", 11 | "noEmit": true, 12 | }, 13 | "include": ["lib"], 14 | } 15 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": [], 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "moduleDetection": "force", 11 | "noEmit": true, 12 | "jsx": "react-jsx", 13 | "jsxImportSource": "preact", 14 | }, 15 | "include": ["lib", "src"], 16 | "references": [{ "path": "./tsconfig.node.json" }], 17 | } 18 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "types": ["node"], 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "strict": true, 9 | "noEmit": true, 10 | "noUncheckedSideEffectImports": true, 11 | }, 12 | "include": ["vite.config.ts"], 13 | } 14 | -------------------------------------------------------------------------------- /packages/bluesky-profile-card-embed/vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { compile as compileSvelte } from 'svelte/compiler'; 4 | import { type Plugin, createFilter, defineConfig } from 'vite'; 5 | 6 | import preact from '@preact/preset-vite'; 7 | import dts from 'vite-plugin-dts'; 8 | 9 | export default defineConfig({ 10 | base: './', 11 | build: { 12 | outDir: 'dist/', 13 | target: 'esnext', 14 | minify: false, 15 | cssMinify: false, 16 | cssCodeSplit: true, 17 | lib: { 18 | entry: { 19 | core: 'lib/core.ts', 20 | wc: 'lib/wc.ts', 21 | }, 22 | formats: ['es'], 23 | }, 24 | rollupOptions: { 25 | external: ['@atcute/client', '@atcute/bluesky-richtext-parser'], 26 | }, 27 | }, 28 | esbuild: { 29 | target: 'esnext', 30 | }, 31 | plugins: [ 32 | svelte(), 33 | preact(), 34 | dts({ 35 | rollupTypes: true, 36 | tsconfigPath: 'tsconfig.build.json', 37 | beforeWriteFile(filePath, content) { 38 | if (filePath.endsWith('/core.d.ts')) { 39 | // Make sure the relevant types are present 40 | return { content: `import '@atcute/bluesky/lexicons';\n${content}` }; 41 | } 42 | }, 43 | }), 44 | ], 45 | }); 46 | 47 | function svelte(): Plugin { 48 | const filter = createFilter('**/*.svelte'); 49 | const stylesheets = new Map(); 50 | 51 | return { 52 | name: 'svelte', 53 | resolveId(id) { 54 | return stylesheets.has(id) ? id : null; 55 | }, 56 | load(id) { 57 | const css = stylesheets.get(id); 58 | if (css !== undefined) { 59 | this.addWatchFile(id.slice(0, -4)); 60 | return { code: css }; 61 | } 62 | 63 | return null; 64 | }, 65 | transform(source, id) { 66 | if (!filter(id)) { 67 | return null; 68 | } 69 | 70 | const result = compileSvelte(source, { 71 | generate: 'server', 72 | css: 'external', 73 | cssHash({ hash, filename }) { 74 | const prefix = `github:mary-ext/bluesky-profile-card-embed/`; 75 | return `s-` + hash(prefix + path.relative(__dirname, filename)); 76 | }, 77 | runes: true, 78 | filename: id, 79 | }); 80 | 81 | { 82 | const { js, css, warnings } = result; 83 | 84 | // nasty hacks to get smaller sizes 85 | let jsCode = js.code 86 | .replace(//g, '') 87 | .replace(/\$\$slots: {.+?},?/g, '') 88 | .replace(/\$\$payload\.out \+= ["'`]{2};|\$\.(push|pop)\(\);/g, '') 89 | .replace(/(?<=\$\$payload\.out \+= )`\${([a-zA-Z0-9_$.,()[\]\s]+?)}`(?=;)/, '$1'); 90 | 91 | if (css) { 92 | const cssId = `${id}.css`; 93 | jsCode = jsCode + `\nimport ${JSON.stringify(cssId)};\n`; 94 | stylesheets.set(cssId, css.code); 95 | } 96 | 97 | for (const warn of warnings) { 98 | this.warn(warn); 99 | } 100 | 101 | return { code: jsCode }; 102 | } 103 | }, 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/.gitignore: -------------------------------------------------------------------------------- 1 | themes/ 2 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/README.md: -------------------------------------------------------------------------------- 1 | # <bluesky-profile-feed-embed> 2 | 3 | A custom element for embedding Bluesky profile feeds. 4 | 5 | ## Installation 6 | 7 | ### via npm 8 | 9 | ``` 10 | npm install bluesky-profile-feed-embed 11 | ``` 12 | 13 | then, import the package on your app. 14 | 15 | ```js 16 | import 'bluesky-profile-feed-embed'; 17 | 18 | import 'bluesky-profile-feed-embed/style.css'; 19 | import 'bluesky-profile-feed-embed/themes/light.css'; 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```html 25 | 26 | 31 | Posts by Paul Frazee (@pfrazee.com) 32 | 33 | 34 | ``` 35 | 36 | ### Attributes 37 | 38 | - `actor` **Required** 39 | DID or handle of the account 40 | - `include-pins` **Optional** 41 | Whether to show pinned posts 42 | - `allow-unauthenticated` **Optional** 43 | Whether to allow unauthenticated viewing 44 | - `service-uri` **Optional** 45 | URL to an AppView service, defaults to `https://public.api.bsky.app` 46 | 47 | ### Events 48 | 49 | - `loaded` 50 | Fired when the embed has successfully loaded the post 51 | - `error` 52 | Fired when the embed fails to load the post 53 | 54 | ## SSR usage 55 | 56 | The embeds are powered by a static HTML renderer, this renderer can be used directly in your 57 | server-rendering framework of choice for a zero-JS experience. 58 | 59 | ```tsx 60 | import { fetchProfileFeed, renderProfileFeed } from 'bluesky-profile-feed-embed/core'; 61 | 62 | import 'bluesky-post-embed/style.css'; 63 | import 'bluesky-post-embed/themes/light.css'; 64 | 65 | // fetch the profile 66 | const controller = new AbortController(); 67 | const data = await fetchProfileFeed({ 68 | actor: `did:plc:ragtjsm2j2vknwkz3zp4oxrd`, 69 | includePins: true, 70 | signal: controller.signal, 71 | }); 72 | 73 | // render the profile 74 | const html = renderProfileFeed(data); 75 | return ( 76 | 80 | ); 81 | ``` 82 | 83 | Check out examples for [Astro](https://github.com/mary-ext/bluesky-embed-astro) and 84 | [SvelteKit](https://github.com/mary-ext/bluesky-embed-sveltekit). 85 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/lib/bluesky-profile-feed.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 | {#if profile === null} 46 | {@render Message(`The profile can't be found, it may have been deleted.`)} 47 | {:else if isPwiForbidden} 48 | {@render Message(`The user has requested for their posts to not be displayed on external sites.`)} 49 | {:else} 50 | 51 | 52 | 53 | {#if items.length > 0} 54 |
55 | {#each items as item} 56 | 57 | {/each} 58 | 59 |
60 |
61 |
62 |
63 | {:else} 64 |
This user has not made any posts.
65 | {/if} 66 |
67 | {/if} 68 | 69 | {#snippet Message(msg: string)} 70 | 71 |
{msg}
72 |
73 | {/snippet} 74 | 75 | 101 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/lib/core.ts: -------------------------------------------------------------------------------- 1 | import '@atcute/bluesky/lexicons'; 2 | 3 | import { simpleFetchHandler, XRPC, XRPCError } from '@atcute/client'; 4 | import type { At } from '@atcute/client/lexicons'; 5 | import { render } from 'svelte/server'; 6 | 7 | import type { ProfileFeedData } from 'internal/types/profile-feed.js'; 8 | import { DEFAULT_APPVIEW_URL } from 'internal/utils/constants.js'; 9 | 10 | import BlueskyProfileFeed from './bluesky-profile-feed.svelte'; 11 | 12 | export type { ProfileFeedData }; 13 | 14 | export interface ProfileFeedFetchOptions { 15 | /** 16 | * Handle or DID identifier of the user 17 | */ 18 | actor: string; 19 | /** 20 | * Abort signal to cancel the request 21 | */ 22 | signal?: AbortSignal; 23 | /** 24 | * Include pinned posts 25 | * @default false 26 | */ 27 | includePins?: boolean; 28 | /** 29 | * Allow unauthenticated viewing 30 | * @default false 31 | */ 32 | allowUnauthenticated?: boolean; 33 | /** 34 | * AppView service to use 35 | * @default "https://public.api.bsky.app" 36 | */ 37 | serviceUri?: string; 38 | } 39 | 40 | export const fetchProfileFeed = async (opts: ProfileFeedFetchOptions): Promise => { 41 | const actor = opts.actor; 42 | const allowUnauthenticated = opts.allowUnauthenticated ?? false; 43 | 44 | const rpc = new XRPC({ handler: simpleFetchHandler({ service: opts.serviceUri ?? DEFAULT_APPVIEW_URL }) }); 45 | 46 | const [{ data: profile }, { data: timeline }] = await Promise.all([ 47 | rpc 48 | .get('app.bsky.actor.getProfile', { 49 | signal: opts.signal, 50 | params: { actor: actor as At.Identifier }, 51 | }) 52 | .catch((err) => { 53 | if (err instanceof XRPCError) { 54 | if (err.kind === 'InvalidRequest' && err.description === 'Profile not found') { 55 | return { data: null }; 56 | } 57 | } 58 | 59 | return Promise.reject(err); 60 | }), 61 | rpc 62 | .get('app.bsky.feed.getAuthorFeed', { 63 | signal: opts.signal, 64 | params: { 65 | actor, 66 | filter: 'posts_no_replies', 67 | includePins: opts.includePins, 68 | limit: 30, 69 | }, 70 | }) 71 | .catch((err) => { 72 | if (err instanceof XRPCError) { 73 | if (err.kind === 'InvalidRequest' && err.description === 'Profile not found') { 74 | return { data: { feed: [] } }; 75 | } 76 | } 77 | 78 | return Promise.reject(err); 79 | }), 80 | ]); 81 | 82 | return { profile: profile, feed: timeline.feed, allowUnauthenticated }; 83 | }; 84 | 85 | export const renderProfileFeed = (data: ProfileFeedData): string => { 86 | return render(BlueskyProfileFeed, { props: data }).body; 87 | }; 88 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/lib/wc.ts: -------------------------------------------------------------------------------- 1 | import { fetchProfileFeed, renderProfileFeed } from './core'; 2 | 3 | export class BlueskyProfileFeed extends HTMLElement { 4 | connectedCallback() { 5 | this.load().then( 6 | () => this.dispatchEvent(new CustomEvent('loaded')), 7 | (err) => { 8 | const defaulted = this.dispatchEvent(new CustomEvent('error', { detail: err })); 9 | if (defaulted) { 10 | throw err; 11 | } 12 | }, 13 | ); 14 | } 15 | 16 | async load() { 17 | const actor = this.getAttribute('actor')!; 18 | const serviceUri = this.getAttribute('service-uri') || undefined; 19 | const allowUnauthenticated = this.getAttribute('allow-unauthenticated') !== null; 20 | const includePins = this.getAttribute('include-pins') !== null; 21 | 22 | const data = await fetchProfileFeed({ actor, allowUnauthenticated, includePins, serviceUri }); 23 | const html = renderProfileFeed(data); 24 | 25 | this.innerHTML = html; 26 | } 27 | } 28 | 29 | customElements.define('bluesky-profile-feed', BlueskyProfileFeed); 30 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "bluesky-profile-feed-embed", 4 | "description": "Custom element for embedding Bluesky profile feeds", 5 | "version": "1.0.3", 6 | "author": "externdefs", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/mary-ext/bluesky-embed", 11 | "directory": "packages/bluesky-profile-feed-embed" 12 | }, 13 | "files": [ 14 | "dist/", 15 | "themes/" 16 | ], 17 | "exports": { 18 | ".": "./dist/wc.js", 19 | "./core": "./dist/core.js", 20 | "./style.css": "./dist/core.css", 21 | "./themes/*": "./themes/*" 22 | }, 23 | "scripts": { 24 | "dev": "vite", 25 | "build": "pnpm run check && vite build && rsync -aHAX --delete ../../themes/ themes/", 26 | "check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json", 27 | "prepack": "pnpm run build" 28 | }, 29 | "dependencies": { 30 | "@atcute/bluesky": "^2.0.0", 31 | "@atcute/bluesky-richtext-segmenter": "^2.0.0", 32 | "@atcute/client": "^3.0.0" 33 | }, 34 | "devDependencies": { 35 | "@tsconfig/svelte": "^5.0.4", 36 | "@types/node": "^22.14.0", 37 | "internal": "workspace:^", 38 | "svelte": "catalog:", 39 | "svelte-check": "^4.1.5", 40 | "vite": "^6.2.6", 41 | "vite-plugin-dts": "^4.5.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": [], 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "moduleDetection": "force", 11 | "noEmit": true, 12 | }, 13 | "include": ["lib"], 14 | "references": [{ "path": "./tsconfig.node.json" }], 15 | } 16 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "types": ["node"], 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "strict": true, 9 | "noEmit": true, 10 | "noUncheckedSideEffectImports": true, 11 | }, 12 | "include": ["vite.config.ts"], 13 | } 14 | -------------------------------------------------------------------------------- /packages/bluesky-profile-feed-embed/vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { compile as compileSvelte } from 'svelte/compiler'; 4 | import { type Plugin, createFilter, defineConfig } from 'vite'; 5 | 6 | import dts from 'vite-plugin-dts'; 7 | 8 | export default defineConfig({ 9 | base: './', 10 | build: { 11 | outDir: 'dist/', 12 | target: 'esnext', 13 | minify: false, 14 | cssMinify: false, 15 | cssCodeSplit: true, 16 | lib: { 17 | entry: { 18 | core: 'lib/core.ts', 19 | wc: 'lib/wc.ts', 20 | }, 21 | formats: ['es'], 22 | }, 23 | rollupOptions: { 24 | external: ['@atcute/client', '@atcute/bluesky-richtext-segmenter'], 25 | }, 26 | }, 27 | esbuild: { 28 | target: 'esnext', 29 | }, 30 | plugins: [ 31 | svelte(), 32 | dts({ 33 | rollupTypes: true, 34 | beforeWriteFile(filePath, content) { 35 | if (filePath.endsWith('/core.d.ts')) { 36 | // Make sure the relevant types are present 37 | return { content: `import '@atcute/bluesky/lexicons';\n${content}` }; 38 | } 39 | }, 40 | }), 41 | ], 42 | }); 43 | 44 | function svelte(): Plugin { 45 | const filter = createFilter('**/*.svelte'); 46 | const stylesheets = new Map(); 47 | 48 | return { 49 | name: 'svelte', 50 | resolveId(id) { 51 | return stylesheets.has(id) ? id : null; 52 | }, 53 | load(id) { 54 | const css = stylesheets.get(id); 55 | if (css !== undefined) { 56 | this.addWatchFile(id.slice(0, -4)); 57 | return { code: css }; 58 | } 59 | 60 | return null; 61 | }, 62 | transform(source, id) { 63 | if (!filter(id)) { 64 | return null; 65 | } 66 | 67 | const result = compileSvelte(source, { 68 | generate: 'server', 69 | css: 'external', 70 | cssHash({ hash, filename }) { 71 | const prefix = `github:mary-ext/bluesky-profile-feed-embed/`; 72 | return `s-` + hash(prefix + path.relative(__dirname, filename)); 73 | }, 74 | runes: true, 75 | filename: id, 76 | }); 77 | 78 | { 79 | const { js, css, warnings } = result; 80 | 81 | // nasty hacks to get smaller sizes 82 | let jsCode = js.code 83 | .replace(//g, '') 84 | .replace(/\$\$slots: {.+?},?/g, '') 85 | .replace(/\$\$payload\.out \+= ["'`]{2};|\$\.(push|pop)\(\);/g, '') 86 | .replace(/(?<=\$\$payload\.out \+= )`\${([a-zA-Z0-9_$.,()[\]\s]+?)}`(?=;)/, '$1'); 87 | 88 | if (css) { 89 | const cssId = `${id}.css`; 90 | jsCode = jsCode + `\nimport ${JSON.stringify(cssId)};\n`; 91 | stylesheets.set(cssId, css.code); 92 | } 93 | 94 | for (const warn of warnings) { 95 | this.warn(warn); 96 | } 97 | 98 | return { code: jsCode }; 99 | } 100 | }, 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /packages/internal/components/content-hider.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if !warning} 14 | {@render children()} 15 | {:else} 16 |
17 | 18 | 19 | 25 | 31 | 32 | 33 | {warning.name} 34 | 35 | 36 | 37 | 38 | {@render children()} 39 |
40 | {/if} 41 | 42 | 89 | -------------------------------------------------------------------------------- /packages/internal/components/embed-frame.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {@render children()} 13 |
14 | 15 | 51 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/embeds.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 55 | 56 |
57 | {#if embed.$type === 'app.bsky.embed.recordWithMedia#view'} 58 | {@render Media(embed.media)} 59 | {@render Record(embed.record)} 60 | {:else if embed.$type === 'app.bsky.embed.record#view'} 61 | {@render Record(embed)} 62 | {:else} 63 | {@render Media(embed)} 64 | {/if} 65 |
66 | 67 | {#snippet Media(embed: MediaEmbed)} 68 | {@const warning = post && findLabel(post.labels, post.author.did)} 69 | 70 | 71 | {#if embed.$type === 'app.bsky.embed.external#view'} 72 | 73 | {:else if embed.$type === 'app.bsky.embed.images#view'} 74 | 75 | {:else if embed.$type === 'app.bsky.embed.video#view'} 76 | 77 | {:else} 78 | {@render Message(`Unsupported media embed`)} 79 | {/if} 80 | 81 | {/snippet} 82 | 83 | {#snippet Record(embed: RecordEmbed)} 84 | {@const record = embed.record} 85 | 86 | {#if record.$type === 'app.bsky.embed.record#viewRecord'} 87 | 88 | {:else if record.$type === 'app.bsky.feed.defs#generatorView'} 89 | 90 | {:else if record.$type === 'app.bsky.graph.defs#listView'} 91 | 92 | {:else if record.$type === 'app.bsky.graph.defs#starterPackViewBasic'} 93 | 94 | {:else} 95 | {@const uri = parseAtUri(record.uri)} 96 | {@const resource = collectionToLabel(uri.collection)} 97 | 98 | {@const isUnavailable = 99 | resource && 100 | (record.$type === 'app.bsky.embed.record#viewNotFound' || 101 | record.$type === 'app.bsky.embed.record#viewBlocked' || 102 | record.$type === 'app.bsky.embed.record#viewDetached')} 103 | 104 | {@render Message(isUnavailable ? `This ${resource} is unavailable` : `Unsupported record embed`)} 105 | {/if} 106 | {/snippet} 107 | 108 | {#snippet Message(message: string)} 109 |
{message}
110 | {/snippet} 111 | 112 | 127 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/external-embed.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 34 | 35 | 36 | {#if external.thumb} 37 | 38 | {/if} 39 | 40 |
41 |

{external.title}

42 |

{external.description}

43 | 44 | {#if domain} 45 |
46 | 47 | 48 | 54 | 55 | 56 | {domain} 57 |
58 | {/if} 59 |
60 |
61 | 62 | 130 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/feed-embed.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 |
20 |
21 | {#if feed.avatar} 22 | 23 | {:else} 24 | 25 | 26 | 30 | 31 | 32 | {/if} 33 |
34 | 35 |
36 |

{feed.displayName}

37 |

Feed by @{creator.handle}

38 |
39 |
40 | 41 |

{feed.description}

42 |
43 | 44 | 101 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/image-embed.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | 21 |
26 | {#if length === 4} 27 |
28 |
29 |
30 | {@render Image(0)} 31 |
32 |
33 | {@render Image(2)} 34 |
35 |
36 |
37 |
38 | {@render Image(1)} 39 |
40 |
41 | {@render Image(3)} 42 |
43 |
44 |
45 | {:else if length === 3} 46 |
47 |
48 |
49 | {@render Image(0)} 50 |
51 |
52 |
53 |
54 | {@render Image(1)} 55 |
56 |
57 | {@render Image(2)} 58 |
59 |
60 |
61 | {:else if length === 2} 62 |
63 |
64 |
65 | {@render Image(0)} 66 |
67 |
68 |
69 |
70 | {@render Image(1)} 71 |
72 |
73 |
74 | {:else if length === 1} 75 | {@const ratio = standalone && (images[0].aspectRatio || DEFAULT_RATIO)} 76 | 77 |
81 | {@render Image(0)} 82 | 83 | {#if ratio} 84 |
85 | {/if} 86 |
87 | {/if} 88 |
89 | 90 | {#snippet Image(index: number)} 91 | {@const image = images[index]} 92 | 93 | {image.alt} 94 | {/snippet} 95 | 96 | 184 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/list-embed.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | 31 | 32 |
33 |
34 | {#if list.avatar} 35 | 36 | {:else} 37 | 38 | 39 | 43 | 44 | 45 | {/if} 46 |
47 | 48 |
49 |

{list.name}

50 |

{getPurpose(list.purpose)} by @{creator.handle}

51 |
52 |
53 | 54 |

{list.description}

55 |
56 | 57 | 114 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/quote-embed.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 65 | 66 | 67 |
68 |
69 | {#if author.avatar} 70 | 71 | {/if} 72 |
73 | 74 | 75 | {#if authorName} 76 | 77 | {authorName} 78 | 79 | {/if} 80 | 81 | @{author.handle} 82 | 83 | 84 | 85 | 86 | 89 |
90 | 91 | {#if text} 92 |
93 | {#if !large} 94 | {#if image} 95 |
96 | 97 |
98 | {:else if video} 99 |
100 | 101 |
102 | {/if} 103 | {/if} 104 | 105 |

{text}

106 |
107 | {:else} 108 |
109 | {/if} 110 | 111 | {#if large || !text} 112 | {#if image} 113 | 114 | {:else if video} 115 | 116 | {/if} 117 | {/if} 118 |
119 | 120 | 214 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/starterpack-embed.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | {#if large} 25 | {@const imageUrl = getStarterpackImgUrl(creatorDid, rkey)} 26 | 27 | 28 | {/if} 29 | 30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 45 | 51 | 52 | 53 |
54 |

{record.name}

55 |

Starter pack by @{creator.handle}

56 |
57 |
58 | 59 |

{record.description}

60 |
61 |
62 | 63 | 123 | -------------------------------------------------------------------------------- /packages/internal/components/embeds/video-embed.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#if standalone} 23 | 28 |
29 | {@render Content()} 30 |
31 |
32 | {:else} 33 |
37 | {@render Content()} 38 |
39 | {/if} 40 | 41 | {#snippet Content()} 42 | 43 | 44 | {#if ratio} 45 |
46 | {/if} 47 | 48 |
49 | 50 | 51 | 52 | 53 |
54 | {/snippet} 55 | 56 | 121 | -------------------------------------------------------------------------------- /packages/internal/components/feed-post.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 |
37 |
38 | {#if prev} 39 |
40 |
41 |
42 | {/if} 43 | 44 | {#if reason} 45 | {#if reason.$type === 'app.bsky.feed.defs#reasonRepost'} 46 | {@const by = reason.by} 47 | 48 |
49 |
50 | 51 | 57 | 58 |
59 | 60 | {by.displayName} 61 | {' '}reposted 62 | 63 |
64 | {:else if reason.$type === 'app.bsky.feed.defs#reasonPin'} 65 |
66 |
67 | 68 | 74 | 75 |
76 | Pinned 77 |
78 | {/if} 79 | {/if} 80 |
81 | 82 |
83 |
84 | 85 | {#if author.avatar} 86 | 87 | {/if} 88 | 89 | 90 | {#if next} 91 |
92 | {/if} 93 |
94 | 95 |
96 | 113 | 114 | {#if !prev && record.reply} 115 |

116 | {#if parent && parent.$type === 'app.bsky.feed.defs#postView'} 117 | {@const author = parent.author} 118 | 119 | Replying to 120 | 121 | {author.displayName?.trim() || `@${author.handle}`} 122 | 123 | {:else} 124 | Replying to an unknown post 125 | {/if} 126 |

127 | {/if} 128 | 129 | 130 | 131 | {#if post.embed} 132 | 133 | {/if} 134 | 135 |
136 |
142 | 143 | 149 | 150 | 151 | 152 | {formatCompactNumber(replyCount)} 153 | 154 |
155 | 156 |
162 | 163 | 169 | 170 | 171 | 172 | {formatCompactNumber(repostCount)} 173 | 174 |
175 | 176 |
182 | 183 | 188 | 189 | 190 | 191 | {formatCompactNumber(likeCount)} 192 | 193 |
194 |
195 |
196 |
197 |
198 | 199 | 401 | -------------------------------------------------------------------------------- /packages/internal/components/highlighted-post.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 |
36 | 66 | 67 | {#if !prev && record.reply} 68 |

69 | {#if parent} 70 | {@const author = parent.author} 71 | 72 | Replying to 73 | 74 | {author.displayName?.trim() || `@${author.handle}`} 75 | 76 | {:else} 77 | Replying to an unknown post 78 | {/if} 79 |

80 | {/if} 81 | 82 | 83 | 84 | {#if post.embed} 85 | 86 | {/if} 87 | 88 | 91 | 92 |
93 | 97 | 98 | 99 | 104 | 105 | 106 | {formatCompactNumber(likeCount)} 107 | 108 | 109 | 115 | 116 | 117 | 123 | 124 | 125 | {formatCompactNumber(repostCount)} 126 | 127 | 128 |
129 | 130 | 139 |
140 |
141 | 142 | 272 | -------------------------------------------------------------------------------- /packages/internal/components/post.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 |
31 | {#if !prev} 32 | 38 | {/if} 39 | 40 | 54 | 55 |
56 | 73 | 74 | {#if !prev && record.reply} 75 |

76 | {#if parent} 77 | {@const author = parent.author} 78 | 79 | Replying to 80 | 81 | {author.displayName?.trim() || `@${author.handle}`} 82 | 83 | {:else} 84 | Replying to an unknown post 85 | {/if} 86 |

87 | {/if} 88 | 89 | 90 | 91 | {#if post.embed} 92 | 93 | {/if} 94 |
95 |
96 | 97 | 227 | -------------------------------------------------------------------------------- /packages/internal/components/profile-card.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | 25 | 26 |
27 | 60 | 61 |
62 |

{profile.displayName?.trim() || profile.handle.slice(0, 64)}

63 |

@{profile.handle}

64 |
65 | 66 |
67 | 68 | {formatCompactNumber(profile.followersCount || 0)} 69 | {profile.followersCount === 1 ? `Follower` : `Followers`} 70 | 71 | 72 | 73 | {formatCompactNumber(profile.followsCount || 0)} 74 | Following 75 | 76 |
77 | 78 | {#if profile.description?.trim()} 79 | 80 | {/if} 81 |
82 |
83 | 84 | 205 | -------------------------------------------------------------------------------- /packages/internal/components/profile-feed-header.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | Posts from @{profile.handle} 15 | 16 | 22 |
23 | 24 | 55 | -------------------------------------------------------------------------------- /packages/internal/components/richtext-raw-renderer.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | 17 |

18 | {#each tokenize(text) as token} 19 | {#if token.type === 'autolink'} 20 | 21 | {token.raw.replace(HTTP_RE, '')} 22 | 23 | {:else if token.type === 'mention'} 24 | {token.raw} 25 | {:else if token.type === 'topic'} 26 | {token.raw} 27 | {:else} 28 | {token.raw} 29 | {/if} 30 | {/each} 31 |

32 | 33 | 54 | -------------------------------------------------------------------------------- /packages/internal/components/richtext-renderer.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 26 |

27 | {#each segmentize(text, facets) as segment} 28 | {@const feature = grabFirstSupported(segment.features)} 29 | 30 | {#if !feature} 31 | {segment.text} 32 | {:else if feature.$type === 'app.bsky.richtext.facet#link'} 33 | {segment.text} 34 | {:else if feature.$type === 'app.bsky.richtext.facet#mention'} 35 | {segment.text} 36 | {:else if feature.$type === 'app.bsky.richtext.facet#tag'} 37 | {segment.text} 38 | {/if} 39 | {/each} 40 |

41 | 42 | 67 | -------------------------------------------------------------------------------- /packages/internal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "name": "internal", 5 | "version": "0.1.0", 6 | "exports": { 7 | "./components/*": "./components/*", 8 | "./types/*": "./types/*", 9 | "./utils/*": "./utils/*" 10 | }, 11 | "peerDependencies": { 12 | "@atcute/bluesky": "^1.0.9", 13 | "@atcute/bluesky-richtext-parser": "^1.0.7", 14 | "@atcute/bluesky-richtext-segmenter": "^1.0.5", 15 | "@atcute/client": "^2.0.6", 16 | "svelte": "catalog:" 17 | }, 18 | "peerDependenciesMeta": { 19 | "@atcute/bluesky-richtext-parser": { 20 | "optional": true 21 | }, 22 | "@atcute/bluesky-richtext-segmenter": { 23 | "optional": true 24 | } 25 | }, 26 | "devDependencies": { 27 | "@atcute/bluesky": "^2.0.0", 28 | "@atcute/bluesky-richtext-parser": "^1.0.7", 29 | "@atcute/bluesky-richtext-segmenter": "^2.0.0", 30 | "@atcute/client": "^3.0.0", 31 | "@tsconfig/svelte": "^5.0.4", 32 | "svelte": "catalog:" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/internal/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@atcute/bluesky/lexicons"], 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "moduleDetection": "force", 11 | "noEmit": true, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /packages/internal/types/post.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs, Brand } from '@atcute/client/lexicons'; 2 | 3 | export interface PostData { 4 | thread: Brand.Union | null; 5 | contextless: boolean; 6 | allowUnauthenticated: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /packages/internal/types/profile-card.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs } from '@atcute/client/lexicons'; 2 | 3 | export interface ProfileCardData { 4 | profile: AppBskyActorDefs.ProfileViewDetailed | null; 5 | allowUnauthenticated: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /packages/internal/types/profile-feed.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/client/lexicons'; 2 | 3 | export interface ProfileFeedData { 4 | profile: AppBskyActorDefs.ProfileViewDetailed | null; 5 | feed: AppBskyFeedDefs.FeedViewPost[]; 6 | allowUnauthenticated: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /packages/internal/utils/bsky-url.ts: -------------------------------------------------------------------------------- 1 | export const getProfileUrl = (author: string): string => { 2 | return `https://bsky.app/profile/${author}`; 3 | }; 4 | 5 | export const getPostUrl = (author: string, rkey: string): string => { 6 | return `https://bsky.app/profile/${author}/post/${rkey}`; 7 | }; 8 | 9 | export const getHashtagUrl = (tag: string): string => { 10 | return `https://bsky.app/hashtag/${tag}`; 11 | }; 12 | 13 | export const getFeedUrl = (author: string, rkey: string): string => { 14 | return `https://bsky.app/profile/${author}/feed/${rkey}`; 15 | }; 16 | 17 | export const getListUrl = (author: string, rkey: string): string => { 18 | return `https://bsky.app/profile/${author}/list/${rkey}`; 19 | }; 20 | 21 | export const getStarterpackUrl = (author: string, rkey: string): string => { 22 | return `https://bsky.app/starter-pack/${author}/${rkey}`; 23 | }; 24 | 25 | export const getStarterpackImgUrl = (author: string, rkey: string): string => { 26 | return `https://ogcard.cdn.bsky.app/start/${author}/${rkey}`; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/internal/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_APPVIEW_URL = 'https://public.api.bsky.app'; 2 | 3 | export const NO_UNAUTHENTICATED_LABEL = '!no-unauthenticated'; 4 | -------------------------------------------------------------------------------- /packages/internal/utils/date.ts: -------------------------------------------------------------------------------- 1 | let startOfYear = 0; 2 | let endOfYear = 0; 3 | 4 | const fmtAbsoluteLong = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeStyle: 'short' }); 5 | const fmtAbsShortWithYear = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }); 6 | const fmtAbsShort = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }); 7 | 8 | export const formatShortDate = (date: string | number): string => { 9 | const inst = new Date(date); 10 | const time = inst.getTime(); 11 | 12 | if (isNaN(time)) { 13 | return 'N/A'; 14 | } 15 | 16 | const now = Date.now(); 17 | if (now > endOfYear) { 18 | const date = new Date(now); 19 | 20 | date.setMonth(0, 1); 21 | date.setHours(0, 0, 0); 22 | startOfYear = date.getTime(); 23 | 24 | date.setFullYear(date.getFullYear() + 1, 0, 0); 25 | date.setHours(23, 59, 59, 999); 26 | endOfYear = date.getTime(); 27 | } 28 | 29 | if (time >= startOfYear && time <= endOfYear) { 30 | return fmtAbsShort.format(inst); 31 | } 32 | 33 | return fmtAbsShortWithYear.format(inst); 34 | }; 35 | 36 | export const formatLongDate = (date: string | number): string => { 37 | const inst = new Date(date); 38 | 39 | if (isNaN(inst.getTime())) { 40 | return 'N/A'; 41 | } 42 | 43 | return fmtAbsoluteLong.format(inst); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/internal/utils/labels.ts: -------------------------------------------------------------------------------- 1 | import type { At, ComAtprotoLabelDefs } from '@atcute/client/lexicons'; 2 | 3 | export const FlagsNone = 0; 4 | export const FlagsNoSelf = 1 << 0; 5 | 6 | type Label = ComAtprotoLabelDefs.Label; 7 | 8 | export interface LabelDefinition { 9 | name: string; 10 | flags: number; 11 | } 12 | 13 | export const LABEL_MAPPING: Record = { 14 | '!hide': { 15 | name: `Hidden by moderators`, 16 | flags: FlagsNoSelf, 17 | }, 18 | '!warn': { 19 | name: `Content warning`, 20 | flags: FlagsNoSelf, 21 | }, 22 | 23 | porn: { 24 | name: `Adult content`, 25 | flags: FlagsNone, 26 | }, 27 | sexual: { 28 | name: `Sexually suggestive`, 29 | flags: FlagsNone, 30 | }, 31 | 'graphic-media': { 32 | name: `Graphic media`, 33 | flags: FlagsNone, 34 | }, 35 | nudity: { 36 | name: `Nudity`, 37 | flags: FlagsNone, 38 | }, 39 | }; 40 | 41 | export const findLabel = (labels: Label[] | undefined, authorDid: At.Did): LabelDefinition | undefined => { 42 | if (labels?.length) { 43 | for (let idx = 0, len = labels.length; idx < len; idx++) { 44 | const label = labels[idx]; 45 | const val = label.val; 46 | 47 | if (!(val in LABEL_MAPPING)) { 48 | continue; 49 | } 50 | 51 | const def = LABEL_MAPPING[val]; 52 | 53 | if (def.flags & FlagsNoSelf && label.src === authorDid) { 54 | continue; 55 | } 56 | 57 | return def; 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /packages/internal/utils/number.ts: -------------------------------------------------------------------------------- 1 | const long = new Intl.NumberFormat('en-US'); 2 | const compact = new Intl.NumberFormat('en-US', { notation: 'compact' }); 3 | 4 | export const formatCompactNumber = (value: number) => { 5 | if (value < 1_000) { 6 | return '' + value; 7 | } 8 | 9 | if (value < 100_000) { 10 | return long.format(value); 11 | } 12 | 13 | return compact.format(value); 14 | }; 15 | 16 | export const formatLongNumber = (value: number) => { 17 | return long.format(value); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/internal/utils/syntax/at-url.ts: -------------------------------------------------------------------------------- 1 | export const AT_URI_RE = 2 | /^at:\/\/((?:did:[a-zA-Z0-9._:%-]+)|(?:[a-zA-Z0-9][a-zA-Z0-9-.]*))(?:\/([a-zA-Z0-9.-]+)(?:\/([a-zA-Z0-9_~.:-]{1,512}))?)?\/?(?:\?([^#\s]*))?(?:#([^\s]*))?$/; 3 | 4 | export interface ParsedAtUri { 5 | repo: string; 6 | collection: string; 7 | rkey: string; 8 | query: string; 9 | fragment: string; 10 | } 11 | 12 | export const parseAtUri = (str: string): ParsedAtUri => { 13 | const match = AT_URI_RE.exec(str); 14 | if (!match) { 15 | throw new InvalidAtUriError(`invalid at-uri: ${str}`); 16 | } 17 | 18 | return { 19 | repo: match[1], 20 | collection: match[2] ?? '', 21 | rkey: match[3] ?? '', 22 | query: match[4] ?? '', 23 | fragment: match[5] ?? '', 24 | }; 25 | }; 26 | 27 | export class InvalidAtUriError extends Error {} 28 | -------------------------------------------------------------------------------- /packages/svelte-site/.gitignore: -------------------------------------------------------------------------------- 1 | pages/ 2 | -------------------------------------------------------------------------------- /packages/svelte-site/README.md: -------------------------------------------------------------------------------- 1 | # Svelte + TS + Vite 2 | 3 | This template should help get you started developing with Svelte and TypeScript in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VS Code](https://code.visualstudio.com/) + 8 | [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). 9 | 10 | ## Need an official Svelte framework? 11 | 12 | Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy 13 | anywhere with its serverless-first approach and adapt to various platforms, with out of the box 14 | support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, 15 | Tailwind CSS, and more. 16 | 17 | ## Technical considerations 18 | 19 | **Why use this over SvelteKit?** 20 | 21 | - It brings its own routing solution which might not be preferable for some users. 22 | - It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. 23 | 24 | This template contains as little as possible to get started with Vite + TypeScript + Svelte, while 25 | taking into account the developer experience with regards to HMR and intellisense. It demonstrates 26 | capabilities on par with the other `create-vite` templates and is a good starting point for 27 | beginners dipping their toes into a Vite + Svelte project. 28 | 29 | Should you later need the extended capabilities and extensibility provided by SvelteKit, the 30 | template has been structured similarly to SvelteKit so that it is easy to migrate. 31 | 32 | **Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** 33 | 34 | Setting `compilerOptions.types` shuts out all other types not explicitly listed in the 35 | configuration. Using triple-slash references keeps the default TypeScript setting of accepting type 36 | information from the entire workspace, while also adding `svelte` and `vite/client` type 37 | information. 38 | 39 | **Why include `.vscode/extensions.json`?** 40 | 41 | Other templates indirectly recommend extensions via the README, but this file allows VS Code to 42 | prompt the user to install the recommended extension upon opening the project. 43 | 44 | **Why enable `allowJs` in the TS template?** 45 | 46 | While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not 47 | prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force 48 | `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase 49 | is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there 50 | are valid use cases in which a mixed codebase may be relevant. 51 | 52 | **Why is HMR not preserving my local component state?** 53 | 54 | HMR state preservation comes with a number of gotchas! It has been disabled by default in both 55 | `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read 56 | the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). 57 | 58 | If you have state that's important to retain within a component, consider creating an external store 59 | which would not be replaced by HMR. 60 | 61 | ```ts 62 | // store.ts 63 | // An extremely simple external store 64 | import { writable } from 'svelte/store'; 65 | export default writable(0); 66 | ``` 67 | -------------------------------------------------------------------------------- /packages/svelte-site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bluesky embed 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/svelte-site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-site", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json", 11 | "publish": "pnpm run build && ./scripts/publish.sh" 12 | }, 13 | "dependencies": { 14 | "@atcute/bluesky": "^2.0.0", 15 | "@atcute/client": "^3.0.0", 16 | "bluesky-post-embed": "workspace:^", 17 | "bluesky-profile-card-embed": "workspace:^", 18 | "bluesky-profile-feed-embed": "workspace:^", 19 | "internal": "workspace:^" 20 | }, 21 | "devDependencies": { 22 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 23 | "@tsconfig/svelte": "^5.0.4", 24 | "svelte": "^5.25.12", 25 | "svelte-check": "^4.1.5", 26 | "terser": "^5.39.0", 27 | "tslib": "^2.8.1", 28 | "vite": "^6.2.6" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/svelte-site/scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -n $(git status --porcelain) ]]; then 6 | echo 'Working directory is not clean' 7 | git status --short 8 | exit 1 9 | fi 10 | 11 | GIT_COMMIT=$(git rev-parse HEAD) 12 | 13 | rsync -aHAX --delete --exclude=.git --exclude=.nojekyll dist/ pages/ 14 | touch pages/.nojekyll 15 | 16 | git -C pages/ add . 17 | git -C pages/ commit -m "deploy: ${GIT_COMMIT}" 18 | git -C pages/ push 19 | -------------------------------------------------------------------------------- /packages/svelte-site/src/App.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 24 | 25 |
26 |

27 | <bluesky-embed> 28 |

29 | 30 | 31 | 32 | 33 | 34 | {#if matched && matched.type === 'profile'} 35 |
36 | 40 | 41 | 45 |
46 | {/if} 47 | 48 |
49 | {#if !matched} 50 | Invalid URL, did you type it correctly? 51 | {:else if matched.type === 'post'} 52 | 53 | {#snippet children(Component)} 54 | 55 | {/snippet} 56 | 57 | {:else if matched.type === 'profile'} 58 | 63 | {#snippet children(Component)} 64 | 65 | {/snippet} 66 | 67 | {/if} 68 |
69 | 70 |
71 | 72 | made with ❤️ by @mary.my.id 73 | 74 | 75 | 76 | source code 77 | 78 | 79 | MIT License 80 |
81 |
82 | 83 | {#snippet LazyFallback()} 84 | 85 | {/snippet} 86 | 87 | {#snippet LazyBoundary(err: unknown)} 88 | 89 | {'' + err} 90 | 91 | {/snippet} 92 | 93 | 160 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/Banner.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | 16 | 43 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/CircularSpinner.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 19 | 20 | 21 | 43 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/CodeBlock.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
{code}
11 | 12 |
13 | 30 |
31 |
32 | 33 | 101 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/Field.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 | 17 |
18 | 19 | 37 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/Lazy.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | 31 | {#await promise} 32 | {@render fallback()} 33 | {:then component} 34 | {@render children(component)} 35 | {:catch err} 36 | {@render boundary(err)} 37 | {/await} 38 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/TextInput.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/display/PostDisplay.svelte: -------------------------------------------------------------------------------- 1 | 82 | 83 | {#await promise} 84 | 85 | {:then data} 86 | 87 | 88 | {#if data.thread} 89 | 90 | 91 | Doing server-side rendering? Check out examples for 92 | Astro and 93 | SvelteKit. 94 | 95 | 96 | 97 |
  • 98 |

    99 | Insert the following scripts and stylesheets to the <head> of your website. 100 |

    101 | 102 |
  • 103 | 104 |
  • 105 |

    Insert the following markup in wherever you want the post to be.

    106 | 107 |
  • 108 |
    109 |
    110 | {/if} 111 | {:catch err} 112 | 113 | {'' + err} 114 | 115 | {/await} 116 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/display/ProfileCardDisplay.svelte: -------------------------------------------------------------------------------- 1 | 66 | 67 | {#await promise} 68 | 69 | {:then data} 70 | 71 | 72 | {#if data.profile} 73 | 74 | 75 |
  • 76 |

    77 | Insert the following scripts and stylesheets to the <head> of your website. 78 |

    79 | 80 |
  • 81 | 82 |
  • 83 |

    Insert the following markup in wherever you want the profile feed to be.

    84 | 85 |
  • 86 |
    87 |
    88 | {/if} 89 | {:catch err} 90 | 91 | {'' + err} 92 | 93 | {/await} 94 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/display/ProfileFeedDisplay.svelte: -------------------------------------------------------------------------------- 1 | 66 | 67 | {#await promise} 68 | 69 | {:then data} 70 | 71 | 72 | {#if data.profile} 73 | 74 | 75 |
  • 76 |

    77 | Insert the following scripts and stylesheets to the <head> of your website. 78 |

    79 | 80 |
  • 81 | 82 |
  • 83 |

    Insert the following markup in wherever you want the profile feed to be.

    84 | 85 |
  • 86 |
    87 |
    88 | {/if} 89 | {:catch err} 90 | 91 | {'' + err} 92 | 93 | {/await} 94 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/embeds/BlueskyPost.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {@html renderPost(data)} 13 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/embeds/BlueskyProfileCard.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {@html renderProfileCard(data)} 13 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/embeds/BlueskyProfileFeed.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {@html renderProfileFeed(data)} 13 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/guides/Guide.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
    13 |

    {title}

    14 | 15 | {@render children()} 16 |
    17 | 18 | 27 | -------------------------------------------------------------------------------- /packages/svelte-site/src/components/guides/GuideInstructions.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
      12 | {@render children()} 13 |
    14 | 15 | 27 | -------------------------------------------------------------------------------- /packages/svelte-site/src/lib/component.ts: -------------------------------------------------------------------------------- 1 | let uid = 0; 2 | export const use_id = () => { 3 | return `s:${uid++}`; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/svelte-site/src/lib/html.ts: -------------------------------------------------------------------------------- 1 | export const escape_html = (text: string): string => { 2 | return text.replace(/[<"&]/g, (c) => '&#' + c.charCodeAt(0) + ';'); 3 | }; 4 | -------------------------------------------------------------------------------- /packages/svelte-site/src/lib/matcher.ts: -------------------------------------------------------------------------------- 1 | import { is_at_identifier, is_tid } from './strings'; 2 | 3 | export interface ExtractedPostInfo { 4 | type: 'post'; 5 | author: string; 6 | rkey: string; 7 | } 8 | 9 | export interface ExtractedProfileInfo { 10 | type: 'profile'; 11 | actor: string; 12 | } 13 | 14 | export type ExtractedInfo = ExtractedPostInfo | ExtractedProfileInfo; 15 | 16 | export const extract_url = (str: string): ExtractedInfo | null => { 17 | const url = safe_parse_url(str); 18 | if (!url) { 19 | return null; 20 | } 21 | 22 | let match: RegExpExecArray | null | undefined; 23 | if (url.host === 'bsky.app' || url.host === 'staging.bsky.app' || url.host === 'main.bsky.dev') { 24 | if ((match = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/.exec(url.pathname))) { 25 | if (!is_at_identifier(match[1]) || !is_tid(match[2])) { 26 | return null; 27 | } 28 | 29 | return { type: 'post', author: match[1], rkey: match[2] }; 30 | } 31 | 32 | if ((match = /^\/profile\/([^/]+)\/?$/.exec(url.pathname))) { 33 | if (!is_at_identifier(match[1])) { 34 | return null; 35 | } 36 | 37 | return { type: 'profile', actor: match[1] }; 38 | } 39 | } 40 | 41 | return null; 42 | }; 43 | 44 | const safe_parse_url = (str: string): URL | null => { 45 | let url: URL | null | undefined; 46 | if ('parse' in URL) { 47 | url = URL.parse(str); 48 | } else { 49 | try { 50 | // @ts-expect-error: `'parse' in URL` is giving truthy 51 | url = new URL(str); 52 | } catch {} 53 | } 54 | 55 | if (url && (url.protocol === 'https:' || url.protocol === 'http:')) { 56 | return url; 57 | } 58 | 59 | return null; 60 | }; 61 | -------------------------------------------------------------------------------- /packages/svelte-site/src/lib/strings.ts: -------------------------------------------------------------------------------- 1 | export const RECORD_KEY_RE = /^(?!\.{1,2}$)[a-zA-Z0-9_~.:-]{1,512}$/; 2 | 3 | export const is_record_key = (str: string): boolean => { 4 | return str.length >= 1 && str.length <= 512 && RECORD_KEY_RE.test(str); 5 | }; 6 | 7 | export const TID_RE = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/; 8 | 9 | export const is_tid = (str: string): boolean => { 10 | return str.length === 13 && TID_RE.test(str); 11 | }; 12 | 13 | export const DID_RE = /^did:([a-z]+):([a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])$/; 14 | 15 | export const is_did = (str: string): boolean => { 16 | return str.length >= 7 && str.length <= 2048 && DID_RE.test(str); 17 | }; 18 | 19 | export const HANDLE_RE = 20 | /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; 21 | 22 | export const is_handle = (str: string): boolean => { 23 | return str.length >= 3 && str.length <= 253 && HANDLE_RE.test(str); 24 | }; 25 | 26 | export const is_at_identifier = (str: string): boolean => { 27 | return is_did(str) || is_handle(str); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/svelte-site/src/main.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'svelte'; 2 | 3 | import './styles/normalize.css'; 4 | import 'bluesky-post-embed/themes/light.css'; 5 | 6 | import App from './App.svelte'; 7 | 8 | mount(App, { 9 | target: document.getElementById('app')!, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/svelte-site/src/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */ 2 | 3 | /* 4 | Document 5 | ======== 6 | */ 7 | 8 | /** 9 | Use a better box model (opinionated). 10 | */ 11 | 12 | *, 13 | ::before, 14 | ::after { 15 | box-sizing: border-box; 16 | } 17 | 18 | html { 19 | line-height: 1.15; /* 1. Correct the line height in all browsers. */ 20 | /* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */ 21 | font-family: 'Inter', 'Roboto', ui-sans-serif, sans-serif, 'Noto Color Emoji', 'Twemoji Mozilla'; 22 | -webkit-text-size-adjust: 100%; /* 2. Prevent adjustments of font size after orientation changes in iOS. */ 23 | tab-size: 4; /* 3. Use a more readable tab size (opinionated). */ 24 | } 25 | 26 | /* 27 | Sections 28 | ======== 29 | */ 30 | 31 | body { 32 | margin: 0; /* Remove the margin in all browsers. */ 33 | } 34 | 35 | /* 36 | Text-level semantics 37 | ==================== 38 | */ 39 | 40 | /** 41 | Add the correct font weight in Chrome and Safari. 42 | */ 43 | 44 | b, 45 | strong { 46 | font-weight: bolder; 47 | } 48 | 49 | /** 50 | 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 51 | 2. Correct the odd 'em' font sizing in all browsers. 52 | */ 53 | 54 | code, 55 | kbd, 56 | samp, 57 | pre { 58 | font-size: 1em; /* 2 */ 59 | font-family: 'JetBrains Mono NL', ui-monospace, monospace; /* 1 */ 60 | } 61 | 62 | /** 63 | Add the correct font size in all browsers. 64 | */ 65 | 66 | small { 67 | font-size: 80%; 68 | } 69 | 70 | /** 71 | Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. 72 | */ 73 | 74 | sub, 75 | sup { 76 | position: relative; 77 | vertical-align: baseline; 78 | font-size: 75%; 79 | line-height: 0; 80 | } 81 | 82 | sub { 83 | bottom: -0.25em; 84 | } 85 | 86 | sup { 87 | top: -0.5em; 88 | } 89 | 90 | /* 91 | Tabular data 92 | ============ 93 | */ 94 | 95 | /** 96 | Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016) 97 | */ 98 | 99 | table { 100 | border-color: currentcolor; 101 | } 102 | 103 | /* 104 | Forms 105 | ===== 106 | */ 107 | 108 | /** 109 | 1. Change the font styles in all browsers. 110 | 2. Remove the margin in Firefox and Safari. 111 | */ 112 | 113 | button, 114 | input, 115 | optgroup, 116 | select, 117 | textarea { 118 | margin: 0; /* 2 */ 119 | font-size: 100%; /* 1 */ 120 | line-height: 1.15; /* 1 */ 121 | font-family: inherit; /* 1 */ 122 | } 123 | 124 | /** 125 | Correct the inability to style clickable types in iOS and Safari. 126 | */ 127 | 128 | button, 129 | [type='button'], 130 | [type='reset'], 131 | [type='submit'] { 132 | -webkit-appearance: button; 133 | } 134 | 135 | /** 136 | Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. 137 | */ 138 | 139 | legend { 140 | padding: 0; 141 | } 142 | 143 | /** 144 | Add the correct vertical alignment in Chrome and Firefox. 145 | */ 146 | 147 | progress { 148 | vertical-align: baseline; 149 | } 150 | 151 | /** 152 | Correct the cursor style of increment and decrement buttons in Safari. 153 | */ 154 | 155 | ::-webkit-inner-spin-button, 156 | ::-webkit-outer-spin-button { 157 | height: auto; 158 | } 159 | 160 | /** 161 | 1. Correct the odd appearance in Chrome and Safari. 162 | 2. Correct the outline style in Safari. 163 | */ 164 | 165 | [type='search'] { 166 | -webkit-appearance: textfield; /* 1 */ 167 | outline-offset: -2px; /* 2 */ 168 | } 169 | 170 | /** 171 | Remove the inner padding in Chrome and Safari on macOS. 172 | */ 173 | 174 | ::-webkit-search-decoration { 175 | -webkit-appearance: none; 176 | } 177 | 178 | /** 179 | 1. Correct the inability to style clickable types in iOS and Safari. 180 | 2. Change font properties to 'inherit' in Safari. 181 | */ 182 | 183 | ::-webkit-file-upload-button { 184 | -webkit-appearance: button; /* 1 */ 185 | font: inherit; /* 2 */ 186 | } 187 | 188 | /* 189 | Interactive 190 | =========== 191 | */ 192 | 193 | /* 194 | Add the correct display in Chrome and Safari. 195 | */ 196 | 197 | summary { 198 | display: list-item; 199 | } 200 | -------------------------------------------------------------------------------- /packages/svelte-site/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/svelte-site/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | }; 8 | -------------------------------------------------------------------------------- /packages/svelte-site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "resolveJsonModule": true, 8 | /** 9 | * Typecheck JS in `.svelte` and `.js` files by default. 10 | * Disable checkJs if you'd like to use dynamic types in JS. 11 | * Note that setting allowJs false does not prevent the use 12 | * of JS in `.svelte` files. 13 | */ 14 | "allowJs": true, 15 | "checkJs": true, 16 | "isolatedModules": true, 17 | "moduleDetection": "force", 18 | }, 19 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], 20 | "references": [{ "path": "./tsconfig.node.json" }], 21 | } 22 | -------------------------------------------------------------------------------- /packages/svelte-site/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "strict": true, 9 | "noEmit": true, 10 | "noUncheckedSideEffectImports": true, 11 | }, 12 | "include": ["vite.config.ts"], 13 | } 14 | -------------------------------------------------------------------------------- /packages/svelte-site/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | base: './', 7 | build: { 8 | target: 'esnext', 9 | minify: 'terser', 10 | }, 11 | esbuild: { 12 | target: 'esnext', 13 | }, 14 | plugins: [svelte()], 15 | }); 16 | -------------------------------------------------------------------------------- /patches/svelte.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/internal/server/index.js b/src/internal/server/index.js 2 | index 6098b496c5acbad719c34b0a036fc8451b77c121..7da20e493e47b693f32576ad8b4681fa2b63a9d4 100644 3 | --- a/src/internal/server/index.js 4 | +++ b/src/internal/server/index.js 5 | @@ -105,57 +105,16 @@ function props_id_generator(prefix) { 6 | * @returns {RenderOutput} 7 | */ 8 | export function render(component, options = {}) { 9 | - const uid = props_id_generator(options.idPrefix ? options.idPrefix + '-' : ''); 10 | - /** @type {Payload} */ 11 | - const payload = { 12 | - out: '', 13 | - css: new Set(), 14 | - head: { title: '', out: '', css: new Set(), uid }, 15 | - uid 16 | - }; 17 | - 18 | - const prev_on_destroy = on_destroy; 19 | - on_destroy = []; 20 | - payload.out += BLOCK_OPEN; 21 | - 22 | - let reset_reset_element; 23 | - 24 | - if (DEV) { 25 | - // prevent parent/child element state being corrupted by a bad render 26 | - reset_reset_element = reset_elements(); 27 | - } 28 | - 29 | - if (options.context) { 30 | - push(); 31 | - /** @type {Component} */ (current_component).c = options.context; 32 | - } 33 | - 34 | - // @ts-expect-error 35 | - component(payload, options.props ?? {}, {}, {}); 36 | + const uid = (() => { 37 | + var pfx = options.idPrefix ?? 's'; 38 | + var uid = 0; 39 | + return () => `${pfx}${uid++}`; 40 | + })(); 41 | 42 | - if (options.context) { 43 | - pop(); 44 | - } 45 | - 46 | - if (reset_reset_element) { 47 | - reset_reset_element(); 48 | - } 49 | - 50 | - payload.out += BLOCK_CLOSE; 51 | - for (const cleanup of on_destroy) cleanup(); 52 | - on_destroy = prev_on_destroy; 53 | - 54 | - let head = payload.head.out + payload.head.title; 55 | + const payload = { out: '', uid }; 56 | + component(payload, options.props ?? {}); 57 | 58 | - for (const { hash, code } of payload.css) { 59 | - head += ``; 60 | - } 61 | - 62 | - return { 63 | - head, 64 | - html: payload.out, 65 | - body: payload.out 66 | - }; 67 | + return { body: payload.out }; 68 | } 69 | 70 | /** 71 | @@ -493,12 +452,7 @@ export { await_block as await }; 72 | 73 | /** @param {any} array_like_or_iterator */ 74 | export function ensure_array_like(array_like_or_iterator) { 75 | - if (array_like_or_iterator) { 76 | - return array_like_or_iterator.length !== undefined 77 | - ? array_like_or_iterator 78 | - : Array.from(array_like_or_iterator); 79 | - } 80 | - return []; 81 | + return array_like_or_iterator; 82 | } 83 | 84 | /** 85 | diff --git a/src/internal/shared/attributes.js b/src/internal/shared/attributes.js 86 | index c8758c1d4d4d68cfe3a3ee76c77605ae65285841..92a352784ca26c3404c86294621f8baf06fd2bab 100644 87 | --- a/src/internal/shared/attributes.js 88 | +++ b/src/internal/shared/attributes.js 89 | @@ -23,7 +23,7 @@ const replacements = { 90 | */ 91 | export function attr(name, value, is_boolean = false) { 92 | if (value == null || (!value && is_boolean)) return ''; 93 | - const normalized = (name in replacements && replacements[name].get(value)) || value; 94 | + const normalized = value; 95 | const assignment = is_boolean ? '' : `="${escape_html(normalized, true)}"`; 96 | return ` ${name}${assignment}`; 97 | } 98 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/internal 3 | - packages/svelte-site 4 | - packages/bluesky-post-embed 5 | - packages/bluesky-profile-card-embed 6 | - packages/bluesky-profile-feed-embed 7 | 8 | catalog: 9 | svelte: ^5.25.12 10 | -------------------------------------------------------------------------------- /themes/dark.css: -------------------------------------------------------------------------------- 1 | .bluesky-embed { 2 | --font-size: 16px; 3 | --font-family: 4 | system-ui, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; 5 | --max-feed-height: 600px; 6 | } 7 | 8 | .bluesky-embed { 9 | --text-primary: #f1f3f5; 10 | --text-secondary: #8c9eb2; 11 | --text-link: #1083fe; 12 | --background-primary: #000000; 13 | --background-secondary: #212d3b; 14 | --divider: rgb(37, 51, 66); 15 | --divider-hover: rgb(66, 87, 108); 16 | --button: #208bfe; 17 | --button-text: #ffffff; 18 | --button-hover: #4ca2fe; 19 | } 20 | -------------------------------------------------------------------------------- /themes/dim.css: -------------------------------------------------------------------------------- 1 | .bluesky-embed { 2 | --font-size: 16px; 3 | --font-family: 4 | system-ui, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; 5 | --max-feed-height: 600px; 6 | } 7 | 8 | .bluesky-embed { 9 | --text-primary: #f1f3f5; 10 | --text-secondary: #aebbc9; 11 | --text-link: #1083fe; 12 | --background-primary: #161e27; 13 | --background-secondary: #212d3b; 14 | --divider: #2e4052; 15 | --divider-hover: #4a6179; 16 | --button: #208bfe; 17 | --button-text: #ffffff; 18 | --button-hover: #4ca2fe; 19 | } 20 | -------------------------------------------------------------------------------- /themes/light.css: -------------------------------------------------------------------------------- 1 | .bluesky-embed { 2 | --font-size: 16px; 3 | --font-family: 4 | system-ui, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; 5 | --max-feed-height: 600px; 6 | } 7 | 8 | .bluesky-embed { 9 | --text-primary: #000000; 10 | --text-secondary: #455668; 11 | --text-link: #1083fe; 12 | --background-primary: #ffffff; 13 | --background-secondary: #455668; 14 | --divider-hover: #a9b7c5; 15 | --divider: #d4dbe2; 16 | --button: #1083fe; 17 | --button-text: #ffffff; 18 | --button-hover: #0168d5; 19 | } 20 | --------------------------------------------------------------------------------