├── .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 | 
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 |
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 |
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 |
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 |
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 |
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 |
54 | {/snippet}
55 |
56 |
121 |
--------------------------------------------------------------------------------
/packages/internal/components/feed-post.svelte:
--------------------------------------------------------------------------------
1 |
35 |
36 |
37 |
38 | {#if prev}
39 |
42 | {/if}
43 |
44 | {#if reason}
45 | {#if reason.$type === 'app.bsky.feed.defs#reasonRepost'}
46 | {@const by = reason.by}
47 |
48 |
64 | {:else if reason.$type === 'app.bsky.feed.defs#reasonPin'}
65 |
78 | {/if}
79 | {/if}
80 |
81 |
82 |
83 |
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 |
89 | {formatLongDate(record.createdAt)}
90 |
91 |
92 |
140 |
141 |
142 |
272 |
--------------------------------------------------------------------------------
/packages/internal/components/post.svelte:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 | {#if !prev}
32 |
33 |
37 |
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 |
21 | {#if profile.banner}
22 |
23 | {/if}
24 |
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 |
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 |
29 |
30 |
31 |
32 |
33 |
34 | {#if matched && matched.type === 'profile'}
35 |
36 |
37 |
38 | Profile feed
39 |
40 |
41 |
42 |
43 | Profile card
44 |
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 |
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 |
13 | {@render children()}
14 |
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 |
{
18 | navigator.clipboard.writeText(code).catch(() => alert(`Failed to copy to clipboard`));
19 | }}
20 | >
21 |
22 |
28 |
29 |
30 |
31 |
32 |
33 |
101 |
--------------------------------------------------------------------------------
/packages/svelte-site/src/components/Field.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 | {label}
15 | {@render children()}
16 |
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 |
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 |
--------------------------------------------------------------------------------