├── .czrc ├── .editorconfig ├── .gitignore ├── .husky └── pre-commit ├── .nano-staged.js ├── .npmrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps └── docs │ ├── app │ ├── (docs) │ │ ├── (home) │ │ │ ├── layout.tsx │ │ │ └── page.mdx │ │ ├── api-reference │ │ │ └── page.mdx │ │ ├── bluesky-theme │ │ │ ├── api-reference │ │ │ │ └── page.mdx │ │ │ └── page.mdx │ │ ├── layout.tsx │ │ ├── next │ │ │ └── page.mdx │ │ └── vite │ │ │ └── page.mdx │ ├── api │ │ └── post │ │ │ └── route.ts │ ├── global.css │ ├── layout.tsx │ ├── playground │ │ ├── CopyButton.tsx │ │ ├── Highlight.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ └── providers.tsx │ ├── components │ ├── Code.tsx │ ├── Footer.tsx │ ├── Header.tsx │ ├── Heading.tsx │ ├── Layout.tsx │ ├── MobileNavigation.tsx │ ├── Navigation.tsx │ ├── Prose.tsx │ ├── Search.tsx │ ├── SectionProvider.tsx │ ├── Tag.tsx │ ├── ThemeToggle.tsx │ ├── mdx.tsx │ └── store │ │ ├── drawer.ts │ │ ├── index.ts │ │ └── search.ts │ ├── index.d.ts │ ├── lib │ └── remToPx.ts │ ├── mdx-components.tsx │ ├── mdx │ ├── recma.mjs │ ├── rehype.mjs │ ├── remark.mjs │ └── search.mjs │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── postcss.config.js │ ├── project.json │ ├── providers │ ├── index.ts │ └── shiki.tsx │ ├── public │ ├── bluesky.svg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.png │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── types.d.ts │ └── typography.js ├── biome.json ├── nx.json ├── package.json ├── packages └── core │ ├── CHANGELOG.md │ ├── package.json │ ├── project.json │ ├── rollup.config.js │ ├── src │ ├── Post.tsx │ ├── Swr.tsx │ ├── api.ts │ ├── components │ │ ├── Container │ │ │ ├── container.module.css │ │ │ └── index.tsx │ │ ├── Embed │ │ │ ├── embed.module.css │ │ │ ├── index.tsx │ │ │ └── utils.ts │ │ ├── Link │ │ │ ├── index.tsx │ │ │ └── link.module.css │ │ ├── Post │ │ │ ├── index.tsx │ │ │ ├── post.module.css │ │ │ └── utils.ts │ │ ├── PostContent │ │ │ ├── index.tsx │ │ │ ├── post-content.module.css │ │ │ ├── unicode.ts │ │ │ └── utils.ts │ │ ├── PostError │ │ │ ├── index.tsx │ │ │ └── post-error.module.css │ │ ├── PostNotFound │ │ │ ├── index.tsx │ │ │ └── post-not-found.module.css │ │ ├── PostSkeleton │ │ │ ├── index.tsx │ │ │ └── post-loading.module.css │ │ └── index.ts │ ├── hooks │ │ ├── index.ts │ │ └── usePost.tsx │ ├── index.client.ts │ ├── index.ts │ ├── theme.css │ ├── types │ │ ├── index.ts │ │ └── post.ts │ └── utils │ │ ├── index.ts │ │ ├── labels.ts │ │ └── validations.ts │ ├── tsconfig.json │ └── tsconfig.lib.json ├── pnpm-lock.yaml └── tsconfig.base.json /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "scopes": [ 3 | { 4 | "value": "core", 5 | "name": "core: anything core specific" 6 | }, 7 | { 8 | "value": "docs", 9 | "name": "docs: anything docs specific" 10 | } 11 | ], 12 | "scopesSearchValue": true, 13 | "allowCustomScopes": false 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .nx/cache 42 | .nx/workspace-data 43 | 44 | # Next.js 45 | .next 46 | out -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | ./node_modules/.bin/nano-staged -------------------------------------------------------------------------------- /.nano-staged.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "{apps,packages,tools}/**/*.{js,jsx,ts,tsx,json}": (api) => 3 | `pnpm dlx @biomejs/biome check --apply ${api.filenames.join(" ")}`, 4 | }; 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | auto-install-peers=true 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | ****. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for showing interest in contributing to React Bluesky 💖, you rock! 4 | 5 | When it comes to open source, you can contribute in different ways, all of which are valuable. Here are a few guidelines that should help you prepare your contribution. 6 | 7 | ## Setup the Project 8 | 9 | The following steps will get you up and running to contribute to React Bluesky: 10 | 11 | 1. Fork the repo (click the `Fork` button at the top right of [this page](https://github.com/rhinobase/react-bluesky)) 12 | 13 | 2. Clone your fork locally 14 | 15 | ```sh 16 | git clone https://github.com//react-bluesky.git 17 | cd react-bluesky 18 | ``` 19 | 20 | 3. Set up all the dependencies and packages by running `pnpm install`. This command will install dependencies. 21 | 22 | > If you run into any issues during this step, kindly reach out to the Rhinobase team here: 23 | 24 | ## Development 25 | 26 | To improve our development process, we’ve set up tooling and systems, and React Bluesky uses a mono repo structure by `nx`. 27 | 28 | ### Tooling 29 | 30 | - [PNPM](https://pnpm.io/) to manage packages and dependencies 31 | - [NX](https://nx.dev/) to manage the monorepo 32 | - [SWC](https://swc.rs/) to bundle packages 33 | - [Changeset](https://github.com/atlassian/changesets) for changes documentation, changelog generation, and release management. 34 | 35 | ### Commands 36 | 37 | **`pnpm install`**: bootstraps the entire project, symlinks all dependencies for cross-component development, and builds all components. 38 | 39 | **`pnpm nx build [package name]`**: run build for a particular package. 40 | 41 | **`pnpm nx run-many -t build`**: run build for all the packages. 42 | 43 | ## Think you found a bug? 44 | 45 | Please follow the issue template and provide a clear path to reproduction with a code example. The best way to show a bug is by sending a CodeSandbox link. 46 | 47 | ## Proposing new or changed API? 48 | 49 | Please provide thoughtful comments and some sample API code. Proposals that don't line up with our roadmap or don't have a thoughtful explanation will be closed. 50 | 51 | ## Making a Pull Request? 52 | 53 | Pull requests need only the :+1: of two or more collaborators to be merged; when the PR author is a collaborator, that counts as one. 54 | 55 | ### Commit Convention 56 | 57 | Before creating a Pull Request, ensure that your commits comply with the commit conventions used in this repository. 58 | 59 | When you create a commit we kindly ask you to follow the convention `category(scope or module): message` in your commit message while using one of the following categories: 60 | 61 | - `feat/feature`: all changes that introduce completely new code or new features 62 | - `fix`: changes that fix a bug (ideally you will additionally reference an issue if present) 63 | - `refactor`: any code-related change that is not a fix nor a feature 64 | - `docs`: changing existing or creating new documentation (i.e. README, docs for the usage of a lib or CLI usage) 65 | - `build`: all changes regarding the build of the software changes to dependencies, or the addition of new dependencies 66 | - `test`: all changes regarding tests (adding new tests or changing existing ones) 67 | - `ci`: all changes regarding the configuration of continuous integration (i.e. GitHub actions, ci system) 68 | - `chore`: all changes to the repository that do not fit into any of the above categories 69 | 70 | You can use `pnpm commit` command to help you with your commits 71 | 72 | If you are interested in the detailed specification you can visit or check out the [Angular Commit Message Guidelines](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines). 73 | 74 | ### Steps to PR 75 | 76 | 1. Fork the `react-bluesky` repository and clone your fork 77 | 78 | 2. Create a new branch out of the `main` branch. We follow the convention `[type/scope]`. For example `fix/memcache` or `docs/core`. `type` can be either `docs`, `fix`, `feat`, `build`, or any other conventional commit type. `scope` is just a short id that describes the scope of work. 79 | 80 | 3. Make and commit your changes following the [commit convention](https://github.com/rhinobase/raftyui/blob/main/CONTRIBUTING.md#commit-convention). As you develop, you can run `pnpm nx build [package name]` to make sure everything works as expected. 81 | 82 | ### Tests 83 | 84 | All commits that fix bugs or add features need a test. 85 | 86 | > **Dear Rhinobase team:** Please do not merge code without tests 87 | 88 | ## Want to write a blog post or tutorial 89 | 90 | That would be amazing! Reach out to the core team here: . We would love to support you in any way we can. 91 | 92 | ## License 93 | 94 | By contributing your code to the `react-bluesky` GitHub repository, you agree to license your contribution under the MIT license. 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rhinobase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bsky-react-post 2 | 3 | bsky-react-post allows you to embed Bluesky posts in your React application when using Next.js, Vite, and more. 4 | 5 | For documentation visit [bsky-react-post.rhinobase.io](https://bsky-react-post.rhinobase.io). 6 | 7 | ## Contributing 8 | 9 | Visit our [contributing docs](https://github.com/rhinobase/react-bluesky/blob/main/CONTRIBUTING.md). 10 | 11 | ## Credits 12 | 13 | - This project would not have been possible without the work of [Vercel](https://github.com/vercel) on the [`react-tweet` package](https://github.com/vercel/react-tweet). 14 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Post } from "bsky-react-post/server"; 2 | import type { PropsWithChildren } from "react"; 3 | 4 | export default function HomeLayout(props: PropsWithChildren) { 5 | return ( 6 | <> 7 |
8 | 9 |
10 | {props.children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/(home)/page.mdx: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | `bsky-react-post` allows you to embed posts in your React application when using Next.js, Vite, and more. Posts can be rendered statically, preventing the need to include an iframe and additional client-side JavaScript. 4 | 5 | You can try it out in the [live playground](/playground). 6 | 7 | This library is fully compatible with React Server Components. [Learn more](https://nextjs.org/docs/getting-started/react-essentials#server-components). 8 | 9 | ## Installation 10 | 11 | Install `bsky-react-post` using your package manager of choice: 12 | 13 | 14 | 15 | ```bash {{ title: 'npm' }} 16 | npm install bsky-react-post 17 | ``` 18 | 19 | ```bash {{ title: 'yarn' }} 20 | yarn add bsky-react-post 21 | ``` 22 | 23 | ```bash {{ title: 'pnpm' }} 24 | pnpm add bsky-react-post 25 | ``` 26 | 27 | 28 | 29 | Now follow the usage instructions for your framework or builder: 30 | 31 | - [Next.js](/next) 32 | - [Vite](/vite) 33 | 34 | > **Important**: Before going to production, we recommend [enabling cache for the Bluesky API](#enabling-cache-for-the-bluesky-api) as server IPs might get rate limited by Bluesky. 35 | 36 | ## Choosing a theme 37 | 38 | The [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) CSS media feature is used to select the theme of the post. 39 | 40 | ### Toggling theme manually 41 | 42 | The closest `data-theme` attribute on a parent element can determine the theme of the post. You can set it to `light` or `dark`, like so: 43 | 44 | ```tsx 45 |
46 | 47 |
48 | ``` 49 | 50 | Alternatively, a parent with the class `light` or `dark` will also work: 51 | 52 | ```tsx 53 |
54 | 55 |
56 | ``` 57 | 58 | ### Updating the theme 59 | 60 | In CSS Modules, you can use the `:global` selector to update the CSS variables used by themes: 61 | 62 | ```css 63 | .my-class :global(.bsky-react-post-theme) { 64 | --post-body-font-size: 1rem; 65 | } 66 | ``` 67 | 68 | For Global CSS the usage of `:global` is not necessary. 69 | 70 | ## Enabling cache for the Bluesky API 71 | 72 | Rendering posts requires making a call to Bluesky's syndication API. Getting rate limited by that API is very hard but it's possible if you're relying only on the endpoint we provide for SWR (`bsky-react-post.rhinobase.io/api/post`) as the IPs of the server are making many requests to the syndication API. This also applies to RSC where the API endpoint is not required but the server is still making the request from the same IP. 73 | 74 | To prevent this, you can use a db like Redis to cache the posts. If you're using Next.js then using [`unstable_cache`](/next#enabling-cache) works too. 75 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/api-reference/page.mdx: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | This is the reference for the utility functions that `bsky-react-post` provides for [building your own post components](/custom-theme) or simply fetching a post. Navigate to the docs for the [Bluesky theme](/bluesky-theme) if you want to render the existing Post components instead. 4 | 5 | ## `fetchPost` 6 | 7 | ```tsx 8 | function fetchPost( 9 | config: PostHandleProps, 10 | fetchOptions?: RequestInit 11 | ): Promise; 12 | ``` 13 | 14 | Fetches and returns a [`Post`](https://github.com/rhinobase/react-bluesky/blob/main/packages/core/src/api.ts), it returns information about the post: 15 | 16 | - **post** - `Post`: The post data. 17 | - **parent** - `Post` (Optional): The parent post. 18 | - **replies** - `Post[]` (Optional): The replies to the post. 19 | 20 | ## `usePost` 21 | 22 | > If your app supports React Server Components, use [`fetchPost`](#fetchpost) instead. 23 | 24 | ```tsx 25 | import { usePost } from "bsky-react-post"; 26 | 27 | const usePost: ( 28 | options: PostHandleWithApiUrlProps & { fetchOptions?: RequestInit } 29 | ) => { 30 | isLoading: boolean; 31 | data: thread | null | undefined; 32 | error: any; 33 | }; 34 | ``` 35 | 36 | SWR hook for fetching a post in the browser. It accepts the following parameters: 37 | 38 | - **handle** - `string`: the post handle. For example in `https://bsky.app/profile/adima7.bsky.social/post/3laq6uzwjbc2t` the handle is `adima7.bsky.social`. 39 | - **did** - `string`: the post DID. For example in `at://did:plc:xdwatsttsxnl5h65mf3ddxbq/app.bsky.feed.post/3laq6uzwjbc2t` the post DID is `did:plc:xdwatsttsxnl5h65mf3ddxbq`. 40 | - **id** - `string`: the post ID. For example in `https://bsky.app/profile/adima7.bsky.social/post/3laq6uzwjbc2t` the post ID is `3laq6uzwjbc2t`. 41 | - **apiUrl** - `string` (Optional): the API URL to fetch the post from. Defaults to `https://bsky-react-post.rhinobase.io/api/post?handle=:handle&id=:id&did=:did`. 42 | - **fetchOptions** - `RequestInit` (Optional): options to pass to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch). Try to pass down a reference to the same object to avoid unnecessary re-renders. 43 | 44 | You can either provide `handle` and `id` or `did` and `id` to fetch the post. If you provide `apiUrl`, then `handle`, `id`, and `did` will be ignored. 45 | 46 | We highly recommend adding your own API endpoint in `apiUrl` for production: 47 | 48 | ```ts 49 | const post = usePost({ apiUrl: id && `/api/post/${id}` }); 50 | ``` 51 | 52 | It's likely you'll never use this hook directly, and `apiUrl` is passed as a prop to a component instead: 53 | 54 | ```tsx 55 | 56 | ``` 57 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/bluesky-theme/api-reference/page.mdx: -------------------------------------------------------------------------------- 1 | ## API Reference 2 | 3 | ### `Post` 4 | 5 | ```tsx 6 | import { Post } from "bsky-react-post"; 7 | ``` 8 | 9 | ```tsx 10 | 11 | ``` 12 | 13 | Fetches and renders the post. It accepts the following props: 14 | 15 | - **handle** - `string`: the post handle. For example in `https://bsky.app/profile/adima7.bsky.social/post/3laq6uzwjbc2t` the handle is `adima7.bsky.social`. 16 | - **did** - `string`: the post DID. For example in `at://did:plc:xdwatsttsxnl5h65mf3ddxbq/app.bsky.feed.post/3laq6uzwjbc2t` the post DID is `did:plc:xdwatsttsxnl5h65mf3ddxbq`. 17 | - **id** - `string`: the post ID. For example in `https://bsky.app/profile/adima7.bsky.social/post/3laq6uzwjbc2t` the post ID is `3laq6uzwjbc2t`. 18 | - **apiUrl** - `string` (Optional): the API URL to fetch the post from. Defaults to `https://bsky-react-post.rhinobase.io/api/post?handle=:handle&id=:id&did=:did`. 19 | - **fallback** - `ReactNode`: The fallback component to render while the post is loading. Defaults to `PostSkeleton`. 20 | - **onError** - `(error?: any) => any`: The returned error will be sent to the `PostNotFound` component. 21 | - **components** - `PostComponents`: Components to replace the default post components. 22 | - **fetchOptions** - `RequestInit` (Optional): options to pass to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch). Try to pass down a reference to the same object to avoid unnecessary re-renders. 23 | 24 | If the environment where `Post` is used does not support React Server Components then it will work with [SWR](https://swr.vercel.app/) instead and the post will be fetched from `https://bsky-react-post.rhinobase.io/api/post`, which is CORS friendly. 25 | 26 | We highly recommend adding your own API route to fetch the post in production (as we cannot guarantee our IP will not get limited). You can do it by using the `apiUrl` prop: 27 | 28 | ```tsx 29 | 30 | ``` 31 | 32 | > Note: `apiUrl` does nothing if the Post is rendered in a server component because it can fetch directly from Bluesky's CDN. 33 | 34 | Here's a good example of how to setup your own API route: 35 | 36 | 37 | 38 | ```ts 39 | import type { VercelRequest, VercelResponse } from "@vercel/node"; 40 | import { fetchPost } from "bsky-react-post/api"; 41 | 42 | const handler = async (req: VercelRequest, res: VercelResponse) => { 43 | const postId = req.query.post; 44 | 45 | if (req.method !== "GET" || typeof postId !== "string") { 46 | res.status(400).json({ error: "Bad Request." }); 47 | return; 48 | } 49 | 50 | try { 51 | const post = await fetchPost(postId); 52 | res.status(post ? 200 : 404).json({ data: post ?? null }); 53 | } catch (error) { 54 | console.error(error); 55 | res.status(400).json({ error: error.message ?? "Bad request." }); 56 | } 57 | }; 58 | 59 | export default handler; 60 | ``` 61 | 62 | 63 | 64 | Something similar can be done with Next.js API Routes or Route Handlers. 65 | 66 | ### `EmbeddedPost` 67 | 68 | ```tsx 69 | import { EmbeddedPost } from "bsky-react-post"; 70 | ``` 71 | 72 | Renders a post. It accepts the following props: 73 | 74 | - **thread** - `AppBskyFeedDefs.ThreadViewPost`: the post data, as returned by `fetchPost`. Required. 75 | 76 | ### `PostSkeleton` 77 | 78 | ```tsx 79 | import { PostSkeleton } from "bsky-react-post"; 80 | ``` 81 | 82 | A post skeleton useful for loading states. 83 | 84 | ### `PostNotFound` 85 | 86 | ```tsx 87 | import { PostNotFound } from "bsky-react-post"; 88 | ``` 89 | 90 | A post not found component. It accepts the following props: 91 | 92 | - **error** - `any`: the error that was thrown when fetching the post. Not required. 93 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/bluesky-theme/page.mdx: -------------------------------------------------------------------------------- 1 | # Bluesky Theme 2 | 3 | This is the theme you'll see in [embed.bsky.app](https://embed.bsky.app/) and the default theme included in `bsky-react-post`. 4 | 5 | ## Usage 6 | 7 | In any component, import `Post` from `bsky-react-post` and use it like so: 8 | 9 | ```tsx 10 | import { Post } from "bsky-react-post"; 11 | 12 | export default function Page() { 13 | return ; 14 | } 15 | ``` 16 | 17 | ## Troubleshooting 18 | 19 | Currently, `bsky-react-post` uses CSS Modules to scope the CSS of each component, so the bundler where it's used needs to support CSS Modules. If you get issues about your bundler not recognizing CSS Modules, please open an issue as we would like to know how well supported this is. 20 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/layout.tsx: -------------------------------------------------------------------------------- 1 | import glob from "fast-glob"; 2 | import type { PropsWithChildren } from "react"; 3 | import { Layout } from "../../components/Layout"; 4 | import type { Section } from "../../components/SectionProvider"; 5 | 6 | export default async function RootLayout(props: PropsWithChildren) { 7 | const pages = await glob("**/*.mdx", { cwd: "./app/(docs)" }); 8 | const allSectionsEntries = (await Promise.all( 9 | pages.map(async (filename) => [ 10 | `/${filename.replace(/(^|\/)page\.mdx$/, "")}`, 11 | (await import(`./${filename}`)).sections, 12 | ]), 13 | )) as Array<[string, Array
]>; 14 | const allSections = Object.fromEntries(allSectionsEntries); 15 | 16 | return {props.children}; 17 | } 18 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/next/page.mdx: -------------------------------------------------------------------------------- 1 | # Next.js 2 | 3 | ## Installation 4 | 5 | > Next.js 13.2.1 or higher is required in order to use `bsky-react-post`. 6 | 7 | Follow the [installation docs in the Introduction](/#installation). 8 | 9 | ## Usage 10 | 11 | In any component, import `Post` from `bsky-react-post` and use it like so: 12 | 13 | ```tsx 14 | import { Post } from "bsky-react-post"; 15 | 16 | export default function Page() { 17 | return ; 18 | } 19 | ``` 20 | 21 | `Post` works differently depending on where it's used. If it's used in the App Router it will fetch the post in the server. If it's used in the pages directory it will fetch the post in the client with [SWR](https://swr.vercel.app/). 22 | 23 | You can learn more about `Post` in the [Bluesky theme docs](/bluesky-theme). 24 | 25 | ### Troubleshooting 26 | 27 | If you see an error saying that CSS can't be imported from `node_modules` in the `pages` directory. Add the following config to `next.config.js`: 28 | 29 | ```js 30 | transpilePackages: ["bsky-react-post"]; 31 | ``` 32 | 33 | The error won't happen if the App Router is enabled, where [Next.js supports CSS imports from `node_modules`](https://github.com/vercel/next.js/discussions/27953#discussioncomment-3978605). 34 | 35 | ### Enabling cache 36 | 37 | It's recommended to enable cache for the Bluesky API if you intend to go to production. This is how you can do it with [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache): 38 | 39 | ```tsx 40 | import { Suspense } from "react"; 41 | import { unstable_cache } from "next/cache"; 42 | import { PostSkeleton, EmbeddedPost, PostNotFound } from "bsky-react-post"; 43 | import { fetchPost } from "bsky-react-post/api"; 44 | 45 | const getPost = unstable_cache(async (id: string) => fetchPost(id), ["post"], { 46 | revalidate: 3600 * 24, 47 | }); 48 | 49 | const PostPage = async ({ id }: { id: string }) => { 50 | try { 51 | const thread = await getPost(id); 52 | return thread ? : ; 53 | } catch (error) { 54 | console.error(error); 55 | return ; 56 | } 57 | }; 58 | 59 | const Page = ({ params }: { params: { post: string } }) => ( 60 | }> 61 | 62 | 63 | ); 64 | 65 | export default Page; 66 | ``` 67 | 68 | This can prevent getting your server IPs rate limited if they are making too many requests to the Bluesky API. 69 | 70 | ## Advanced usage 71 | 72 | ### Manual data fetching 73 | 74 | You can use the [`fetchPost`](/api-reference#fetchpost) function from `bsky-react-post/api` to fetch the post manually. This is useful for SSG pages and for other [Next.js data fetching methods](https://nextjs.org/docs/basic-features/data-fetching/overview) in the `pages` directory. 75 | 76 | For example, using `getStaticProps` in `pages/[post].tsx` to fetch the post and send it as props to the page component: 77 | 78 | ```tsx 79 | import { useRouter } from "next/router"; 80 | import { fetchPost } from "bsky-react-post/api"; 81 | import { PostSkeleton, EmbeddedPost } from "bsky-react-post"; 82 | 83 | export async function getStaticProps({ params }: { params: { post: string } }) { 84 | const postId = params.post; 85 | 86 | try { 87 | const thread = await fetchPost({ 88 | handle: "", 89 | id: postId, 90 | }); 91 | return thread ? { props: { thread } } : { notFound: true }; 92 | } catch (error) { 93 | return { notFound: true }; 94 | } 95 | } 96 | 97 | export async function getStaticPaths() { 98 | return { paths: [], fallback: true }; 99 | } 100 | 101 | export default function Page({ thread }: { thread: any }) { 102 | const { isFallback } = useRouter(); 103 | return isFallback ? : ; 104 | } 105 | ``` 106 | -------------------------------------------------------------------------------- /apps/docs/app/(docs)/vite/page.mdx: -------------------------------------------------------------------------------- 1 | # Vite 2 | 3 | ## Installation 4 | 5 | Follow the [installation docs in the Introduction](/#installation). 6 | 7 | ## Usage 8 | 9 | In any component, import `Post` from `bsky-react-post` and use it like so: 10 | 11 | ```tsx 12 | import { Post } from "bsky-react-post"; 13 | 14 | export const IndexPage = () => ( 15 | 16 | ); 17 | ``` 18 | 19 | You can learn more about `Post` in the [Post theme docs](/bluesky-theme). 20 | -------------------------------------------------------------------------------- /apps/docs/app/api/post/route.ts: -------------------------------------------------------------------------------- 1 | import type { PostHandleProps } from "bsky-react-post"; 2 | import { fetchPost } from "bsky-react-post/api"; 3 | import cors from "edge-cors"; 4 | import { type NextRequest, NextResponse } from "next/server"; 5 | 6 | export const fetchCache = "only-cache"; 7 | 8 | export async function GET(req: NextRequest) { 9 | try { 10 | let config: PostHandleProps; 11 | 12 | if (req.nextUrl.searchParams.has("handle")) { 13 | config = { 14 | handle: req.nextUrl.searchParams.get("handle") as string, 15 | id: req.nextUrl.searchParams.get("id") as string, 16 | }; 17 | } else if (req.nextUrl.searchParams.has("did")) { 18 | config = { 19 | did: req.nextUrl.searchParams.get("did") as string, 20 | id: req.nextUrl.searchParams.get("id") as string, 21 | }; 22 | } else { 23 | throw new Error("Invalid Bluesky Embed Config"); 24 | } 25 | 26 | const post = await fetchPost(config); 27 | 28 | return cors( 29 | req, 30 | NextResponse.json({ data: post ?? null }, { status: post ? 200 : 404 }), 31 | ); 32 | } catch (error) { 33 | console.error(error); 34 | return cors( 35 | req, 36 | NextResponse.json( 37 | { error: error instanceof Error ? error.message : "Bad request." }, 38 | { status: 400 }, 39 | ), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/docs/app/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .bsky_mdx { 6 | max-width: 40rem; 7 | margin-left: auto; 8 | margin-right: auto; 9 | } 10 | 11 | @media (min-width: 1024px) { 12 | .bsky_mdx { 13 | max-width: 50rem; 14 | margin-left: calc(50% - min(50%, 33rem)); 15 | margin-right: calc(50% - min(50%, 33rem)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/docs/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleAnalytics } from "@next/third-parties/google"; 2 | import type { PropsWithChildren } from "react"; 3 | import "./global.css"; 4 | import type { Metadata } from "next"; 5 | import { Inter } from "next/font/google"; 6 | import { Providers } from "./providers"; 7 | 8 | // If loading a variable font, you don't need to specify the font weight 9 | const inter = Inter({ 10 | subsets: ["latin"], 11 | display: "swap", 12 | }); 13 | 14 | export const metadata: Metadata = { 15 | title: { 16 | default: "bsky-react-post", 17 | template: "%s – bsky-react-post", 18 | }, 19 | description: "Embed Bluesky posts in your React applications.", 20 | twitter: { 21 | card: "summary_large_image", 22 | site: "bsky-react-post.rhinobase.io", 23 | creator: "@rhinobaseio", 24 | }, 25 | openGraph: { 26 | title: { 27 | default: "Bsky React Post", 28 | template: "%s – bsky-react-post", 29 | }, 30 | description: "Embed Bluesky posts in your React applications.", 31 | type: "website", 32 | images: { 33 | url: "https://res.cloudinary.com/rhinobase/image/upload/f_auto,q_auto/v1/bsky-react-post/sm1syi7qzxaoamjpjrd9", 34 | }, 35 | }, 36 | authors: { 37 | name: "Rhinobase Team", 38 | url: "https://github.com/rhinobase", 39 | }, 40 | icons: [ 41 | { 42 | rel: "icon", 43 | type: "image/png", 44 | sizes: "16x16", 45 | url: "/favicon-16x16.png", 46 | }, 47 | { 48 | rel: "icon", 49 | type: "image/png", 50 | sizes: "32x32", 51 | url: "/favicon-32x32.png", 52 | }, 53 | ], 54 | }; 55 | 56 | // Google Analytics ID 57 | const GMT_ID = "G-4R4ZW45190"; 58 | 59 | export default function RootLayout(props: PropsWithChildren) { 60 | return ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | {props.children} 69 | 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /apps/docs/app/playground/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; 2 | import { Button, eventHandler } from "@rafty/ui"; 3 | import { useEffect, useState } from "react"; 4 | 5 | export type CopyButton = { 6 | data?: string; 7 | }; 8 | 9 | export function CopyButton({ data }: CopyButton) { 10 | const [copyCount, setCopyCount] = useState(0); 11 | const copied = copyCount > 0; 12 | 13 | useEffect(() => { 14 | if (copyCount > 0) { 15 | const timeout = setTimeout(() => setCopyCount(0), 2000); 16 | return () => { 17 | clearTimeout(timeout); 18 | }; 19 | } 20 | }, [copyCount]); 21 | 22 | const handleCopy = eventHandler(() => { 23 | if (data) 24 | window.navigator.clipboard.writeText(data).then(() => { 25 | setCopyCount((count) => count + 1); 26 | }); 27 | }); 28 | 29 | const Icon = copied ? CheckIcon : DocumentDuplicateIcon; 30 | 31 | return ( 32 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/docs/app/playground/Highlight.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Skeleton } from "@rafty/ui"; 3 | import { useTheme } from "next-themes"; 4 | import { useShiki } from "../../providers"; 5 | 6 | export type CodeHighlighter = { content: string; language: string }; 7 | 8 | export function CodeHighlighter({ content, language }: CodeHighlighter) { 9 | const highlighter = useShiki(); 10 | const { resolvedTheme } = useTheme(); 11 | 12 | if (!highlighter) return ; 13 | 14 | const html = highlighter.codeToHtml(content, { 15 | lang: language, 16 | theme: 17 | resolvedTheme === "light" 18 | ? "github-light-default" 19 | : "github-dark-default", 20 | }); 21 | 22 | return ( 23 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/docs/app/playground/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import type { PropsWithChildren } from "react"; 3 | import { ShikiProvider } from "../../providers"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Playground", 7 | description: "Try out the bsky-react-post library.", 8 | }; 9 | 10 | export default function PlaygroundLayout(props: PropsWithChildren) { 11 | return {props.children}; 12 | } 13 | -------------------------------------------------------------------------------- /apps/docs/app/playground/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ArrowDownIcon } from "@heroicons/react/24/outline"; 3 | import { InputField } from "@rafty/ui"; 4 | import { ThemeToggle } from "apps/docs/components/ThemeToggle"; 5 | import { Post, PostError, type PostHandleProps } from "bsky-react-post"; 6 | import Image from "next/image"; 7 | import Link from "next/link"; 8 | import { useState } from "react"; 9 | import BlueskyLogo from "../../public/bluesky.svg"; 10 | import { CopyButton } from "./CopyButton"; 11 | import { CodeHighlighter } from "./Highlight"; 12 | 13 | type PostType = PostHandleProps & { default?: boolean }; 14 | 15 | const DEFAULT_POST: PostType = { 16 | handle: "adima7.bsky.social", 17 | id: "3laq6uzwjbc2t", 18 | default: true, 19 | }; 20 | 21 | export default function PlaygroundPage() { 22 | const [config, setConfig] = useState(DEFAULT_POST); 23 | const [error, setError] = useState(null); 24 | 25 | const handleChange = (e: React.FocusEvent) => { 26 | const value = e.target.value; 27 | 28 | try { 29 | let tmpConfig: PostType = DEFAULT_POST; 30 | 31 | if (value !== "") { 32 | try { 33 | const urlp = new URL(value); 34 | if (!urlp.hostname.endsWith("bsky.app")) { 35 | throw new Error("Invalid hostname"); 36 | } 37 | const split = urlp.pathname.slice(1).split("/"); 38 | if (split.length < 4) { 39 | throw new Error("Invalid pathname"); 40 | } 41 | const [profile, didOrHandle, type, rkey] = split; 42 | if (profile !== "profile" || type !== "post") { 43 | throw new Error("Invalid profile or type"); 44 | } 45 | 46 | if (!didOrHandle.startsWith("did:")) { 47 | tmpConfig = { 48 | did: didOrHandle, 49 | id: rkey, 50 | }; 51 | } else { 52 | tmpConfig = { 53 | handle: didOrHandle, 54 | id: rkey, 55 | }; 56 | } 57 | } catch (err) { 58 | console.error(err); 59 | throw new Error("Invalid Bluesky URL"); 60 | } 61 | } 62 | 63 | setConfig(tmpConfig); 64 | setError(null); 65 | } catch (err) { 66 | console.error(err); 67 | setError(err instanceof Error ? err.message : "Invalid Bluesky URL"); 68 | } 69 | }; 70 | 71 | const content = 72 | "did" in config && config.did 73 | ? `` 74 | : ``; 75 | 76 | return ( 77 |
78 |
79 | 80 | 81 | Bluesky 88 | 89 |

90 | Embed a Bluesky Post in React 91 |

92 | 97 | 98 | {!config.default && error === null && ( 99 |
100 |
101 | 102 |
103 | 104 |
105 | )} 106 | {error == null ? : } 107 |
108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /apps/docs/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ThemeProvider } from "next-themes"; 3 | 4 | export function Providers({ children }: { children: React.ReactNode }) { 5 | return ( 6 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/docs/components/Code.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { CheckIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; 3 | import { 4 | Button, 5 | Tab, 6 | TabContent, 7 | TabList, 8 | TabTrigger, 9 | classNames, 10 | } from "@rafty/ui"; 11 | import { 12 | Children, 13 | type HTMLAttributes, 14 | type PropsWithChildren, 15 | createContext, 16 | isValidElement, 17 | useContext, 18 | useEffect, 19 | useRef, 20 | useState, 21 | } from "react"; 22 | import { create } from "zustand"; 23 | import { Tag } from "./Tag"; 24 | 25 | const languageNames: Record = { 26 | js: "JavaScript", 27 | ts: "TypeScript", 28 | javascript: "JavaScript", 29 | typescript: "TypeScript", 30 | php: "PHP", 31 | python: "Python", 32 | ruby: "Ruby", 33 | go: "Go", 34 | }; 35 | 36 | type getPanelTitle = { 37 | title?: string; 38 | language?: string; 39 | }; 40 | 41 | function getPanelTitle({ title, language }: getPanelTitle) { 42 | if (title) { 43 | return title; 44 | } 45 | if (language && language in languageNames) { 46 | return languageNames[language]; 47 | } 48 | return "Code"; 49 | } 50 | 51 | type CopyButton = { code: string }; 52 | 53 | function CopyButton({ code }: CopyButton) { 54 | const [copyCount, setCopyCount] = useState(0); 55 | const copied = copyCount > 0; 56 | 57 | useEffect(() => { 58 | if (copyCount > 0) { 59 | const timeout = setTimeout(() => setCopyCount(0), 2000); 60 | return () => { 61 | clearTimeout(timeout); 62 | }; 63 | } 64 | }, [copyCount]); 65 | 66 | return ( 67 |
68 | 95 |
96 | ); 97 | } 98 | 99 | type CodePanelHeader = { tag?: string; label?: string }; 100 | 101 | function CodePanelHeader({ tag, label }: CodePanelHeader) { 102 | if (!tag && !label) { 103 | return null; 104 | } 105 | 106 | return ( 107 |
108 | {tag && ( 109 |
110 | {tag} 111 |
112 | )} 113 | {tag && label && ( 114 | 115 | )} 116 | {label && ( 117 | 118 | {label} 119 | 120 | )} 121 |
122 | ); 123 | } 124 | 125 | type CodePanel = PropsWithChildren<{ 126 | tag?: string; 127 | label?: string; 128 | code?: string; 129 | }>; 130 | 131 | function CodePanel({ children, tag, label, code }: CodePanel) { 132 | const child = Children.only(children); 133 | 134 | if (isValidElement(child)) { 135 | tag = child.props.tag ?? tag; 136 | label = child.props.label ?? label; 137 | code = child.props.code ?? code; 138 | } 139 | 140 | if (!code) { 141 | throw new Error( 142 | "`CodePanel` requires a `code` prop, or a child with a `code` prop.", 143 | ); 144 | } 145 | 146 | return ( 147 |
148 | 149 |
150 |
151 |           {children}
152 |         
153 | 154 |
155 |
156 | ); 157 | } 158 | 159 | type CodeGroupHeader = PropsWithChildren<{ 160 | title: string; 161 | }>; 162 | 163 | function CodeGroupHeader({ title, children }: CodeGroupHeader) { 164 | const hasTabs = Children.count(children) > 1; 165 | 166 | if (!title && !hasTabs) { 167 | return null; 168 | } 169 | 170 | return ( 171 |
172 | {title && ( 173 |

179 | {title} 180 |

181 | )} 182 | {hasTabs && ( 183 | 184 | {Children.map(children, (child) => ( 185 | 189 | {isValidElement(child) 190 | ? getPanelTitle(child.props || {}) 191 | : getPanelTitle({})} 192 | 193 | ))} 194 | 195 | )} 196 |
197 | ); 198 | } 199 | 200 | type CodeGroupPanels = CodePanel; 201 | 202 | function CodeGroupPanels({ children, ...props }: CodeGroupPanels) { 203 | const hasTabs = Children.count(children) > 1; 204 | 205 | if (hasTabs) { 206 | return ( 207 | <> 208 | {Children.map(children, (child) => ( 209 | 213 | {child} 214 | 215 | ))} 216 | 217 | ); 218 | } 219 | 220 | return {children}; 221 | } 222 | 223 | function usePreventLayoutShift() { 224 | const positionRef = useRef(null); 225 | const rafRef = useRef(); 226 | 227 | useEffect(() => { 228 | return () => { 229 | if (typeof rafRef.current !== "undefined") { 230 | window.cancelAnimationFrame(rafRef.current); 231 | } 232 | }; 233 | }, []); 234 | 235 | return { 236 | positionRef, 237 | preventLayoutShift(callback: () => void) { 238 | if (!positionRef.current) { 239 | return; 240 | } 241 | 242 | const initialTop = positionRef.current.getBoundingClientRect().top; 243 | 244 | callback(); 245 | 246 | rafRef.current = window.requestAnimationFrame(() => { 247 | const newTop = 248 | positionRef.current?.getBoundingClientRect().top ?? initialTop; 249 | window.scrollBy(0, newTop - initialTop); 250 | }); 251 | }, 252 | }; 253 | } 254 | 255 | const usePreferredLanguageStore = create<{ 256 | preferredLanguages: Array; 257 | addPreferredLanguage: (language: string) => void; 258 | }>()((set) => ({ 259 | preferredLanguages: [], 260 | addPreferredLanguage: (language) => 261 | set((state) => ({ 262 | preferredLanguages: [ 263 | ...state.preferredLanguages.filter( 264 | (preferredLanguage) => preferredLanguage !== language, 265 | ), 266 | language, 267 | ], 268 | })), 269 | })); 270 | 271 | function useTabGroupProps(availableLanguages: Array) { 272 | const { preferredLanguages, addPreferredLanguage } = 273 | usePreferredLanguageStore(); 274 | const [selectedIndex, setSelectedIndex] = useState(0); 275 | const activeLanguage = [...availableLanguages].sort( 276 | (a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a), 277 | )[0]; 278 | const languageIndex = availableLanguages.indexOf(activeLanguage); 279 | const newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex; 280 | if (newSelectedIndex !== selectedIndex) { 281 | setSelectedIndex(newSelectedIndex); 282 | } 283 | 284 | const { positionRef, preventLayoutShift } = usePreventLayoutShift(); 285 | 286 | return { 287 | as: "div" as const, 288 | ref: positionRef, 289 | selectedIndex, 290 | onChange: (newSelectedIndex: number) => { 291 | preventLayoutShift(() => 292 | addPreferredLanguage(availableLanguages[newSelectedIndex]), 293 | ); 294 | }, 295 | }; 296 | } 297 | 298 | const CodeGroupContext = createContext(false); 299 | 300 | export type CodeGroup = CodeGroupPanels & { 301 | title: string; 302 | }; 303 | 304 | export function CodeGroup({ children, title, ...props }: CodeGroup) { 305 | const languages = 306 | Children.map(children, (child) => { 307 | const title = getPanelTitle(isValidElement(child) ? child.props : {}); 308 | return title; 309 | }) ?? []; 310 | const tabGroupProps = useTabGroupProps(languages); 311 | const hasTabs = Children.count(children) > 1; 312 | 313 | const containerClassName = 314 | "not-prose my-6 overflow-hidden rounded-2xl bg-secondary-900 shadow-md dark:ring-1 dark:ring-white/10"; 315 | const header = {children}; 316 | const panels = {children}; 317 | 318 | return ( 319 | 320 | {hasTabs ? ( 321 | 326 | {header} 327 | {panels} 328 | 329 | ) : ( 330 |
331 | {header} 332 | {panels} 333 |
334 | )} 335 |
336 | ); 337 | } 338 | 339 | export type Code = HTMLAttributes; 340 | 341 | export function Code({ children, ...props }: Code) { 342 | const isGrouped = useContext(CodeGroupContext); 343 | 344 | if (isGrouped) { 345 | if (typeof children !== "string") { 346 | throw new Error( 347 | "`Code` children must be a string when nested inside a `CodeGroup`.", 348 | ); 349 | } 350 | // biome-ignore lint/security/noDangerouslySetInnerHtml: for showing code string 351 | return ; 352 | } 353 | 354 | return {children}; 355 | } 356 | 357 | export type Pre = CodeGroup; 358 | 359 | export function Pre({ children, ...props }: Pre) { 360 | const isGrouped = useContext(CodeGroupContext); 361 | 362 | if (isGrouped) { 363 | return children; 364 | } 365 | 366 | return {children}; 367 | } 368 | -------------------------------------------------------------------------------- /apps/docs/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; 3 | import { Button } from "@rafty/ui"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | import { NAVIGATION } from "./Navigation"; 7 | 8 | type PageLink = { 9 | label: string; 10 | page: { href: string; title: string }; 11 | previous?: boolean; 12 | }; 13 | 14 | function PageLink({ label, page, previous = false }: PageLink) { 15 | return ( 16 | <> 17 | 18 | 35 | 36 | 42 | {page.title} 43 | 44 | 45 | ); 46 | } 47 | 48 | function PageNavigation() { 49 | const pathname = usePathname(); 50 | const allPages = NAVIGATION.flatMap(({ links }) => links); 51 | const currentPageIndex = allPages.findIndex(({ href }) => href === pathname); 52 | 53 | if (currentPageIndex === -1) { 54 | return null; 55 | } 56 | 57 | const previousPage = allPages[currentPageIndex - 1]; 58 | const nextPage = allPages[currentPageIndex + 1]; 59 | 60 | if (!previousPage && !nextPage) { 61 | return null; 62 | } 63 | 64 | return ( 65 |
66 | {previousPage && ( 67 |
68 | 69 |
70 | )} 71 | {nextPage && ( 72 |
73 | 74 |
75 | )} 76 |
77 | ); 78 | } 79 | 80 | export function Footer() { 81 | return ( 82 |
83 | 84 |

85 | © {new Date().getFullYear()} rhinobase, Inc. All rights reserved. 86 |

87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /apps/docs/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from "@rafty/ui"; 2 | import { motion, useScroll, useTransform } from "framer-motion"; 3 | import Link from "next/link"; 4 | import { type HTMLAttributes, type PropsWithChildren, forwardRef } from "react"; 5 | import { 6 | MobileNavigation, 7 | SOCIALS, 8 | useIsInsideMobileNavigation, 9 | } from "./MobileNavigation"; 10 | import { MobileSearch, Search } from "./Search"; 11 | import { ThemeToggle } from "./ThemeToggle"; 12 | import { useDrawerDialog } from "./store"; 13 | 14 | type TopLevelNavItem = PropsWithChildren<{ 15 | href: string; 16 | }>; 17 | 18 | function TopLevelNavItem({ href, children }: TopLevelNavItem) { 19 | return ( 20 |
  • 21 | 25 | {children} 26 | 27 |
  • 28 | ); 29 | } 30 | 31 | export type Header = Pick, "className">; 32 | 33 | export const Header = forwardRef(function Header( 34 | { className }, 35 | forwardedRef, 36 | ) { 37 | const { isOpen: mobileNavIsOpen } = useDrawerDialog(); 38 | const isInsideMobileNavigation = useIsInsideMobileNavigation(); 39 | 40 | const { scrollY } = useScroll(); 41 | const bgOpacityLight = useTransform(scrollY, [0, 72], [0.5, 0.9]); 42 | const bgOpacityDark = useTransform(scrollY, [0, 72], [0.2, 0.8]); 43 | 44 | return ( 45 | 63 |
    70 | 71 |
    72 | 73 | 74 | bsky-react-post 75 | 76 |
    77 | 78 | bsky-react-post 79 | 80 |
    81 | 86 |
    87 |
    88 | 89 | 90 |
    91 | 92 |
    93 |
    94 | 95 | ); 96 | }); 97 | 98 | function SmallPrint() { 99 | return ( 100 |
    101 | {SOCIALS.map(({ name, icon: Icon, link }) => ( 102 | 109 | 113 | {name} 114 | 115 | ))} 116 |
    117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /apps/docs/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { LinkIcon } from "@heroicons/react/24/outline"; 3 | import { useInView } from "framer-motion"; 4 | import Link from "next/link"; 5 | import { 6 | type HTMLAttributes, 7 | type PropsWithChildren, 8 | useEffect, 9 | useRef, 10 | } from "react"; 11 | import { remToPx } from "../lib/remToPx"; 12 | import { useSectionStore } from "./SectionProvider"; 13 | import { Tag } from "./Tag"; 14 | 15 | type Eyebrow = { tag?: string; label?: string }; 16 | 17 | function Eyebrow({ tag, label }: Eyebrow) { 18 | if (!tag && !label) { 19 | return null; 20 | } 21 | 22 | return ( 23 |
    24 | {tag && {tag}} 25 | {tag && label && ( 26 | 27 | )} 28 | {label && ( 29 | 30 | {label} 31 | 32 | )} 33 |
    34 | ); 35 | } 36 | 37 | type Anchor = PropsWithChildren<{ 38 | id: string; 39 | inView: boolean; 40 | }>; 41 | 42 | function Anchor({ id, inView, children }: Anchor) { 43 | return ( 44 | 48 | {inView && ( 49 |
    50 |
    51 | 52 |
    53 |
    54 | )} 55 | {children} 56 | 57 | ); 58 | } 59 | 60 | export type Heading = HTMLAttributes & { 61 | id: string; 62 | tag?: string; 63 | label?: string; 64 | level?: Level; 65 | anchor?: boolean; 66 | }; 67 | 68 | export function Heading({ 69 | children, 70 | tag, 71 | label, 72 | level, 73 | anchor = true, 74 | ...props 75 | }: Heading) { 76 | level = level ?? (2 as Level); 77 | const Component = `h${level}` as "h2" | "h3"; 78 | const ref = useRef(null); 79 | const registerHeading = useSectionStore((s) => s.registerHeading); 80 | 81 | const inView = useInView(ref, { 82 | margin: `${remToPx(-3.5)}px 0px 0px 0px`, 83 | amount: "all", 84 | }); 85 | 86 | useEffect(() => { 87 | if (level === 2) { 88 | registerHeading({ id: props.id, ref, offsetRem: tag || label ? 8 : 6 }); 89 | } 90 | }); 91 | 92 | return ( 93 | <> 94 | 95 | 100 | {anchor ? ( 101 | 102 | {children} 103 | 104 | ) : ( 105 | children 106 | )} 107 | 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /apps/docs/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { motion } from "framer-motion"; 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import { type PropsWithChildren, Suspense } from "react"; 6 | import { Footer } from "./Footer"; 7 | import { Header } from "./Header"; 8 | import { MobileNavigationDialog } from "./MobileNavigation"; 9 | import { Navigation } from "./Navigation"; 10 | import { SearchDialog } from "./Search"; 11 | import { type Section, SectionProvider } from "./SectionProvider"; 12 | 13 | export type Layout = PropsWithChildren<{ 14 | allSections: Record>; 15 | }>; 16 | 17 | export function Layout({ children, allSections }: Layout) { 18 | const pathname = usePathname(); 19 | 20 | return ( 21 | 22 |
    23 | 27 |
    28 |
    29 | 30 | bsky-react-post 31 | 32 |
    33 |
    34 | 35 |
    36 |
    37 |
    38 |
    {children}
    39 |
    40 |
    41 |
    42 | 43 | 44 | 45 | 46 |
    47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /apps/docs/components/MobileNavigation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Bars3Icon } from "@heroicons/react/24/outline"; 3 | import { 4 | Button, 5 | Drawer, 6 | DrawerClose, 7 | DrawerContent, 8 | DrawerOverlay, 9 | } from "@rafty/ui"; 10 | import Link from "next/link"; 11 | import { usePathname } from "next/navigation"; 12 | import { createContext, useContext, useEffect, useRef } from "react"; 13 | import { BsGithub, BsTwitter } from "react-icons/bs"; 14 | import { FaBluesky } from "react-icons/fa6"; 15 | import { Navigation } from "./Navigation"; 16 | import { useDrawerDialog } from "./store"; 17 | 18 | export const SOCIALS = [ 19 | { 20 | name: "Bluesky", 21 | link: "https://bsky.app/profile/adima7.bsky.social", 22 | icon: FaBluesky, 23 | }, 24 | { 25 | name: "Twitter", 26 | link: "https://x.com/rhinobaseio", 27 | icon: BsTwitter, 28 | }, 29 | { 30 | name: "Github", 31 | link: "https://github.com/rhinobase/react-bluesky", 32 | icon: BsGithub, 33 | }, 34 | ]; 35 | 36 | const IsInsideMobileNavigationContext = createContext(false); 37 | 38 | export function MobileNavigationDialog() { 39 | const { isOpen, setOpen } = useDrawerDialog(); 40 | const pathname = usePathname(); 41 | const initialPathname = useRef(pathname).current; 42 | 43 | useEffect(() => { 44 | if (pathname !== initialPathname) { 45 | setOpen(false); 46 | } 47 | }, [pathname, setOpen, initialPathname]); 48 | 49 | return ( 50 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | function Socials() { 67 | return ( 68 |
    69 | {SOCIALS.map(({ name, icon: Icon, link }) => ( 70 | 77 | 81 |

    {name}

    82 | 83 | ))} 84 |
    85 | ); 86 | } 87 | 88 | export function useIsInsideMobileNavigation() { 89 | return useContext(IsInsideMobileNavigationContext); 90 | } 91 | 92 | export function MobileNavigation() { 93 | const { setOpen } = useDrawerDialog(); 94 | 95 | return ( 96 | 97 | 105 | 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /apps/docs/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { classNames } from "@rafty/ui"; 3 | import { AnimatePresence, motion, useIsPresent } from "framer-motion"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | import { 7 | type HTMLAttributes, 8 | type LiHTMLAttributes, 9 | type PropsWithChildren, 10 | useRef, 11 | } from "react"; 12 | import { useIsInsideMobileNavigation } from "../components/MobileNavigation"; 13 | import { useSectionStore } from "../components/SectionProvider"; 14 | import { Tag } from "../components/Tag"; 15 | import { remToPx } from "../lib/remToPx"; 16 | 17 | type NavGroupType = { 18 | title: string; 19 | links: { 20 | title: string; 21 | href: string; 22 | }[]; 23 | }; 24 | 25 | function useInitialValue(value: T, condition = true) { 26 | const initialValue = useRef(value).current; 27 | return condition ? initialValue : value; 28 | } 29 | 30 | function TopLevelNavItem({ 31 | href, 32 | children, 33 | }: PropsWithChildren<{ 34 | href: string; 35 | }>) { 36 | return ( 37 |
  • 38 | 42 | {children} 43 | 44 |
  • 45 | ); 46 | } 47 | 48 | function NavLink({ 49 | href, 50 | children, 51 | tag, 52 | active = false, 53 | isAnchorLink = false, 54 | }: PropsWithChildren<{ 55 | href: string; 56 | tag?: string; 57 | active?: boolean; 58 | isAnchorLink?: boolean; 59 | }>) { 60 | return ( 61 | 72 | {children} 73 | {tag && ( 74 | 75 | {tag} 76 | 77 | )} 78 | 79 | ); 80 | } 81 | 82 | function VisibleSectionHighlight({ 83 | group, 84 | pathname, 85 | }: { 86 | group: NavGroupType; 87 | pathname: string; 88 | }) { 89 | const [sections, visibleSections] = useInitialValue( 90 | [ 91 | useSectionStore((s) => s.sections), 92 | useSectionStore((s) => s.visibleSections), 93 | ], 94 | useIsInsideMobileNavigation(), 95 | ); 96 | 97 | const isPresent = useIsPresent(); 98 | const firstVisibleSectionIndex = Math.max( 99 | 0, 100 | [{ id: "_top" }, ...sections].findIndex( 101 | (section) => section.id === visibleSections[0], 102 | ), 103 | ); 104 | const itemHeight = remToPx(2); 105 | const height = isPresent 106 | ? Math.max(1, visibleSections.length) * itemHeight 107 | : itemHeight; 108 | const top = 109 | group.links.findIndex((link) => link.href === pathname) * itemHeight + 110 | firstVisibleSectionIndex * itemHeight; 111 | 112 | return ( 113 | 121 | ); 122 | } 123 | 124 | function ActivePageMarker({ 125 | group, 126 | pathname, 127 | }: { 128 | group: NavGroupType; 129 | pathname: string; 130 | }) { 131 | const itemHeight = remToPx(2); 132 | const offset = remToPx(0.25); 133 | const activePageIndex = group.links.findIndex( 134 | (link) => link.href === pathname, 135 | ); 136 | const top = offset + activePageIndex * itemHeight; 137 | 138 | return ( 139 | 147 | ); 148 | } 149 | 150 | function NavigationGroup({ 151 | group, 152 | className, 153 | }: { 154 | group: NavGroupType; 155 | } & Pick, "className">) { 156 | // If this is the mobile navigation then we always render the initial 157 | // state, so that the state does not change during the close animation. 158 | // The state will still update when we re-open (re-render) the navigation. 159 | const isInsideMobileNavigation = useIsInsideMobileNavigation(); 160 | const [pathname, sections] = useInitialValue( 161 | [usePathname(), useSectionStore((s) => s.sections)], 162 | isInsideMobileNavigation, 163 | ); 164 | 165 | const isActiveGroup = 166 | group.links.findIndex((link) => link.href === pathname) !== -1; 167 | 168 | return ( 169 |
  • 170 | 174 | {group.title} 175 | 176 |
    177 | 178 | {isActiveGroup && ( 179 | 180 | )} 181 | 182 | 186 | 187 | {isActiveGroup && ( 188 | 189 | )} 190 | 191 |
      192 | {group.links.map((link) => ( 193 | 194 | 195 | {link.title} 196 | 197 | 198 | {link.href === pathname && sections.length > 0 && ( 199 | 201 | role="list" 202 | initial={{ opacity: 0 }} 203 | animate={{ 204 | opacity: 1, 205 | transition: { delay: 0.1 }, 206 | }} 207 | exit={{ 208 | opacity: 0, 209 | transition: { duration: 0.15 }, 210 | }} 211 | > 212 | {sections.map((section) => ( 213 |
    • 214 | 219 | {section.title} 220 | 221 |
    • 222 | ))} 223 |
      224 | )} 225 |
      226 |
      227 | ))} 228 |
    229 |
    230 |
  • 231 | ); 232 | } 233 | 234 | export const NAVIGATION: NavGroupType[] = [ 235 | { 236 | title: "Usage", 237 | links: [ 238 | { title: "Introduction", href: "/" }, 239 | { title: "Next.js", href: "/next" }, 240 | { title: "Vite", href: "/vite" }, 241 | { title: "API Reference", href: "/api-reference" }, 242 | ], 243 | }, 244 | { 245 | title: "Themes", 246 | links: [ 247 | { title: "Bluesky Theme", href: "/bluesky-theme" }, 248 | { title: "API Reference", href: "/bluesky-theme/api-reference" }, 249 | ], 250 | }, 251 | ]; 252 | 253 | export function Navigation(props: HTMLAttributes) { 254 | return ( 255 | 269 | ); 270 | } 271 | -------------------------------------------------------------------------------- /apps/docs/components/Prose.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from "@rafty/ui"; 2 | import type { ComponentPropsWithoutRef, ElementType } from "react"; 3 | 4 | export type Prose = Omit< 5 | ComponentPropsWithoutRef, 6 | "as" 7 | > & { 8 | as?: T; 9 | }; 10 | 11 | export function Prose({ 12 | as, 13 | className, 14 | ...props 15 | }: Prose) { 16 | const Component = as ?? "div"; 17 | 18 | return ( 19 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/docs/components/Search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | type AutocompleteApi, 4 | type AutocompleteCollection, 5 | type AutocompleteState, 6 | createAutocomplete, 7 | } from "@algolia/autocomplete-core"; 8 | import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; 9 | import { 10 | Button, 11 | Dialog, 12 | DialogContent, 13 | DialogOverlay, 14 | InputField, 15 | Kbd, 16 | classNames, 17 | } from "@rafty/ui"; 18 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 19 | import { 20 | Fragment, 21 | type SVGProps, 22 | forwardRef, 23 | useEffect, 24 | useId, 25 | useRef, 26 | useState, 27 | } from "react"; 28 | import Highlighter from "react-highlight-words"; 29 | import type { Result } from "../mdx/search.mjs"; 30 | import { NAVIGATION } from "./Navigation"; 31 | import { useSearchDialog } from "./store"; 32 | 33 | type EmptyObject = Record; 34 | 35 | type Autocomplete = AutocompleteApi< 36 | Result, 37 | React.SyntheticEvent, 38 | React.MouseEvent, 39 | React.KeyboardEvent 40 | >; 41 | 42 | type useAutocomplete = { close: () => void }; 43 | 44 | function useAutocomplete({ close }: useAutocomplete) { 45 | const id = useId(); 46 | const router = useRouter(); 47 | const [autocompleteState, setAutocompleteState] = useState< 48 | AutocompleteState | EmptyObject 49 | >({}); 50 | 51 | function navigate({ itemUrl }: { itemUrl?: string }) { 52 | if (!itemUrl) { 53 | return; 54 | } 55 | 56 | router.push(itemUrl); 57 | 58 | if ( 59 | itemUrl === 60 | window.location.pathname + window.location.search + window.location.hash 61 | ) { 62 | close(); 63 | } 64 | } 65 | 66 | const [autocomplete] = useState(() => 67 | createAutocomplete< 68 | Result, 69 | React.SyntheticEvent, 70 | React.MouseEvent, 71 | React.KeyboardEvent 72 | >({ 73 | id, 74 | placeholder: "Find something...", 75 | defaultActiveItemId: 0, 76 | onStateChange({ state }) { 77 | setAutocompleteState(state); 78 | }, 79 | shouldPanelOpen({ state }) { 80 | return state.query !== ""; 81 | }, 82 | navigator: { 83 | navigate, 84 | }, 85 | getSources({ query }) { 86 | return import("../mdx/search.mjs").then(({ search }) => { 87 | return [ 88 | { 89 | sourceId: "documentation", 90 | getItems() { 91 | return search(query, { limit: 5 }); 92 | }, 93 | getItemUrl({ item }) { 94 | return item.url; 95 | }, 96 | onSelect: navigate, 97 | }, 98 | ]; 99 | }); 100 | }, 101 | }), 102 | ); 103 | 104 | return { autocomplete, autocompleteState }; 105 | } 106 | 107 | type IconType = SVGProps; 108 | 109 | function NoResultsIcon(props: IconType) { 110 | return ( 111 | 118 | ); 119 | } 120 | 121 | function LoadingIcon(props: IconType) { 122 | const id = useId(); 123 | 124 | return ( 125 | 147 | ); 148 | } 149 | 150 | type HighlightQuery = { text: string; query: string }; 151 | 152 | function HighlightQuery({ text, query }: HighlightQuery) { 153 | return ( 154 | 160 | ); 161 | } 162 | 163 | type SearchResult = { 164 | result: Result; 165 | resultIndex: number; 166 | autocomplete: Autocomplete; 167 | collection: AutocompleteCollection; 168 | query: string; 169 | }; 170 | 171 | function SearchResult({ 172 | result, 173 | resultIndex, 174 | autocomplete, 175 | collection, 176 | query, 177 | }: SearchResult) { 178 | const id = useId(); 179 | 180 | const sectionTitle = NAVIGATION.find(({ links }) => 181 | links.find((link) => link.href === result.url.split("#")[0]), 182 | )?.title; 183 | const hierarchy = [sectionTitle, result.pageTitle].filter( 184 | (x): x is string => typeof x === "string", 185 | ); 186 | 187 | return ( 188 |
  • 0 && 192 | "border-secondary-100 dark:border-secondary-800 border-t", 193 | )} 194 | aria-labelledby={`${id}-hierarchy ${id}-title`} 195 | {...autocomplete.getItemProps({ 196 | item: result, 197 | source: collection.source, 198 | })} 199 | > 200 | 207 | {hierarchy.length > 0 && ( 208 | 228 | )} 229 |
  • 230 | ); 231 | } 232 | 233 | type SearchResults = { 234 | autocomplete: Autocomplete; 235 | query: string; 236 | collection: AutocompleteCollection; 237 | }; 238 | 239 | function SearchResults({ autocomplete, query, collection }: SearchResults) { 240 | if (collection.items.length === 0) { 241 | return ( 242 |
    243 | 244 |

    245 | Nothing found for{" "} 246 | 247 | ‘{query}’ 248 | 249 | . Please try again. 250 |

    251 |
    252 | ); 253 | } 254 | 255 | return ( 256 |
      257 | {collection.items.map((result, resultIndex) => ( 258 | 266 | ))} 267 |
    268 | ); 269 | } 270 | 271 | type SearchInput = { 272 | autocomplete: Autocomplete; 273 | autocompleteState: AutocompleteState | EmptyObject; 274 | onClose: () => void; 275 | }; 276 | 277 | const SearchInput = forwardRef( 278 | ({ autocomplete, autocompleteState, onClose }, forwardedRef) => { 279 | const inputProps = autocomplete.getInputProps({ inputElement: null }); 280 | 281 | return ( 282 |
    283 | 288 | { 293 | if ( 294 | event.key === "Escape" && 295 | !autocompleteState.isOpen && 296 | autocompleteState.query === "" 297 | ) { 298 | if (document.activeElement instanceof HTMLElement) { 299 | document.activeElement.blur(); 300 | } 301 | 302 | onClose(); 303 | } else { 304 | inputProps.onKeyDown(event); 305 | } 306 | }} 307 | /> 308 | {autocompleteState.status === "stalled" && ( 309 |
    310 | 311 |
    312 | )} 313 |
    314 | ); 315 | }, 316 | ); 317 | SearchInput.displayName = "SearchInput"; 318 | 319 | export function SearchDialog() { 320 | const formRef = useRef(null); 321 | const panelRef = useRef(null); 322 | const inputRef = useRef(null); 323 | 324 | const { isOpen, setOpen } = useSearchDialog(); 325 | 326 | const { autocomplete, autocompleteState } = useAutocomplete({ 327 | close() { 328 | setOpen(false); 329 | }, 330 | }); 331 | const pathname = usePathname(); 332 | const searchParams = useSearchParams(); 333 | 334 | // biome-ignore lint/correctness/useExhaustiveDependencies: we need this to update state on page change 335 | useEffect(() => { 336 | setOpen(false); 337 | }, [pathname, searchParams, setOpen]); 338 | 339 | useEffect(() => { 340 | if (!isOpen) { 341 | const keyDownHandler = (event: KeyboardEvent) => { 342 | if (event.ctrlKey && event.key === "q") { 343 | setOpen(true); 344 | } 345 | }; 346 | 347 | window.addEventListener("keydown", keyDownHandler); 348 | return () => { 349 | window.removeEventListener("keydown", keyDownHandler); 350 | }; 351 | } 352 | }, [isOpen, setOpen]); 353 | 354 | return ( 355 | 356 | 357 | 361 |
    362 |
    368 | setOpen(false)} 373 | /> 374 |
    379 | {autocompleteState.isOpen && ( 380 | 385 | )} 386 |
    387 | 388 |
    389 |
    390 |
    391 | ); 392 | } 393 | 394 | export function Search() { 395 | const [modifierKey, setModifierKey] = useState(); 396 | const setOpen = useSearchDialog((state) => state.setOpen); 397 | 398 | useEffect(() => { 399 | setModifierKey( 400 | /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl ", 401 | ); 402 | }, []); 403 | 404 | return ( 405 |
    406 | 419 |
    420 | ); 421 | } 422 | 423 | export function MobileSearch() { 424 | const setOpen = useSearchDialog((state) => state.setOpen); 425 | 426 | return ( 427 |
    428 | 437 |
    438 | ); 439 | } 440 | -------------------------------------------------------------------------------- /apps/docs/components/SectionProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | createContext, 4 | useContext, 5 | useEffect, 6 | useLayoutEffect, 7 | useState, 8 | } from "react"; 9 | import { type StoreApi, createStore, useStore } from "zustand"; 10 | import { remToPx } from "../lib/remToPx"; 11 | 12 | export type Section = { 13 | id: string; 14 | title: string; 15 | offsetRem?: number; 16 | tag?: string; 17 | headingRef?: React.RefObject; 18 | }; 19 | 20 | type SectionState = { 21 | sections: Section[]; 22 | visibleSections: string[]; 23 | setVisibleSections: (visibleSections: string[]) => void; 24 | registerHeading: ({ 25 | id, 26 | ref, 27 | offsetRem, 28 | }: { 29 | id: string; 30 | ref: React.RefObject; 31 | offsetRem: number; 32 | }) => void; 33 | }; 34 | 35 | function createSectionStore(sections: Section[]) { 36 | return createStore()((set) => ({ 37 | sections, 38 | visibleSections: [], 39 | setVisibleSections: (visibleSections) => 40 | set((state) => 41 | state.visibleSections.join() === visibleSections.join() 42 | ? {} 43 | : { visibleSections }, 44 | ), 45 | registerHeading: ({ id, ref, offsetRem }) => 46 | set((state) => { 47 | return { 48 | sections: state.sections.map((section) => { 49 | if (section.id === id) { 50 | return { 51 | ...section, 52 | headingRef: ref, 53 | offsetRem, 54 | }; 55 | } 56 | return section; 57 | }), 58 | }; 59 | }), 60 | })); 61 | } 62 | 63 | function useVisibleSections(sectionStore: StoreApi) { 64 | const setVisibleSections = useStore( 65 | sectionStore, 66 | (s) => s.setVisibleSections, 67 | ); 68 | const sections = useStore(sectionStore, (s) => s.sections); 69 | 70 | useEffect(() => { 71 | function checkVisibleSections() { 72 | const { innerHeight, scrollY } = window; 73 | const newVisibleSections = []; 74 | 75 | for ( 76 | let sectionIndex = 0; 77 | sectionIndex < sections.length; 78 | sectionIndex++ 79 | ) { 80 | const section = sections.at(sectionIndex); 81 | 82 | if (section == null) continue; 83 | 84 | const { id, headingRef, offsetRem = 0 } = section; 85 | 86 | if (!headingRef?.current) { 87 | continue; 88 | } 89 | 90 | const offset = remToPx(offsetRem); 91 | const top = headingRef.current.getBoundingClientRect().top + scrollY; 92 | 93 | if (sectionIndex === 0 && top - offset > scrollY) { 94 | newVisibleSections.push("_top"); 95 | } 96 | 97 | const nextSection = sections.at(sectionIndex + 1); 98 | const bottom = 99 | (nextSection?.headingRef?.current?.getBoundingClientRect().top ?? 100 | Number.POSITIVE_INFINITY) + 101 | scrollY - 102 | remToPx(nextSection?.offsetRem ?? 0); 103 | 104 | if ( 105 | (top > scrollY && top < scrollY + innerHeight) || 106 | (bottom > scrollY && bottom < scrollY + innerHeight) || 107 | (top <= scrollY && bottom >= scrollY + innerHeight) 108 | ) { 109 | newVisibleSections.push(id); 110 | } 111 | } 112 | 113 | setVisibleSections(newVisibleSections); 114 | } 115 | 116 | const raf = window.requestAnimationFrame(() => checkVisibleSections()); 117 | window.addEventListener("scroll", checkVisibleSections, { passive: true }); 118 | window.addEventListener("resize", checkVisibleSections); 119 | 120 | return () => { 121 | window.cancelAnimationFrame(raf); 122 | window.removeEventListener("scroll", checkVisibleSections); 123 | window.removeEventListener("resize", checkVisibleSections); 124 | }; 125 | }, [setVisibleSections, sections]); 126 | } 127 | 128 | const SectionStoreContext = createContext | null>(null); 129 | 130 | const useIsomorphicLayoutEffect = 131 | typeof window === "undefined" ? useEffect : useLayoutEffect; 132 | 133 | export function SectionProvider({ 134 | sections, 135 | children, 136 | }: { 137 | sections: Array
    ; 138 | children: React.ReactNode; 139 | }) { 140 | const [sectionStore] = useState(() => createSectionStore(sections)); 141 | 142 | useVisibleSections(sectionStore); 143 | 144 | useIsomorphicLayoutEffect(() => { 145 | sectionStore.setState({ sections }); 146 | }, [sectionStore, sections]); 147 | 148 | return ( 149 | 150 | {children} 151 | 152 | ); 153 | } 154 | 155 | export function useSectionStore(selector: (state: SectionState) => T) { 156 | const store = useContext(SectionStoreContext); 157 | 158 | if (!store) throw new Error("Unable to get context for SelectionStore!"); 159 | 160 | return useStore(store, selector); 161 | } 162 | -------------------------------------------------------------------------------- /apps/docs/components/Tag.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from "@rafty/ui"; 2 | 3 | const variantStyles = { 4 | small: "", 5 | medium: "rounded-lg px-1.5 ring-1 ring-inset", 6 | }; 7 | 8 | const colorStyles = { 9 | emerald: { 10 | small: "text-emerald-500 dark:text-emerald-400", 11 | medium: 12 | "ring-emerald-300 dark:ring-emerald-400/30 bg-emerald-400/10 text-emerald-500 dark:text-emerald-400", 13 | }, 14 | sky: { 15 | small: "text-sky-500", 16 | medium: 17 | "ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400", 18 | }, 19 | amber: { 20 | small: "text-amber-500", 21 | medium: 22 | "ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400", 23 | }, 24 | rose: { 25 | small: "text-red-500 dark:text-rose-500", 26 | medium: 27 | "ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400", 28 | }, 29 | secondary: { 30 | small: "text-secondary-400 dark:text-secondary-500", 31 | medium: 32 | "ring-secondary-200 bg-secondary-50 text-secondary-500 dark:ring-secondary-500/20 dark:bg-secondary-400/10 dark:text-secondary-400", 33 | }, 34 | }; 35 | 36 | const valueColorMap = { 37 | GET: "emerald", 38 | POST: "sky", 39 | PUT: "amber", 40 | DELETE: "rose", 41 | } as Record; 42 | 43 | export function Tag({ 44 | children, 45 | variant = "medium", 46 | color = valueColorMap[children] ?? "emerald", 47 | }: { 48 | children: keyof typeof valueColorMap & string; 49 | variant?: keyof typeof variantStyles; 50 | color?: keyof typeof colorStyles; 51 | }) { 52 | return ( 53 | 60 | {children} 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /apps/docs/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; 2 | import { Button } from "@rafty/ui"; 3 | import { useTheme } from "next-themes"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export type ThemeToggle = { 7 | className?: HTMLDivElement["className"]; 8 | }; 9 | 10 | export function ThemeToggle({ className }: ThemeToggle) { 11 | const { resolvedTheme, setTheme } = useTheme(); 12 | const otherTheme = resolvedTheme === "dark" ? "light" : "dark"; 13 | const [mounted, setMounted] = useState(false); 14 | 15 | useEffect(() => { 16 | setMounted(true); 17 | }, []); 18 | 19 | return ( 20 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/docs/components/mdx.tsx: -------------------------------------------------------------------------------- 1 | import { InformationCircleIcon } from "@heroicons/react/24/outline"; 2 | import { classNames } from "@rafty/ui"; 3 | import Link from "next/link"; 4 | import type { PropsWithChildren } from "react"; 5 | import { Heading } from "./Heading"; 6 | import { Prose } from "./Prose"; 7 | 8 | export const a = Link; 9 | export { CodeGroup, Code as code, Pre as pre } from "./Code"; 10 | 11 | export function wrapper({ children }: PropsWithChildren) { 12 | return ( 13 |
    14 | {children} 15 |
    16 | ); 17 | } 18 | 19 | export const h2 = function H2(props: Omit, "level">) { 20 | return ; 21 | }; 22 | 23 | export function Note({ children }: PropsWithChildren) { 24 | return ( 25 |
    26 |
    27 | 28 |
    29 |
    30 | {children} 31 |
    32 |
    33 | ); 34 | } 35 | 36 | export function Row({ children }: PropsWithChildren) { 37 | return ( 38 |
    39 | {children} 40 |
    41 | ); 42 | } 43 | 44 | export function Col({ 45 | children, 46 | sticky = false, 47 | }: PropsWithChildren<{ 48 | sticky?: boolean; 49 | }>) { 50 | return ( 51 |
    :first-child]:mt-0 [&>:last-child]:mb-0", 54 | sticky && "xl:sticky xl:top-24", 55 | )} 56 | > 57 | {children} 58 |
    59 | ); 60 | } 61 | 62 | export function Properties({ children }: PropsWithChildren) { 63 | return ( 64 |
    65 |
      66 | {children} 67 |
    68 |
    69 | ); 70 | } 71 | 72 | export function Property({ 73 | name, 74 | children, 75 | type, 76 | }: PropsWithChildren<{ 77 | name: string; 78 | type?: string; 79 | }>) { 80 | return ( 81 |
  • 82 |
    83 |
    Name
    84 |
    85 | {name} 86 |
    87 | {type && ( 88 | <> 89 |
    Type
    90 |
    91 | {type} 92 |
    93 | 94 | )} 95 |
    Description
    96 |
    97 | {children} 98 |
    99 |
    100 |
  • 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /apps/docs/components/store/drawer.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type DrawerType = { 4 | isOpen: boolean; 5 | setOpen: (value: boolean) => void; 6 | }; 7 | 8 | export const useDrawerDialog = create((set) => ({ 9 | isOpen: false, 10 | setOpen: (open) => set({ isOpen: open }), 11 | })); 12 | -------------------------------------------------------------------------------- /apps/docs/components/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./drawer"; 2 | export * from "./search"; 3 | -------------------------------------------------------------------------------- /apps/docs/components/store/search.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type SearchDialogType = { 4 | isOpen: boolean; 5 | setOpen: (value: boolean) => void; 6 | }; 7 | 8 | export const useSearchDialog = create((set) => ({ 9 | isOpen: false, 10 | setOpen: (open) => set({ isOpen: open }), 11 | })); 12 | -------------------------------------------------------------------------------- /apps/docs/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | // biome-ignore lint/suspicious/noExplicitAny: 3 | const content: any; 4 | // biome-ignore lint/suspicious/noExplicitAny: 5 | export const ReactComponent: any; 6 | export default content; 7 | } 8 | -------------------------------------------------------------------------------- /apps/docs/lib/remToPx.ts: -------------------------------------------------------------------------------- 1 | export function remToPx(remValue: number) { 2 | const rootFontSize = 3 | typeof window === "undefined" 4 | ? 16 5 | : Number.parseFloat( 6 | window.getComputedStyle(document.documentElement).fontSize, 7 | ); 8 | 9 | return remValue * rootFontSize; 10 | } 11 | -------------------------------------------------------------------------------- /apps/docs/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from "mdx/types"; 2 | import * as mdxComponents from "./components/mdx"; 3 | 4 | export function useMDXComponents(components: MDXComponents) { 5 | return { 6 | ...components, 7 | ...mdxComponents, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /apps/docs/mdx/recma.mjs: -------------------------------------------------------------------------------- 1 | import { mdxAnnotations } from "mdx-annotations"; 2 | 3 | export const recmaPlugins = [mdxAnnotations.recma]; 4 | -------------------------------------------------------------------------------- /apps/docs/mdx/rehype.mjs: -------------------------------------------------------------------------------- 1 | import { slugifyWithCounter } from "@sindresorhus/slugify"; 2 | import * as acorn from "acorn"; 3 | import { toString as _toString } from "mdast-util-to-string"; 4 | import { mdxAnnotations } from "mdx-annotations"; 5 | import { getSingletonHighlighter } from "shiki"; 6 | import { visit } from "unist-util-visit"; 7 | 8 | function rehypeParseCodeBlocks() { 9 | return (tree) => { 10 | visit(tree, "element", (node, _nodeIndex, parentNode) => { 11 | if (node.tagName === "code" && node.properties.className) { 12 | parentNode.properties.language = node.properties.className[0]?.replace( 13 | /^language-/, 14 | "", 15 | ); 16 | } 17 | }); 18 | }; 19 | } 20 | 21 | function rehypeShiki() { 22 | return async (tree) => { 23 | const highlighter = await getSingletonHighlighter({ 24 | themes: ["dracula"], 25 | // TODO: Remove unused langs 26 | langs: [ 27 | "json", 28 | "jsonc", 29 | "ts", 30 | "js", 31 | "jsx", 32 | "tsx", 33 | "sh", 34 | "py", 35 | "bash", 36 | "php", 37 | ], 38 | }); 39 | 40 | visit(tree, "element", (node) => { 41 | if (node.tagName === "pre" && node.children[0]?.tagName === "code") { 42 | const codeNode = node.children[0]; 43 | const textNode = codeNode.children[0]; 44 | 45 | node.properties.code = textNode.value; 46 | 47 | if (node.properties.language) { 48 | textNode.value = highlighter.codeToHtml(textNode.value, { 49 | lang: node.properties.language, 50 | theme: "dracula", 51 | }); 52 | } 53 | } 54 | }); 55 | }; 56 | } 57 | 58 | function rehypeSlugify() { 59 | return (tree) => { 60 | const slugify = slugifyWithCounter(); 61 | visit(tree, "element", (node) => { 62 | if (node.tagName === "h2" && !node.properties.id) { 63 | node.properties.id = slugify(_toString(node)); 64 | } 65 | }); 66 | }; 67 | } 68 | 69 | function rehypeAddMDXExports(getExports) { 70 | return (tree) => { 71 | const exports = Object.entries(getExports(tree)); 72 | 73 | for (const [name, value] of exports) { 74 | for (const node of tree.children) { 75 | if ( 76 | node.type === "mdxjsEsm" && 77 | new RegExp(`export\\s+const\\s+${name}\\s*=`).test(node.value) 78 | ) { 79 | return; 80 | } 81 | } 82 | 83 | const exportStr = `export const ${name} = ${value}`; 84 | 85 | tree.children.push({ 86 | type: "mdxjsEsm", 87 | value: exportStr, 88 | data: { 89 | estree: acorn.parse(exportStr, { 90 | sourceType: "module", 91 | ecmaVersion: "latest", 92 | }), 93 | }, 94 | }); 95 | } 96 | }; 97 | } 98 | 99 | function getSections(node) { 100 | const sections = []; 101 | 102 | for (const child of node.children ?? []) { 103 | if (child.type === "element" && child.tagName === "h2") { 104 | sections.push(`{ 105 | title: ${JSON.stringify(_toString(child))}, 106 | id: ${JSON.stringify(child.properties.id)}, 107 | ...${child.properties.annotation} 108 | }`); 109 | } else if (child.children) { 110 | sections.push(...getSections(child)); 111 | } 112 | } 113 | 114 | return sections; 115 | } 116 | 117 | export const rehypePlugins = [ 118 | mdxAnnotations.rehype, 119 | rehypeParseCodeBlocks, 120 | rehypeShiki, 121 | rehypeSlugify, 122 | [ 123 | rehypeAddMDXExports, 124 | (tree) => ({ 125 | sections: `[${getSections(tree).join()}]`, 126 | }), 127 | ], 128 | ]; 129 | -------------------------------------------------------------------------------- /apps/docs/mdx/remark.mjs: -------------------------------------------------------------------------------- 1 | import { mdxAnnotations } from "mdx-annotations"; 2 | import remarkGfm from "remark-gfm"; 3 | 4 | export const remarkPlugins = [mdxAnnotations.remark, remarkGfm]; 5 | -------------------------------------------------------------------------------- /apps/docs/mdx/search.mjs: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs"; 2 | import * as path from "node:path"; 3 | import * as url from "node:url"; 4 | import { slugifyWithCounter } from "@sindresorhus/slugify"; 5 | import glob from "fast-glob"; 6 | import { toString as _toString } from "mdast-util-to-string"; 7 | import { remark } from "remark"; 8 | import remarkMdx from "remark-mdx"; 9 | import { createLoader } from "simple-functional-loader"; 10 | import { filter } from "unist-util-filter"; 11 | import { SKIP, visit } from "unist-util-visit"; 12 | 13 | const __filename = url.fileURLToPath(import.meta.url); 14 | const processor = remark().use(remarkMdx).use(extractSections); 15 | const slugify = slugifyWithCounter(); 16 | 17 | function isObjectExpression(node) { 18 | return ( 19 | node.type === "mdxTextExpression" && 20 | node.data?.estree?.body?.[0]?.expression?.type === "ObjectExpression" 21 | ); 22 | } 23 | 24 | function excludeObjectExpressions(tree) { 25 | return filter(tree, (node) => !isObjectExpression(node)); 26 | } 27 | 28 | function extractSections() { 29 | return (tree, { sections }) => { 30 | slugify.reset(); 31 | 32 | visit(tree, (node) => { 33 | if (node.type === "heading" || node.type === "paragraph") { 34 | const content = _toString(excludeObjectExpressions(node)); 35 | if (node.type === "heading" && node.depth <= 2) { 36 | const hash = node.depth === 1 ? null : slugify(content); 37 | sections.push([content, hash, []]); 38 | } else { 39 | sections.at(-1)?.[2].push(content); 40 | } 41 | return SKIP; 42 | } 43 | }); 44 | }; 45 | } 46 | 47 | export default function A(nextConfig = {}) { 48 | const cache = new Map(); 49 | 50 | return Object.assign({}, nextConfig, { 51 | webpack(config, options) { 52 | config.module.rules.push({ 53 | test: __filename, 54 | use: [ 55 | createLoader(function () { 56 | const appDir = path.resolve("./app/(docs)"); 57 | this.addContextDependency(appDir); 58 | 59 | const files = glob.sync("**/*.mdx", { cwd: appDir }); 60 | const data = files.map((file) => { 61 | const url = `/${file.replace(/(^|\/)page\.mdx$/, "")}`; 62 | const mdx = fs.readFileSync(path.join(appDir, file), "utf8"); 63 | 64 | let sections = []; 65 | 66 | if (cache.get(file)?.[0] === mdx) { 67 | sections = cache.get(file)[1]; 68 | } else { 69 | const vfile = { value: mdx, sections }; 70 | processor.runSync(processor.parse(vfile), vfile); 71 | cache.set(file, [mdx, sections]); 72 | } 73 | 74 | return { url, sections }; 75 | }); 76 | 77 | // When this file is imported within the application 78 | // the following module is loaded: 79 | return ` 80 | import FlexSearch from 'flexsearch' 81 | 82 | let sectionIndex = new FlexSearch.Document({ 83 | tokenize: 'full', 84 | document: { 85 | id: 'url', 86 | index: 'content', 87 | store: ['title', 'pageTitle'], 88 | }, 89 | context: { 90 | resolution: 9, 91 | depth: 2, 92 | bidirectional: true 93 | } 94 | }) 95 | 96 | let data = ${JSON.stringify(data)} 97 | 98 | for (let { url, sections } of data) { 99 | for (let [title, hash, content] of sections) { 100 | sectionIndex.add({ 101 | url: url + (hash ? ('#' + hash) : ''), 102 | title, 103 | content: [title, ...content].join('\\n'), 104 | pageTitle: hash ? sections[0][0] : undefined, 105 | }) 106 | } 107 | } 108 | 109 | export function search(query, options = {}) { 110 | let result = sectionIndex.search(query, { 111 | ...options, 112 | enrich: true, 113 | }) 114 | if (result.length === 0) { 115 | return [] 116 | } 117 | return result[0].result.map((item) => ({ 118 | url: item.id, 119 | title: item.doc.title, 120 | pageTitle: item.doc.pageTitle, 121 | })) 122 | } 123 | `; 124 | }), 125 | ], 126 | }); 127 | 128 | if (typeof nextConfig.webpack === "function") { 129 | return nextConfig.webpack(config, options); 130 | } 131 | 132 | return config; 133 | }, 134 | }); 135 | } 136 | -------------------------------------------------------------------------------- /apps/docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | import nextMDX from "@next/mdx"; 2 | import { composePlugins, withNx } from "@nx/next"; 3 | import withSearch from "./mdx/search.mjs"; 4 | import { recmaPlugins } from "./mdx/recma.mjs"; 5 | import { rehypePlugins } from "./mdx/rehype.mjs"; 6 | import { remarkPlugins } from "./mdx/remark.mjs"; 7 | 8 | const withMDX = nextMDX({ 9 | options: { 10 | remarkPlugins, 11 | rehypePlugins, 12 | recmaPlugins, 13 | }, 14 | }); 15 | 16 | /** 17 | * @type {import('@nx/next/plugins/with-nx').WithNxOptions} 18 | **/ 19 | const nextConfig = { 20 | nx: { 21 | // Set this to true if you would like to use SVGR 22 | // See: https://github.com/gregberge/svgr 23 | svgr: false, 24 | }, 25 | pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"], 26 | experimental: { 27 | optimizePackageImports: ["@rafty/ui"], 28 | }, 29 | reactStrictMode: false, 30 | }; 31 | 32 | const plugins = [withSearch, withMDX, withNx]; 33 | 34 | export default composePlugins(...plugins)(nextConfig); 35 | -------------------------------------------------------------------------------- /apps/docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | const { join } = require("node:path"); 2 | 3 | // Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build 4 | // option from your application's configuration (i.e. project.json). 5 | // 6 | // See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries 7 | 8 | module.exports = { 9 | plugins: { 10 | tailwindcss: { 11 | config: join(__dirname, "tailwind.config.js"), 12 | }, 13 | autoprefixer: {}, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /apps/docs/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/docs", 5 | "projectType": "application", 6 | "tags": [], 7 | "// targets": "to see all targets run: nx show project docs --web", 8 | "targets": {} 9 | } 10 | -------------------------------------------------------------------------------- /apps/docs/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./shiki"; 2 | -------------------------------------------------------------------------------- /apps/docs/providers/shiki.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | type PropsWithChildren, 4 | createContext, 5 | useContext, 6 | useEffect, 7 | useState, 8 | } from "react"; 9 | import type { BundledLanguage, BundledTheme, HighlighterGeneric } from "shiki"; 10 | 11 | const ShikiContext = createContext | null>( 12 | null, 13 | ); 14 | 15 | export function ShikiProvider({ children }: PropsWithChildren) { 16 | const value = useHighlighter(); 17 | return ( 18 | {children} 19 | ); 20 | } 21 | 22 | function useHighlighter() { 23 | const [highlighter, setHighlighter] = useState | null>(null); 27 | 28 | useEffect(() => { 29 | const controller = new AbortController(); 30 | try { 31 | new Promise((resolve, reject) => { 32 | // biome-ignore lint/suspicious/noExplicitAny: 33 | const abortListener = ({ target }: any) => { 34 | controller.signal.removeEventListener("abort", abortListener); 35 | reject(target.reason); 36 | }; 37 | controller.signal.addEventListener("abort", abortListener); 38 | 39 | import("shiki") 40 | .then(({ getSingletonHighlighter }) => 41 | getSingletonHighlighter({ 42 | themes: ["github-light-default", "github-dark-default"], 43 | langs: ["js"], 44 | }), 45 | ) 46 | .then((highlighter) => { 47 | setHighlighter(highlighter); 48 | resolve(); 49 | }); 50 | }); 51 | } catch { 52 | /* Catching all the abort errors */ 53 | } 54 | 55 | return () => { 56 | controller.abort(); 57 | }; 58 | }, []); 59 | 60 | return highlighter; 61 | } 62 | 63 | export function useShiki() { 64 | const context = useContext(ShikiContext); 65 | 66 | return context; 67 | } 68 | -------------------------------------------------------------------------------- /apps/docs/public/bluesky.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhinobase/react-bluesky/97658fe636b92aaed78530505811d6de350f201e/apps/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /apps/docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhinobase/react-bluesky/97658fe636b92aaed78530505811d6de350f201e/apps/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /apps/docs/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhinobase/react-bluesky/97658fe636b92aaed78530505811d6de350f201e/apps/docs/public/favicon.png -------------------------------------------------------------------------------- /apps/docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { createGlobPatternsForDependencies } = require("@nx/react/tailwind"); 2 | const { join } = require("node:path"); 3 | const { typographyStyles } = require("./typography"); 4 | const colors = require("tailwindcss/colors"); 5 | 6 | /** @type {import('tailwindcss').Config} */ 7 | module.exports = { 8 | darkMode: "class", 9 | content: [ 10 | join( 11 | __dirname, 12 | "{components,app}/**/*!(*.stories|*.spec).{ts,tsx,html,mdx}", 13 | ), 14 | "../../node_modules/@rafty/ui/**/*.js", 15 | ...createGlobPatternsForDependencies(__dirname), 16 | "../../node_modules/@rafty/**/*.js", 17 | ], 18 | theme: { 19 | typography: typographyStyles, 20 | extend: { 21 | colors: { 22 | primary: colors.sky, 23 | }, 24 | boxShadow: { 25 | glow: "0 0 4px rgb(0 0 0 / 0.1)", 26 | }, 27 | maxWidth: { 28 | lg: "33rem", 29 | "2xl": "40rem", 30 | "3xl": "50rem", 31 | "5xl": "66rem", 32 | }, 33 | opacity: { 34 | 1: "0.01", 35 | 2.5: "0.025", 36 | 7.5: "0.075", 37 | 15: "0.15", 38 | }, 39 | animation: { 40 | rotate: "rotate 5s linear infinite", 41 | }, 42 | keyframes: { 43 | rotate: { 44 | "0%": { transform: "rotate(0deg) scale(10)" }, 45 | "100%": { transform: "rotate(-360deg) scale(10)" }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | plugins: [require("@tailwindcss/typography"), require("@rafty/plugin")], 51 | }; 52 | -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "allowJs": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "incremental": true, 14 | "plugins": [ 15 | { 16 | "name": "next" 17 | } 18 | ] 19 | }, 20 | "include": [ 21 | "**/*.ts", 22 | "**/*.tsx", 23 | "**/*.js", 24 | "**/*.jsx", 25 | "../../apps/docs/.next/types/**/*.ts", 26 | "../../dist/apps/docs/.next/types/**/*.ts", 27 | "next-env.d.ts", 28 | ".next/types/**/*.ts", 29 | "next.config.mjs" 30 | ], 31 | "exclude": ["node_modules", "jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] 32 | } 33 | -------------------------------------------------------------------------------- /apps/docs/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { SearchOptions } from "flexsearch"; 2 | 3 | declare module "./mdx/search.mjs" { 4 | export type Result = { 5 | url: string; 6 | title: string; 7 | pageTitle?: string; 8 | }; 9 | 10 | export function search(query: string, options?: SearchOptions): Array; 11 | } 12 | -------------------------------------------------------------------------------- /apps/docs/typography.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | typographyStyles: ({ theme }) => { 3 | return { 4 | DEFAULT: { 5 | css: { 6 | "--tw-prose-body": theme("colors.secondary.700"), 7 | "--tw-prose-headings": theme("colors.secondary.900"), 8 | "--tw-prose-links": theme("colors.primary.500"), 9 | "--tw-prose-links-hover": theme("colors.primary.600"), 10 | "--tw-prose-links-underline": theme("colors.primary.600"), 11 | "--tw-prose-bold": theme("colors.secondary.900"), 12 | "--tw-prose-counters": theme("colors.secondary.500"), 13 | "--tw-prose-bullets": theme("colors.secondary.300"), 14 | "--tw-prose-hr": theme("colors.secondary.900 / 0.05"), 15 | "--tw-prose-quotes": theme("colors.secondary.900"), 16 | "--tw-prose-quote-borders": theme("colors.secondary.200"), 17 | "--tw-prose-captions": theme("colors.secondary.500"), 18 | "--tw-prose-code": theme("colors.secondary.900"), 19 | "--tw-prose-code-bg": theme("colors.secondary.100"), 20 | "--tw-prose-code-ring": theme("colors.secondary.300"), 21 | "--tw-prose-th-borders": theme("colors.secondary.300"), 22 | "--tw-prose-td-borders": theme("colors.secondary.200"), 23 | 24 | "--tw-prose-invert-body": theme("colors.secondary.400"), 25 | "--tw-prose-invert-headings": theme("colors.white"), 26 | "--tw-prose-invert-links": theme("colors.primary.400"), 27 | "--tw-prose-invert-links-hover": theme("colors.primary.300"), 28 | "--tw-prose-invert-links-underline": theme( 29 | "colors.primary.300 / 0.3", 30 | ), 31 | "--tw-prose-invert-bold": theme("colors.white"), 32 | "--tw-prose-invert-counters": theme("colors.secondary.400"), 33 | "--tw-prose-invert-bullets": theme("colors.secondary.600"), 34 | "--tw-prose-invert-hr": theme("colors.white / 0.05"), 35 | "--tw-prose-invert-quotes": theme("colors.secondary.100"), 36 | "--tw-prose-invert-quote-borders": theme("colors.secondary.700"), 37 | "--tw-prose-invert-captions": theme("colors.secondary.400"), 38 | "--tw-prose-invert-code": theme("colors.white"), 39 | "--tw-prose-invert-code-bg": theme("colors.secondary.700 / 0.15"), 40 | "--tw-prose-invert-code-ring": theme("colors.white / 0.1"), 41 | "--tw-prose-invert-th-borders": theme("colors.secondary.600"), 42 | "--tw-prose-invert-td-borders": theme("colors.secondary.700"), 43 | 44 | // Base 45 | color: "var(--tw-prose-body)", 46 | fontSize: theme("fontSize.sm")[0], 47 | lineHeight: theme("lineHeight.7"), 48 | 49 | // Layout 50 | "> *": { 51 | maxWidth: theme("maxWidth.2xl"), 52 | marginLeft: "auto", 53 | marginRight: "auto", 54 | "@screen lg": { 55 | maxWidth: theme("maxWidth.3xl"), 56 | marginLeft: `calc(50% - min(50%, ${theme("maxWidth.lg")}))`, 57 | marginRight: `calc(50% - min(50%, ${theme("maxWidth.lg")}))`, 58 | }, 59 | }, 60 | 61 | // Text 62 | p: { 63 | marginTop: theme("spacing.6"), 64 | marginBottom: theme("spacing.6"), 65 | }, 66 | '[class~="lead"]': { 67 | fontSize: theme("fontSize.base")[0], 68 | ...theme("fontSize.base")[1], 69 | }, 70 | 71 | // Lists 72 | ol: { 73 | listStyleType: "decimal", 74 | marginTop: theme("spacing.5"), 75 | marginBottom: theme("spacing.5"), 76 | paddingLeft: "1.625rem", 77 | }, 78 | 'ol[type="A"]': { 79 | listStyleType: "upper-alpha", 80 | }, 81 | 'ol[type="a"]': { 82 | listStyleType: "lower-alpha", 83 | }, 84 | 'ol[type="A" s]': { 85 | listStyleType: "upper-alpha", 86 | }, 87 | 'ol[type="a" s]': { 88 | listStyleType: "lower-alpha", 89 | }, 90 | 'ol[type="I"]': { 91 | listStyleType: "upper-roman", 92 | }, 93 | 'ol[type="i"]': { 94 | listStyleType: "lower-roman", 95 | }, 96 | 'ol[type="I" s]': { 97 | listStyleType: "upper-roman", 98 | }, 99 | 'ol[type="i" s]': { 100 | listStyleType: "lower-roman", 101 | }, 102 | 'ol[type="1"]': { 103 | listStyleType: "decimal", 104 | }, 105 | ul: { 106 | listStyleType: "disc", 107 | marginTop: theme("spacing.5"), 108 | marginBottom: theme("spacing.5"), 109 | paddingLeft: "1.625rem", 110 | }, 111 | li: { 112 | marginTop: theme("spacing.2"), 113 | marginBottom: theme("spacing.2"), 114 | }, 115 | ":is(ol, ul) > li": { 116 | paddingLeft: theme("spacing[1.5]"), 117 | }, 118 | "ol > li::marker": { 119 | fontWeight: "400", 120 | color: "var(--tw-prose-counters)", 121 | }, 122 | "ul > li::marker": { 123 | color: "var(--tw-prose-bullets)", 124 | }, 125 | "> ul > li p": { 126 | marginTop: theme("spacing.3"), 127 | marginBottom: theme("spacing.3"), 128 | }, 129 | "> ul > li > *:first-child": { 130 | marginTop: theme("spacing.5"), 131 | }, 132 | "> ul > li > *:last-child": { 133 | marginBottom: theme("spacing.5"), 134 | }, 135 | "> ol > li > *:first-child": { 136 | marginTop: theme("spacing.5"), 137 | }, 138 | "> ol > li > *:last-child": { 139 | marginBottom: theme("spacing.5"), 140 | }, 141 | "ul ul, ul ol, ol ul, ol ol": { 142 | marginTop: theme("spacing.3"), 143 | marginBottom: theme("spacing.3"), 144 | }, 145 | 146 | // Horizontal rules 147 | hr: { 148 | borderColor: "var(--tw-prose-hr)", 149 | borderTopWidth: 1, 150 | marginTop: theme("spacing.16"), 151 | marginBottom: theme("spacing.16"), 152 | maxWidth: "none", 153 | marginLeft: `calc(-1 * ${theme("spacing.4")})`, 154 | marginRight: `calc(-1 * ${theme("spacing.4")})`, 155 | "@screen sm": { 156 | marginLeft: `calc(-1 * ${theme("spacing.6")})`, 157 | marginRight: `calc(-1 * ${theme("spacing.6")})`, 158 | }, 159 | "@screen lg": { 160 | marginLeft: `calc(-1 * ${theme("spacing.8")})`, 161 | marginRight: `calc(-1 * ${theme("spacing.8")})`, 162 | }, 163 | }, 164 | 165 | // Quotes 166 | blockquote: { 167 | fontWeight: "500", 168 | fontStyle: "italic", 169 | color: "var(--tw-prose-quotes)", 170 | borderLeftWidth: "0.25rem", 171 | borderLeftColor: "var(--tw-prose-quote-borders)", 172 | quotes: '"\\201C""\\201D""\\2018""\\2019"', 173 | marginTop: theme("spacing.8"), 174 | marginBottom: theme("spacing.8"), 175 | paddingLeft: theme("spacing.5"), 176 | }, 177 | "blockquote p:first-of-type::before": { 178 | content: "open-quote", 179 | }, 180 | "blockquote p:last-of-type::after": { 181 | content: "close-quote", 182 | }, 183 | 184 | // Headings 185 | h1: { 186 | color: "var(--tw-prose-headings)", 187 | fontWeight: "700", 188 | fontSize: theme("fontSize.2xl")[0], 189 | ...theme("fontSize.2xl")[1], 190 | marginBottom: theme("spacing.2"), 191 | }, 192 | h2: { 193 | color: "var(--tw-prose-headings)", 194 | fontWeight: "600", 195 | fontSize: theme("fontSize.lg")[0], 196 | ...theme("fontSize.lg")[1], 197 | marginTop: theme("spacing.16"), 198 | marginBottom: theme("spacing.2"), 199 | }, 200 | h3: { 201 | color: "var(--tw-prose-headings)", 202 | fontSize: theme("fontSize.base")[0], 203 | ...theme("fontSize.base")[1], 204 | fontWeight: "600", 205 | marginTop: theme("spacing.10"), 206 | marginBottom: theme("spacing.2"), 207 | }, 208 | 209 | // Media 210 | "img, video, figure": { 211 | marginTop: theme("spacing.8"), 212 | marginBottom: theme("spacing.8"), 213 | }, 214 | "figure > *": { 215 | marginTop: "0", 216 | marginBottom: "0", 217 | }, 218 | figcaption: { 219 | color: "var(--tw-prose-captions)", 220 | fontSize: theme("fontSize.xs")[0], 221 | ...theme("fontSize.xs")[1], 222 | marginTop: theme("spacing.2"), 223 | }, 224 | 225 | // Tables 226 | table: { 227 | width: "100%", 228 | tableLayout: "auto", 229 | textAlign: "left", 230 | marginTop: theme("spacing.8"), 231 | marginBottom: theme("spacing.8"), 232 | lineHeight: theme("lineHeight.6"), 233 | }, 234 | thead: { 235 | borderBottomWidth: "1px", 236 | borderBottomColor: "var(--tw-prose-th-borders)", 237 | }, 238 | "thead th": { 239 | color: "var(--tw-prose-headings)", 240 | fontWeight: "600", 241 | verticalAlign: "bottom", 242 | paddingRight: theme("spacing.2"), 243 | paddingBottom: theme("spacing.2"), 244 | paddingLeft: theme("spacing.2"), 245 | }, 246 | "thead th:first-child": { 247 | paddingLeft: "0", 248 | }, 249 | "thead th:last-child": { 250 | paddingRight: "0", 251 | }, 252 | "tbody tr": { 253 | borderBottomWidth: "1px", 254 | borderBottomColor: "var(--tw-prose-td-borders)", 255 | }, 256 | "tbody tr:last-child": { 257 | borderBottomWidth: "0", 258 | }, 259 | "tbody td": { 260 | verticalAlign: "baseline", 261 | }, 262 | tfoot: { 263 | borderTopWidth: "1px", 264 | borderTopColor: "var(--tw-prose-th-borders)", 265 | }, 266 | "tfoot td": { 267 | verticalAlign: "top", 268 | }, 269 | ":is(tbody, tfoot) td": { 270 | paddingTop: theme("spacing.2"), 271 | paddingRight: theme("spacing.2"), 272 | paddingBottom: theme("spacing.2"), 273 | paddingLeft: theme("spacing.2"), 274 | }, 275 | ":is(tbody, tfoot) td:first-child": { 276 | paddingLeft: "0", 277 | }, 278 | ":is(tbody, tfoot) td:last-child": { 279 | paddingRight: "0", 280 | }, 281 | 282 | // Inline elements 283 | a: { 284 | color: "var(--tw-prose-links)", 285 | textDecoration: "underline transparent", 286 | fontWeight: "500", 287 | transitionProperty: "color, text-decoration-color", 288 | transitionDuration: theme("transitionDuration.DEFAULT"), 289 | transitionTimingFunction: theme("transitionTimingFunction.DEFAULT"), 290 | "&:hover": { 291 | color: "var(--tw-prose-links-hover)", 292 | textDecorationColor: "var(--tw-prose-links-underline)", 293 | }, 294 | }, 295 | ":is(h1, h2, h3) a": { 296 | fontWeight: "inherit", 297 | }, 298 | strong: { 299 | color: "var(--tw-prose-bold)", 300 | fontWeight: "600", 301 | }, 302 | ":is(a, blockquote, thead th) strong": { 303 | color: "inherit", 304 | }, 305 | code: { 306 | color: "var(--tw-prose-code)", 307 | borderRadius: theme("borderRadius.lg"), 308 | paddingTop: theme("padding.1"), 309 | paddingRight: theme("padding[1.5]"), 310 | paddingBottom: theme("padding.1"), 311 | paddingLeft: theme("padding[1.5]"), 312 | boxShadow: "inset 0 0 0 1px var(--tw-prose-code-ring)", 313 | backgroundColor: "var(--tw-prose-code-bg)", 314 | fontSize: theme("fontSize.2xs"), 315 | }, 316 | ":is(a, h1, h2, h3, blockquote, thead th) code": { 317 | color: "inherit", 318 | }, 319 | "h2 code": { 320 | fontSize: theme("fontSize.base")[0], 321 | fontWeight: "inherit", 322 | }, 323 | "h3 code": { 324 | fontSize: theme("fontSize.sm")[0], 325 | fontWeight: "inherit", 326 | }, 327 | 328 | // Overrides 329 | ":is(h1, h2, h3) + *": { 330 | marginTop: "0", 331 | }, 332 | "> :first-child": { 333 | marginTop: "0 !important", 334 | }, 335 | "> :last-child": { 336 | marginBottom: "0 !important", 337 | }, 338 | }, 339 | }, 340 | invert: { 341 | css: { 342 | "--tw-prose-body": "var(--tw-prose-invert-body)", 343 | "--tw-prose-headings": "var(--tw-prose-invert-headings)", 344 | "--tw-prose-links": "var(--tw-prose-invert-links)", 345 | "--tw-prose-links-hover": "var(--tw-prose-invert-links-hover)", 346 | "--tw-prose-links-underline": 347 | "var(--tw-prose-invert-links-underline)", 348 | "--tw-prose-bold": "var(--tw-prose-invert-bold)", 349 | "--tw-prose-counters": "var(--tw-prose-invert-counters)", 350 | "--tw-prose-bullets": "var(--tw-prose-invert-bullets)", 351 | "--tw-prose-hr": "var(--tw-prose-invert-hr)", 352 | "--tw-prose-quotes": "var(--tw-prose-invert-quotes)", 353 | "--tw-prose-quote-borders": "var(--tw-prose-invert-quote-borders)", 354 | "--tw-prose-captions": "var(--tw-prose-invert-captions)", 355 | "--tw-prose-code": "var(--tw-prose-invert-code)", 356 | "--tw-prose-code-bg": "var(--tw-prose-invert-code-bg)", 357 | "--tw-prose-code-ring": "var(--tw-prose-invert-code-ring)", 358 | "--tw-prose-th-borders": "var(--tw-prose-invert-th-borders)", 359 | "--tw-prose-td-borders": "var(--tw-prose-invert-td-borders)", 360 | }, 361 | }, 362 | }; 363 | }, 364 | }; 365 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | }, 12 | "formatter": { 13 | "indentStyle": "space", 14 | "lineWidth": 80, 15 | "lineEnding": "crlf" 16 | }, 17 | "vcs": { 18 | "enabled": true, 19 | "clientKind": "git", 20 | "useIgnoreFile": true 21 | } 22 | } -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "namedInputs": { 4 | "default": [ 5 | "{projectRoot}/**/*", 6 | "sharedGlobals" 7 | ], 8 | "production": [ 9 | "default", 10 | "!{projectRoot}/.eslintrc.json", 11 | "!{projectRoot}/eslint.config.js" 12 | ], 13 | "sharedGlobals": [] 14 | }, 15 | "release": { 16 | "projects": [ 17 | "*" 18 | ], 19 | "changelog": { 20 | "projectChangelogs": { 21 | "renderOptions": { 22 | "authors": false 23 | } 24 | } 25 | }, 26 | "projectsRelationship": "independent" 27 | }, 28 | "plugins": [ 29 | { 30 | "plugin": "@nx/rollup/plugin", 31 | "options": { 32 | "buildTargetName": "build" 33 | } 34 | }, 35 | { 36 | "plugin": "@nx/next/plugin", 37 | "options": { 38 | "startTargetName": "start", 39 | "buildTargetName": "build", 40 | "devTargetName": "dev", 41 | "serveStaticTargetName": "serve-static" 42 | } 43 | } 44 | ], 45 | "generators": { 46 | "@nx/react": { 47 | "library": { 48 | "unitTestRunner": "none" 49 | } 50 | }, 51 | "@nx/next": { 52 | "application": { 53 | "style": "tailwind" 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bsky-react-post/source", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "commit": "czg", 7 | "prepare": "is-ci || husky", 8 | "format": "biome check --write ." 9 | }, 10 | "private": true, 11 | "devDependencies": { 12 | "@biomejs/biome": "^1.9.4", 13 | "@nx/js": "19.8.10", 14 | "@nx/next": "19.8.10", 15 | "@nx/react": "19.8.10", 16 | "@nx/rollup": "19.8.10", 17 | "@nx/workspace": "19.8.10", 18 | "@rafty/plugin": "^1.1.2", 19 | "@rollup/plugin-terser": "^0.4.4", 20 | "@swc-node/register": "~1.9.1", 21 | "@swc/cli": "~0.3.12", 22 | "@swc/core": "~1.5.7", 23 | "@swc/helpers": "~0.5.11", 24 | "@types/node": "18.16.9", 25 | "@types/react": "18.3.1", 26 | "@types/react-dom": "18.3.0", 27 | "@types/react-highlight-words": "^0.20.0", 28 | "autoprefixer": "10.4.13", 29 | "czg": "^1.10.1", 30 | "husky": "^9.1.6", 31 | "is-ci": "^3.0.1", 32 | "nano-staged": "^0.8.0", 33 | "nx": "19.8.10", 34 | "postcss": "8.4.38", 35 | "rollup": "^4.14.0", 36 | "swc-loader": "0.1.15", 37 | "tailwindcss": "3.4.3", 38 | "tslib": "^2.3.0", 39 | "typescript": "~5.5.2" 40 | }, 41 | "dependencies": { 42 | "@algolia/autocomplete-core": "^1.17.7", 43 | "@atproto/api": "^0.13.15", 44 | "@heroicons/react": "^2.1.5", 45 | "@mdx-js/loader": "^3.1.0", 46 | "@next/mdx": "^15.0.3", 47 | "@next/third-parties": "^15.0.3", 48 | "@rafty/ui": "^1.7.7", 49 | "@sindresorhus/slugify": "^2.2.1", 50 | "@tailwindcss/typography": "0.5.9", 51 | "@types/mdx": "^2.0.13", 52 | "acorn": "^8.14.0", 53 | "edge-cors": "^0.2.1", 54 | "fast-glob": "^3.3.2", 55 | "flexsearch": "^0.7.43", 56 | "framer-motion": "^11.11.11", 57 | "mdast-util-to-string": "^4.0.0", 58 | "mdx-annotations": "^0.1.4", 59 | "next": "14.2.3", 60 | "next-themes": "^0.4.3", 61 | "react": "18.3.1", 62 | "react-dom": "18.3.1", 63 | "react-highlight-words": "^0.20.0", 64 | "react-icons": "^5.3.0", 65 | "remark": "^15.0.1", 66 | "remark-gfm": "^4.0.0", 67 | "remark-mdx": "^3.1.0", 68 | "rollup-plugin-preserve-directives": "^0.4.0", 69 | "shiki": "^1.22.2", 70 | "simple-functional-loader": "^1.2.1", 71 | "swr": "^2.2.5", 72 | "unist-util-filter": "^5.0.1", 73 | "unist-util-visit": "^5.0.0", 74 | "zustand": "^5.0.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.6 (2024-11-27) 2 | 3 | This was a version bump only for core to align it with other projects, there were no code changes. 4 | 5 | ## 0.1.5 (2024-11-27) 6 | 7 | This was a version bump only for core to align it with other projects, there were no code changes. 8 | 9 | ## 0.1.4 (2024-11-27) 10 | 11 | ### 🩹 Fixes 12 | 13 | - minor changes ([396a70a](https://github.com/rhinobase/react-bluesky/commit/396a70a)) 14 | 15 | ## 0.1.3 (2024-11-14) 16 | 17 | This was a version bump only for core to align it with other projects, there were no code changes. 18 | 19 | ## 0.1.2 (2024-11-14) 20 | 21 | ### 🩹 Fixes 22 | 23 | - **core:** updated build config ([3584a68](https://github.com/rhinobase/react-bluesky/commit/3584a68)) 24 | 25 | ## 0.1.1 (2024-11-14) 26 | 27 | ### 🚀 Features 28 | 29 | - **docs:** completed ([d020518](https://github.com/rhinobase/react-bluesky/commit/d020518)) 30 | 31 | ### 🩹 Fixes 32 | 33 | - minor changes ([4beb1cc](https://github.com/rhinobase/react-bluesky/commit/4beb1cc)) 34 | 35 | ## 0.1.0 (2024-11-14) 36 | 37 | ### 🚀 Features 38 | 39 | - **core:** completed ui part for post ([2192ed7](https://github.com/rhinobase/react-bluesky/commit/2192ed7)) 40 | 41 | - **core:** replaced all tailwind classes with core css properties in all components ([68ac766](https://github.com/rhinobase/react-bluesky/commit/68ac766)) 42 | 43 | - **core:** improvements ([fc15921](https://github.com/rhinobase/react-bluesky/commit/fc15921)) 44 | 45 | - **core:** added embed component ([263b196](https://github.com/rhinobase/react-bluesky/commit/263b196)) 46 | 47 | ### 🩹 Fixes 48 | 49 | - restructuring components ([def08ae](https://github.com/rhinobase/react-bluesky/commit/def08ae)) 50 | 51 | - **core:** corrected the components ([d7a0709](https://github.com/rhinobase/react-bluesky/commit/d7a0709)) 52 | 53 | - **core:** improved package size ([54be92a](https://github.com/rhinobase/react-bluesky/commit/54be92a)) 54 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bsky-react-post", 3 | "description": "Embed Bluesky (bsky) posts in your React application. ", 4 | "version": "0.1.6", 5 | "license": "MIT", 6 | "keywords": ["bsky", "bluesky", "react", "embed", "rsc", "client"], 7 | "homepage": "https://bsky-react-post.rhinobase.io", 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/rhinobase/react-bluesky.git", 14 | "directory": "packages/core" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/rhinobase/react-bluesky/issues" 18 | }, 19 | "peerDependencies": { 20 | "react": ">= 18.0.0", 21 | "swr": "^2.2.5" 22 | }, 23 | "devDependencies": { 24 | "@atproto/api": "~0.13.15" 25 | }, 26 | "exports": { 27 | ".": { 28 | "react-server": "./index.esm.js", 29 | "default": "./client.esm.js" 30 | }, 31 | "./api": "./api.esm.js", 32 | "./theme.css": "./index.esm.css" 33 | }, 34 | "typesVersions": { 35 | "*": { 36 | "index": ["./index.esm.d.ts"], 37 | "api": ["./api.esm.d.ts"], 38 | "*": [] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/core/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "core", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/core/src", 5 | "projectType": "library", 6 | "tags": [], 7 | "// targets": "to see all targets run: nx show project core --web", 8 | "targets": { 9 | "nx-release-publish": { 10 | "options": { 11 | "packageRoot": "dist/{projectRoot}" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/core/rollup.config.js: -------------------------------------------------------------------------------- 1 | const { withNx } = require("@nx/rollup/with-nx"); 2 | const preserveDirectives = require("rollup-plugin-preserve-directives"); 3 | const terser = require("@rollup/plugin-terser"); 4 | 5 | module.exports = withNx( 6 | { 7 | main: "./src/client.ts", 8 | outputPath: "../../dist/packages/core", 9 | tsConfig: "./tsconfig.lib.json", 10 | compiler: "swc", 11 | format: ["esm"], 12 | assets: [{ input: ".", output: ".", glob: "README.md" }], 13 | }, 14 | { 15 | input: { 16 | index: "./src/index.ts", 17 | client: "./src/index.client.ts", 18 | api: "./src/api.ts", 19 | }, 20 | output: { 21 | preserveModules: true, 22 | }, 23 | plugins: [preserveDirectives.default(), terser()], 24 | }, 25 | ); 26 | -------------------------------------------------------------------------------- /packages/core/src/Post.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { fetchPost } from "./api"; 3 | import { EmbeddedPost, PostNotFound, PostSkeleton } from "./components"; 4 | import type { PostProps } from "./types"; 5 | 6 | async function SuspensedPost({ 7 | components, 8 | fetchOptions, 9 | onError, 10 | ...config 11 | }: PostProps) { 12 | let error: unknown; 13 | 14 | const data = !("apiUrl" in config) 15 | ? await fetchPost(config, fetchOptions).catch((err) => { 16 | if (onError) { 17 | error = onError(err); 18 | } else { 19 | console.error(err); 20 | error = err; 21 | } 22 | }) 23 | : undefined; 24 | 25 | if (!data) { 26 | const NotFound = components?.PostNotFound || PostNotFound; 27 | return ; 28 | } 29 | 30 | return ; 31 | } 32 | 33 | export function Post({ fallback = , ...props }: PostProps) { 34 | return ( 35 | 36 | {/* @ts-ignore: Async components are valid in the app directory */} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /packages/core/src/Swr.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { EmbeddedPost, PostNotFound, PostSkeleton } from "./components"; 3 | import { usePost } from "./hooks"; 4 | import type { PostProps } from "./types"; 5 | 6 | export function Post({ 7 | fallback = , 8 | components, 9 | onError, 10 | ...props 11 | }: PostProps) { 12 | const { data, error, isLoading } = usePost(props); 13 | 14 | if (isLoading) return fallback; 15 | 16 | if (error || !data) { 17 | const NotFound = components?.PostNotFound || PostNotFound; 18 | return ; 19 | } 20 | 21 | return ; 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/src/api.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AppBskyFeedDefs, 3 | AppBskyFeedGetPostThread, 4 | ComAtprotoIdentityResolveHandle, 5 | } from "@atproto/api"; 6 | import type { PostHandleProps } from "./types"; 7 | 8 | const BASE_PATH = "https://public.api.bsky.app/xrpc"; 9 | 10 | export async function fetchPost( 11 | config: PostHandleProps, 12 | fetchOptions?: RequestInit, 13 | ) { 14 | let atUri: string; 15 | 16 | if ("handle" in config && config.handle) { 17 | try { 18 | const resolution = await fetch( 19 | `${BASE_PATH}/com.atproto.identity.resolveHandle?handle=${config.handle}`, 20 | fetchOptions, 21 | ).then( 22 | (res) => 23 | res.json() as Promise, 24 | ); 25 | 26 | if (!resolution.did) { 27 | throw new Error("No DID found"); 28 | } 29 | 30 | atUri = `at://${resolution.did}/app.bsky.feed.post/${config.id}`; 31 | } catch (err) { 32 | console.error(err); 33 | throw new Error("Invalid Bluesky URL"); 34 | } 35 | } else if ("did" in config && config.did) { 36 | atUri = `at://${config.did}/app.bsky.feed.post/${config.id}`; 37 | } else { 38 | throw new Error("Invalid Bluesky Embed Config"); 39 | } 40 | 41 | const { thread } = await fetch( 42 | `${BASE_PATH}/app.bsky.feed.getPostThread?uri=${atUri}&depth=0&parentHeight=0`, 43 | fetchOptions, 44 | ).then((res) => res.json() as Promise); 45 | 46 | if (!isThreadViewPost(thread)) { 47 | throw new Error("Post not found"); 48 | } 49 | 50 | return thread; 51 | } 52 | 53 | function isThreadViewPost(v: unknown): v is AppBskyFeedDefs.ThreadViewPost { 54 | return ( 55 | v != null && 56 | typeof v === "object" && 57 | "$type" in v && 58 | v.$type === "app.bsky.feed.defs#threadViewPost" 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /packages/core/src/components/Container/container.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | position: relative; 4 | background-color: var(--post-bg-color); 5 | transition-property: color, background-color, border-color, 6 | text-decoration-color, fill, stroke; 7 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 8 | transition-duration: 150ms; 9 | min-width: 300px; 10 | max-width: 600px; 11 | display: flex; 12 | border: var(--post-border); 13 | border-radius: 0.75rem; 14 | } 15 | .container:hover { 16 | background-color: var(--post-bg-color-hover); 17 | } 18 | 19 | .article { 20 | flex: 1 1 0%; 21 | padding-left: 1rem; 22 | padding-right: 1rem; 23 | padding-top: 0.75rem; 24 | padding-bottom: 0.625rem; 25 | width: 100%; 26 | } 27 | -------------------------------------------------------------------------------- /packages/core/src/components/Container/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { type PropsWithChildren, useEffect, useRef } from "react"; 3 | import { classNames, eventHandler } from "../../utils"; 4 | import { Link } from "../Link"; 5 | import "../../theme.css"; 6 | import s from "./container.module.css"; 7 | 8 | export type Container = PropsWithChildren<{ 9 | href?: string; 10 | }>; 11 | 12 | export function Container({ children, href }: Container) { 13 | const ref = useRef(null); 14 | const prevHeight = useRef(0); 15 | 16 | useEffect(() => { 17 | if (ref.current) { 18 | const observer = new ResizeObserver((entries) => { 19 | const entry = entries[0]; 20 | if (!entry) return; 21 | 22 | let { height } = entry.contentRect; 23 | height += 2; // border top and bottom 24 | if (height !== prevHeight.current) { 25 | prevHeight.current = height; 26 | window.parent.postMessage( 27 | { 28 | height, 29 | id: new URLSearchParams(window.location.search).get("id"), 30 | }, 31 | "*", 32 | ); 33 | } 34 | }); 35 | observer.observe(ref.current); 36 | return () => observer.disconnect(); 37 | } 38 | }, []); 39 | 40 | const handleInteraction = eventHandler(() => { 41 | if (ref.current && href) { 42 | const anchor = ref.current.querySelector("a"); 43 | if (anchor) { 44 | anchor.click(); 45 | } 46 | } 47 | }); 48 | 49 | return ( 50 |
    56 | {href && } 57 |
    {children}
    58 |
    59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /packages/core/src/components/Embed/embed.module.css: -------------------------------------------------------------------------------- 1 | .record { 2 | width: 100%; 3 | border: var(--post-border); 4 | border-radius: 0.5rem; 5 | padding: 0.5rem; 6 | transition-property: color, background-color, border-color, 7 | text-decoration-color, fill, stroke; 8 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 9 | transition-duration: 150ms; 10 | display: flex; 11 | flex-direction: column; 12 | gap: 0.375rem; 13 | background-color: var(--post-bg-color); 14 | } 15 | .record:hover { 16 | background-color: var(--post-bg-color-hover); 17 | } 18 | 19 | .recordHeader { 20 | width: 100%; 21 | display: flex; 22 | align-items: center; 23 | gap: 0.375rem; 24 | } 25 | 26 | .recordAvatar { 27 | width: 1rem; 28 | height: 1rem; 29 | min-width: 1rem; 30 | min-height: 1rem; 31 | overflow: hidden; 32 | border-radius: 9999px; 33 | flex-shrink: 0; 34 | background-color: var(--post-bg-color-hover); 35 | } 36 | 37 | .recordAvatarImg { 38 | filter: blur(0.094rem); 39 | } 40 | 41 | .recordAuthor { 42 | font-size: 0.875rem; 43 | line-height: 1.25rem; 44 | overflow: hidden; 45 | display: -webkit-box; 46 | -webkit-box-orient: vertical; 47 | -webkit-line-clamp: 1; 48 | } 49 | 50 | .recordAuthorDisplayName { 51 | font-weight: 700; 52 | } 53 | 54 | .recordAuthorHandle { 55 | color: var(--post-font-color-secondary); 56 | margin-left: 0.25rem; 57 | } 58 | 59 | .recordText { 60 | font-size: 0.875rem; 61 | line-height: 1.25rem; 62 | } 63 | 64 | .recordMedia { 65 | display: flex; 66 | flex-direction: column; 67 | gap: 0.5rem; 68 | } 69 | 70 | .info { 71 | width: 100%; 72 | border-radius: 0.5rem; 73 | border: var(--post-border); 74 | padding-top: 0.5rem; 75 | padding-bottom: 0.5rem; 76 | padding-left: 0.625rem; 77 | padding-right: 0.625rem; 78 | display: flex; 79 | gap: 0.5rem; 80 | background-color: var(--post-bg-color-hover); 81 | } 82 | 83 | .infoIcon { 84 | width: 1rem; 85 | height: 1rem; 86 | min-width: 1rem; 87 | min-height: 1rem; 88 | flex-shrink: 0; 89 | margin-top: 0.125rem; 90 | fill: var(--post-font-color); 91 | } 92 | 93 | .infoText { 94 | font-size: 0.875rem; 95 | line-height: 1.25rem; 96 | color: var(--post-font-color-secondary); 97 | } 98 | 99 | .singleImage { 100 | width: 100%; 101 | border-radius: 0.5rem; 102 | overflow: hidden; 103 | object-fit: cover; 104 | height: auto; 105 | max-height: 1000px; 106 | } 107 | 108 | .imagesContainer { 109 | width: 100%; 110 | aspect-ratio: 2/1; 111 | border-radius: 0.5rem; 112 | display: flex; 113 | gap: 0.25rem; 114 | overflow: hidden; 115 | } 116 | 117 | .doubleImagesImg { 118 | width: 50%; 119 | height: 100%; 120 | object-fit: cover; 121 | border-radius: 0.125rem; 122 | } 123 | 124 | .threeImagesLargeImg { 125 | flex: 3; 126 | object-fit: cover; 127 | border-radius: 0.125rem; 128 | } 129 | 130 | .threeImagesRemainingImagesContainer { 131 | flex: 2; 132 | display: flex; 133 | flex-direction: column; 134 | gap: 0.25rem; 135 | } 136 | 137 | .threeImagesRemainingImages { 138 | width: 100%; 139 | height: 100%; 140 | object-fit: cover; 141 | border-radius: 0.125rem; 142 | } 143 | 144 | .fourImagesContainer { 145 | width: 100%; 146 | display: grid; 147 | grid-template-columns: repeat(2, minmax(0, 1fr)); 148 | gap: 0.25rem; 149 | border-radius: 0.5rem; 150 | overflow: hidden; 151 | } 152 | 153 | .fourImagesImg { 154 | aspect-ratio: 1 / 1; 155 | width: 100%; 156 | object-fit: cover; 157 | border-radius: 0.125rem; 158 | } 159 | 160 | .external { 161 | width: 100%; 162 | border-radius: 0.5rem; 163 | overflow: hidden; 164 | border: var(--post-border); 165 | display: flex; 166 | flex-direction: column; 167 | align-items: stretch; 168 | } 169 | 170 | .externalThumbnail { 171 | aspect-ratio: 1.91 / 1; 172 | object-fit: cover; 173 | } 174 | 175 | .externalContent { 176 | padding-top: 0.75rem; 177 | padding-bottom: 0.75rem; 178 | padding-left: 1rem; 179 | padding-right: 1rem; 180 | } 181 | 182 | .externalDomain { 183 | font-size: 0.875rem; 184 | line-height: 1.25rem; 185 | color: var(--post-font-color-secondary); 186 | overflow: hidden; 187 | display: -webkit-box; 188 | -webkit-box-orient: vertical; 189 | -webkit-line-clamp: 1; 190 | } 191 | 192 | .externalTitle { 193 | font-weight: 600; 194 | overflow: hidden; 195 | display: -webkit-box; 196 | -webkit-box-orient: vertical; 197 | -webkit-line-clamp: 3; 198 | } 199 | 200 | .externalDescription { 201 | font-size: 0.875rem; 202 | line-height: 1.25rem; 203 | color: var(--post-font-color-secondary); 204 | overflow: hidden; 205 | display: -webkit-box; 206 | -webkit-box-orient: vertical; 207 | -webkit-line-clamp: 2; 208 | margin-top: 0.125rem; 209 | } 210 | 211 | .generic { 212 | width: 100%; 213 | border-radius: 0.5rem; 214 | border: var(--post-border); 215 | padding-top: 0.5rem; 216 | padding-bottom: 0.5rem; 217 | padding-left: 0.75rem; 218 | padding-right: 0.75rem; 219 | display: flex; 220 | flex-direction: column; 221 | gap: 0.5rem; 222 | } 223 | 224 | .genericHeader { 225 | display: flex; 226 | gap: 0.625rem; 227 | align-items: center; 228 | } 229 | 230 | .genericImage { 231 | width: 2rem; 232 | height: 2rem; 233 | min-width: 2rem; 234 | min-height: 2rem; 235 | border-radius: 0.375rem; 236 | flex-shrink: 0; 237 | } 238 | 239 | .genericImageImg { 240 | background-color: var(--post-bg-color-hover); 241 | } 242 | 243 | .genericImagePlaceholder { 244 | background-color: var(--post-color-blue-primary); 245 | } 246 | 247 | .genericTitleAndDescription { 248 | flex: 1 1 0%; 249 | font-size: 0.875rem; 250 | line-height: 1.25rem; 251 | } 252 | 253 | .genericTitle { 254 | font-weight: 700; 255 | } 256 | 257 | .genericDescription { 258 | color: var(--post-font-color-secondary); 259 | } 260 | 261 | .genericText { 262 | color: var(--post-font-color-secondary); 263 | font-size: 0.875rem; 264 | line-height: 1.25rem; 265 | } 266 | 267 | .videoEmbed { 268 | width: 100%; 269 | overflow: hidden; 270 | border-radius: 0.5rem; 271 | aspect-ratio: 1 / 1; 272 | position: relative; 273 | } 274 | 275 | .videoEmbedThumbnail { 276 | object-fit: cover; 277 | width: 100%; 278 | height: 100%; 279 | } 280 | 281 | .videoEmbedIconBg { 282 | width: 6rem; 283 | height: 6rem; 284 | position: absolute; 285 | top: 50%; 286 | left: 50%; 287 | --tw-translate-x: -50%; 288 | --tw-translate-y: -50%; 289 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) 290 | rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) 291 | scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 292 | border-radius: 9999px; 293 | background-color: rgb(0 0 0 / 0.5); 294 | display: flex; 295 | align-items: center; 296 | justify-content: center; 297 | } 298 | 299 | .videoEmbedIcon { 300 | object-fit: cover; 301 | width: 60%; 302 | height: 60%; 303 | } 304 | 305 | .starterPack { 306 | width: 100%; 307 | border-radius: 0.5rem; 308 | overflow: hidden; 309 | border: var(--post-border); 310 | display: flex; 311 | flex-direction: column; 312 | align-items: stretch; 313 | } 314 | 315 | .starterPackImage { 316 | aspect-ratio: 1.91 / 1; 317 | object-fit: cover; 318 | } 319 | 320 | .starterPackContent { 321 | padding-top: 0.75rem; 322 | padding-bottom: 0.75rem; 323 | padding-left: 1rem; 324 | padding-right: 1rem; 325 | } 326 | 327 | .starterPackContentHeader { 328 | display: flex; 329 | gap: 0.5rem; 330 | align-items: center; 331 | } 332 | 333 | .starterPackIcon { 334 | width: 2.5rem; 335 | height: 2.5rem; 336 | min-width: 2.5rem; 337 | min-height: 2.5rem; 338 | } 339 | 340 | .starterPackName { 341 | font-size: 1rem; 342 | line-height: 1.313rem; 343 | font-weight: 600; 344 | } 345 | 346 | .starterPackAuthor { 347 | font-size: 0.875rem; 348 | line-height: 1.125rem; 349 | color: var(--post-font-color-secondary); 350 | overflow: hidden; 351 | display: -webkit-box; 352 | -webkit-box-orient: vertical; 353 | -webkit-line-clamp: 2; 354 | } 355 | 356 | .starterPackDescription { 357 | font-size: 0.875rem; 358 | line-height: 1.25rem; 359 | margin-top: 0.25rem; 360 | } 361 | 362 | .starterPackJoined { 363 | font-size: 0.875rem; 364 | line-height: 1.25rem; 365 | font-weight: 600; 366 | color: var(--post-font-color-secondary); 367 | margin-top: 0.25rem; 368 | } 369 | -------------------------------------------------------------------------------- /packages/core/src/components/Embed/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { 3 | AppBskyEmbedExternal, 4 | AppBskyEmbedImages, 5 | AppBskyEmbedVideo, 6 | AppBskyFeedDefs, 7 | AppBskyGraphDefs, 8 | } from "@atproto/api"; 9 | import { type PropsWithChildren, useMemo } from "react"; 10 | import { CONTENT_LABELS, classNames, labelsToInfo } from "../../utils"; 11 | import { getRkey } from "../../utils"; 12 | import { Link } from "../Link"; 13 | import { isRecord } from "../Post/utils"; 14 | import s from "./embed.module.css"; 15 | import { 16 | isEmbedExternalView, 17 | isEmbedRecordView, 18 | isEmbedRecordWithMediaView, 19 | isEmbedViewBlocked, 20 | isEmbedViewDetached, 21 | isEmbedViewNotFound, 22 | isEmbedViewRecord, 23 | isFeedGeneratorView, 24 | isGraphListView, 25 | isImageView, 26 | isLabelerView, 27 | isStarterPackViewBasic, 28 | isStarterpackRecord, 29 | isVideoView, 30 | } from "./utils"; 31 | 32 | export type Embed = { 33 | content: AppBskyFeedDefs.PostView["embed"]; 34 | labels: AppBskyFeedDefs.PostView["labels"]; 35 | hideRecord?: boolean; 36 | }; 37 | 38 | export function Embed({ content, labels, hideRecord }: Embed) { 39 | const labelInfo = useMemo(() => labelsToInfo(labels), [labels]); 40 | 41 | if (!content) return null; 42 | 43 | try { 44 | // Case 1: Image 45 | if (isImageView(content)) { 46 | return ; 47 | } 48 | 49 | // Case 2: External link 50 | if (isEmbedExternalView(content)) { 51 | return ; 52 | } 53 | 54 | // Case 3: Record (quote or linked post) 55 | if (isEmbedRecordView(content)) { 56 | if (hideRecord) { 57 | return null; 58 | } 59 | 60 | const record = content.record; 61 | 62 | // Case 3.1: Post 63 | if (isEmbedViewRecord(record)) { 64 | const pwiOptOut = !!record.author.labels?.find( 65 | (label) => label.val === "!no-unauthenticated", 66 | ); 67 | if (pwiOptOut) { 68 | return ( 69 | 70 | The author of the quoted post has requested their posts not be 71 | displayed on external sites. 72 | 73 | ); 74 | } 75 | 76 | let text: string | undefined; 77 | if (isRecord(record.value)) { 78 | text = record.value.text; 79 | } 80 | 81 | const isAuthorLabeled = record.author.labels?.some((label) => 82 | CONTENT_LABELS.includes(label.val), 83 | ); 84 | 85 | return ( 86 | 90 |
    91 |
    92 | {record.author.displayName} 97 |
    98 |

    99 | 100 | {record.author.displayName} 101 | 102 | 103 | @{record.author.handle} 104 | 105 |

    106 |
    107 | {text &&

    {text}

    } 108 | {record.embeds?.map((embed) => ( 109 | 115 | ))} 116 | 117 | ); 118 | } 119 | 120 | // Case 3.2: List 121 | if (isGraphListView(record)) { 122 | return ( 123 | 134 | ); 135 | } 136 | 137 | // Case 3.3: Feed 138 | if (isFeedGeneratorView(record)) { 139 | return ( 140 | 147 | ); 148 | } 149 | 150 | // Case 3.4: Labeler 151 | if (isLabelerView(record)) { 152 | // Embed type does not exist in the app, so show nothing 153 | return null; 154 | } 155 | 156 | // Case 3.5: Starter pack 157 | if (isStarterPackViewBasic(record)) { 158 | return ; 159 | } 160 | 161 | // Case 3.6: Post not found 162 | if (isEmbedViewNotFound(record)) { 163 | return Quoted post not found, it may have been deleted.; 164 | } 165 | 166 | // Case 3.7: Post blocked 167 | if (isEmbedViewBlocked(record)) { 168 | return The quoted post is blocked.; 169 | } 170 | 171 | // Case 3.8: Detached quote post 172 | if (isEmbedViewDetached(record)) { 173 | // Just don't show anything 174 | return null; 175 | } 176 | 177 | // Unknown embed type 178 | return null; 179 | } 180 | 181 | // Case 4: Video 182 | if (isVideoView(content)) { 183 | return ; 184 | } 185 | 186 | // Case 5: Record with media 187 | if ( 188 | isEmbedRecordWithMediaView(content) && 189 | isEmbedViewRecord(content.record.record) 190 | ) { 191 | return ( 192 |
    193 | 198 | 206 |
    207 | ); 208 | } 209 | 210 | // Unknown embed type 211 | return null; 212 | } catch (err) { 213 | return ( 214 | {err instanceof Error ? err.message : "An error occurred"} 215 | ); 216 | } 217 | } 218 | 219 | function Info({ children }: PropsWithChildren) { 220 | return ( 221 |
    222 | 228 |

    {children}

    229 |
    230 | ); 231 | } 232 | 233 | type ImageEmbed = { 234 | content: AppBskyEmbedImages.View; 235 | labelInfo?: string; 236 | }; 237 | 238 | function ImageEmbed({ content, labelInfo }: ImageEmbed) { 239 | if (labelInfo) { 240 | return {labelInfo}; 241 | } 242 | 243 | switch (content.images.length) { 244 | case 1: 245 | return ( 246 | {content.images[0].alt} 251 | ); 252 | case 2: 253 | return ( 254 |
    255 | {content.images.map((image, i) => ( 256 | {image.alt} 262 | ))} 263 |
    264 | ); 265 | case 3: 266 | return ( 267 |
    268 | {content.images[0].alt} 273 |
    274 | {content.images.slice(1).map((image, i) => ( 275 | {image.alt} 281 | ))} 282 |
    283 |
    284 | ); 285 | case 4: 286 | return ( 287 |
    288 | {content.images.map((image, i) => ( 289 | {image.alt} 295 | ))} 296 |
    297 | ); 298 | default: 299 | return null; 300 | } 301 | } 302 | 303 | type ExternalEmbed = { 304 | content: AppBskyEmbedExternal.View; 305 | labelInfo?: string; 306 | }; 307 | 308 | function ExternalEmbed({ content, labelInfo }: ExternalEmbed) { 309 | function toNiceDomain(url: string): string { 310 | try { 311 | const urlp = new URL(url); 312 | return urlp.host ? urlp.host : url; 313 | } catch (e) { 314 | return url; 315 | } 316 | } 317 | 318 | if (labelInfo) { 319 | return {labelInfo}; 320 | } 321 | 322 | return ( 323 | 324 | {content.external.thumb && ( 325 | {content.external.title} 330 | )} 331 |
    332 |

    {toNiceDomain(content.external.uri)}

    333 |

    {content.external.title}

    334 |

    {content.external.description}

    335 |
    336 | 337 | ); 338 | } 339 | 340 | type GenericWithImageEmbed = { 341 | title: string; 342 | subtitle: string; 343 | href: string; 344 | image?: string; 345 | description?: string; 346 | }; 347 | 348 | function GenericWithImageEmbed({ 349 | title, 350 | subtitle, 351 | href, 352 | image, 353 | description, 354 | }: GenericWithImageEmbed) { 355 | return ( 356 | 357 |
    358 | {image ? ( 359 | {title} 364 | ) : ( 365 |
    368 | )} 369 |
    370 |

    {title}

    371 |

    {subtitle}

    372 |
    373 |
    374 | {description &&

    {description}

    } 375 | 376 | ); 377 | } 378 | 379 | type VideoEmbed = { content: AppBskyEmbedVideo.View }; 380 | 381 | function VideoEmbed({ content }: VideoEmbed) { 382 | let aspectRatio = 1; 383 | 384 | if (content.aspectRatio) { 385 | const { width, height } = content.aspectRatio; 386 | aspectRatio = clamp(width / height, 1 / 1, 3 / 1); 387 | } 388 | 389 | return ( 390 |
    391 | {content.alt} 396 |
    397 | 403 | Play Icon 404 | 408 | 409 |
    410 |
    411 | ); 412 | } 413 | 414 | type StarterPackEmbed = { 415 | content: AppBskyGraphDefs.StarterPackViewBasic; 416 | }; 417 | 418 | function StarterPackEmbed({ content }: StarterPackEmbed) { 419 | if (!isStarterpackRecord(content.record)) { 420 | return null; 421 | } 422 | 423 | const starterPackHref = getStarterPackHref(content); 424 | const imageUri = getStarterPackImage(content); 425 | 426 | return ( 427 | 428 | {content.record.name} 433 |
    434 |
    435 | 441 | Starter pack icon 442 | 443 | 451 | 452 | 453 | 454 | 455 | 461 | 467 | 468 |
    469 |

    {content.record.name}

    470 |

    471 | Starter pack by{" "} 472 | {content.creator.displayName || `@${content.creator.handle}`} 473 |

    474 |
    475 |
    476 | {content.record.description && ( 477 |

    478 | {content.record.description} 479 |

    480 | )} 481 | {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && ( 482 |

    483 | {content.joinedAllTimeCount} users have joined! 484 |

    485 | )} 486 |
    487 | 488 | ); 489 | } 490 | 491 | function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) { 492 | const rkey = getRkey(starterPack); 493 | return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`; 494 | } 495 | 496 | function getStarterPackHref( 497 | starterPack: AppBskyGraphDefs.StarterPackViewBasic, 498 | ) { 499 | const rkey = getRkey(starterPack); 500 | const handleOrDid = starterPack.creator.handle || starterPack.creator.did; 501 | return `/starter-pack/${handleOrDid}/${rkey}`; 502 | } 503 | 504 | function clamp(num: number, min: number, max: number) { 505 | return Math.max(min, Math.min(num, max)); 506 | } 507 | -------------------------------------------------------------------------------- /packages/core/src/components/Embed/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AppBskyEmbedExternal, 3 | AppBskyEmbedImages, 4 | AppBskyEmbedRecord, 5 | AppBskyEmbedRecordWithMedia, 6 | AppBskyEmbedVideo, 7 | AppBskyFeedDefs, 8 | AppBskyGraphDefs, 9 | AppBskyGraphStarterpack, 10 | AppBskyLabelerDefs, 11 | } from "@atproto/api"; 12 | import { hasProp, isObj } from "../../utils"; 13 | 14 | export function isImageView(v: unknown): v is AppBskyEmbedImages.View { 15 | return ( 16 | isObj(v) && hasProp(v, "$type") && v.$type === "app.bsky.embed.images#view" 17 | ); 18 | } 19 | 20 | export function isEmbedExternalView( 21 | v: unknown, 22 | ): v is AppBskyEmbedExternal.View { 23 | return ( 24 | isObj(v) && 25 | hasProp(v, "$type") && 26 | v.$type === "app.bsky.embed.external#view" 27 | ); 28 | } 29 | 30 | export function isEmbedRecordView(v: unknown): v is AppBskyEmbedRecord.View { 31 | return ( 32 | isObj(v) && hasProp(v, "$type") && v.$type === "app.bsky.embed.record#view" 33 | ); 34 | } 35 | 36 | export function isEmbedViewRecord( 37 | v: unknown, 38 | ): v is AppBskyEmbedRecord.ViewRecord { 39 | return ( 40 | isObj(v) && 41 | hasProp(v, "$type") && 42 | v.$type === "app.bsky.embed.record#viewRecord" 43 | ); 44 | } 45 | 46 | export function isGraphListView(v: unknown): v is AppBskyGraphDefs.ListView { 47 | return ( 48 | isObj(v) && 49 | hasProp(v, "$type") && 50 | v.$type === "app.bsky.graph.defs#listView" 51 | ); 52 | } 53 | 54 | export function isFeedGeneratorView( 55 | v: unknown, 56 | ): v is AppBskyFeedDefs.GeneratorView { 57 | return ( 58 | isObj(v) && 59 | hasProp(v, "$type") && 60 | v.$type === "app.bsky.feed.defs#generatorView" 61 | ); 62 | } 63 | 64 | export function isLabelerView(v: unknown): v is AppBskyLabelerDefs.LabelerView { 65 | return ( 66 | isObj(v) && 67 | hasProp(v, "$type") && 68 | v.$type === "app.bsky.labeler.defs#labelerView" 69 | ); 70 | } 71 | 72 | export function isStarterPackViewBasic( 73 | v: unknown, 74 | ): v is AppBskyGraphDefs.StarterPackViewBasic { 75 | return ( 76 | isObj(v) && 77 | hasProp(v, "$type") && 78 | v.$type === "app.bsky.graph.defs#starterPackViewBasic" 79 | ); 80 | } 81 | 82 | export function isEmbedViewNotFound( 83 | v: unknown, 84 | ): v is AppBskyEmbedRecord.ViewNotFound { 85 | return ( 86 | isObj(v) && 87 | hasProp(v, "$type") && 88 | v.$type === "app.bsky.embed.record#viewNotFound" 89 | ); 90 | } 91 | 92 | export function isEmbedViewBlocked( 93 | v: unknown, 94 | ): v is AppBskyEmbedRecord.ViewBlocked { 95 | return ( 96 | isObj(v) && 97 | hasProp(v, "$type") && 98 | v.$type === "app.bsky.embed.record#viewBlocked" 99 | ); 100 | } 101 | 102 | export function isEmbedViewDetached( 103 | v: unknown, 104 | ): v is AppBskyEmbedRecord.ViewDetached { 105 | return ( 106 | isObj(v) && 107 | hasProp(v, "$type") && 108 | v.$type === "app.bsky.embed.record#viewDetached" 109 | ); 110 | } 111 | 112 | export function isVideoView(v: unknown): v is AppBskyEmbedVideo.View { 113 | return ( 114 | isObj(v) && hasProp(v, "$type") && v.$type === "app.bsky.embed.video#view" 115 | ); 116 | } 117 | 118 | export function isEmbedRecordWithMediaView( 119 | v: unknown, 120 | ): v is AppBskyEmbedRecordWithMedia.View { 121 | return ( 122 | isObj(v) && 123 | hasProp(v, "$type") && 124 | v.$type === "app.bsky.embed.recordWithMedia#view" 125 | ); 126 | } 127 | 128 | export function isStarterpackRecord( 129 | v: unknown, 130 | ): v is AppBskyGraphStarterpack.Record { 131 | return ( 132 | isObj(v) && 133 | hasProp(v, "$type") && 134 | (v.$type === "app.bsky.graph.starterpack#main" || 135 | v.$type === "app.bsky.graph.starterpack") 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /packages/core/src/components/Link/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { AnchorHTMLAttributes } from "react"; 3 | import { classNames } from "../../utils"; 4 | import s from "./link.module.css"; 5 | 6 | export type Link = { 7 | href: string; 8 | disableTracking?: boolean; 9 | } & Omit, "href">; 10 | 11 | export function Link({ 12 | href, 13 | className, 14 | disableTracking, 15 | onClick, 16 | onKeyDown, 17 | ...props 18 | }: Link) { 19 | let ref_url: string | null = null; 20 | 21 | if (typeof window !== "undefined") { 22 | const searchParam = new URLSearchParams(window.location.search); 23 | ref_url = searchParam.get("ref_url"); 24 | } 25 | 26 | const newSearchParam = new URLSearchParams(); 27 | newSearchParam.set("ref_src", "embed"); 28 | if (ref_url) { 29 | newSearchParam.set("ref_url", ref_url); 30 | } 31 | 32 | return ( 33 | { 41 | event.stopPropagation(); 42 | onClick?.(event); 43 | }} 44 | onKeyDown={(event) => { 45 | event.stopPropagation(); 46 | onKeyDown?.(event); 47 | }} 48 | className={classNames(s.link, className)} 49 | /> 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /packages/core/src/components/Link/link.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | cursor: pointer; 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/src/components/Post/index.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 2 | import { 3 | CONTENT_LABELS, 4 | classNames, 5 | getRkey, 6 | niceDate, 7 | prettyNumber, 8 | } from "../../utils"; 9 | import { Container } from "../Container"; 10 | import { Embed } from "../Embed"; 11 | import { Link } from "../Link"; 12 | import { PostContent } from "../PostContent"; 13 | import { PostError } from "../PostError"; 14 | import s from "./post.module.css"; 15 | import { isRecord } from "./utils"; 16 | 17 | export type Post = { 18 | thread: AppBskyFeedDefs.ThreadViewPost; 19 | }; 20 | 21 | export function Post({ thread: { post, parent, replies } }: Post) { 22 | let record: AppBskyFeedPost.Record | null = null; 23 | if (isRecord(post.record)) { 24 | record = post.record; 25 | } 26 | 27 | if (post.author.labels?.find((label) => label.val === "!no-unauthenticated")) 28 | return ( 29 | 30 | ); 31 | 32 | const href = `/profile/${post.author.did}/post/${getRkey(post)}`; 33 | 34 | return ( 35 | 36 |
    37 |
    38 | 39 | 40 | 41 | 46 |
    47 |
    48 | ); 49 | } 50 | 51 | type Header = { 52 | author: AppBskyFeedDefs.PostView["author"]; 53 | href: string; 54 | }; 55 | 56 | function Header({ author, href }: Header) { 57 | const isAuthorLabeled = author.labels?.some((label) => 58 | CONTENT_LABELS.includes(label.val), 59 | ); 60 | 61 | return ( 62 |
    63 | 64 |
    65 | {author.displayName} 70 |
    71 | 72 |
    73 | 74 |

    {author.displayName}

    75 | 76 | 77 |

    @{author.handle}

    78 | 79 |
    80 |
    81 | 82 | 88 | Bluesky Logo 89 | 93 | 94 | 95 |
    96 | ); 97 | } 98 | 99 | type CreatedAt = { 100 | indexedAt: string; 101 | href: string; 102 | }; 103 | 104 | function CreatedAt({ indexedAt, href }: CreatedAt) { 105 | return ( 106 | 107 | 113 | 114 | ); 115 | } 116 | 117 | type Actions = { 118 | likeCount: number | undefined; 119 | repostCount: number | undefined; 120 | replyCount: number | undefined; 121 | }; 122 | 123 | function Actions({ likeCount, replyCount, repostCount }: Actions) { 124 | return ( 125 |
    126 | {!!likeCount && ( 127 |
    128 | 134 | Like icon 135 | 139 | 140 |

    {prettyNumber(likeCount)}

    141 |
    142 | )} 143 | {!!repostCount && ( 144 |
    145 | 151 | Repost icon 152 | 156 | 157 |

    {prettyNumber(repostCount)}

    158 |
    159 | )} 160 |
    161 | 167 | Reply icon 168 | 172 | 173 |

    Reply

    174 |
    175 |
    176 |

    177 | {replyCount 178 | ? `Read ${prettyNumber(replyCount)} ${ 179 | replyCount > 1 ? "replies" : "reply" 180 | } on Bluesky` 181 | : "View on Bluesky"} 182 |

    183 |

    184 | View on Bluesky 185 |

    186 |
    187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /packages/core/src/components/Post/post.module.css: -------------------------------------------------------------------------------- 1 | .post { 2 | flex: 1 1 0%; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 0.5rem; 6 | width: 100%; 7 | } 8 | 9 | .header { 10 | display: flex; 11 | align-items: center; 12 | gap: 0.625rem; 13 | width: 100%; 14 | } 15 | 16 | .avatarLink { 17 | border-radius: 9999px; 18 | } 19 | 20 | .avatar { 21 | width: 2.5rem; 22 | height: 2.5rem; 23 | min-width: 2.5rem; 24 | min-height: 2.5rem; 25 | overflow: hidden; 26 | border-radius: inherit; 27 | flex-shrink: 0; 28 | background-color: var(--post-bg-color-hover); 29 | } 30 | 31 | .avatarImg { 32 | filter: blur(0.156rem); 33 | } 34 | 35 | .displayName { 36 | font-size: 1.063rem; 37 | font-weight: 700; 38 | line-height: 1.25rem; 39 | overflow: hidden; 40 | display: -webkit-box; 41 | -webkit-box-orient: vertical; 42 | -webkit-line-clamp: 1; 43 | } 44 | .displayName:hover { 45 | text-decoration-line: underline; 46 | text-underline-offset: 2px; 47 | text-decoration-thickness: 2px; 48 | } 49 | 50 | .handle { 51 | font-size: 0.938rem; 52 | color: var(--post-font-color-secondary); 53 | overflow: hidden; 54 | display: -webkit-box; 55 | -webkit-box-orient: vertical; 56 | -webkit-line-clamp: 1; 57 | } 58 | .handle:hover { 59 | text-decoration-line: underline; 60 | } 61 | 62 | .spacer { 63 | flex: 1 1 0%; 64 | } 65 | 66 | .logoLink { 67 | align-self: flex-start; 68 | flex-shrink: 0; 69 | transition-property: transform; 70 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 71 | transition-duration: 150ms; 72 | } 73 | .logoLink:hover { 74 | --tw-scale-x: 1.1; 75 | --tw-scale-y: 1.1; 76 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) 77 | rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) 78 | scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 79 | } 80 | 81 | .logo { 82 | height: 2rem; 83 | } 84 | 85 | .createdAtLink { 86 | width: max-content; 87 | max-width: 100%; 88 | } 89 | 90 | .createdAt { 91 | color: var(--post-font-color-secondary); 92 | margin-top: 0.25rem; 93 | font-size: 0.875rem; 94 | line-height: 1.25rem; 95 | } 96 | .createdAt:hover { 97 | text-decoration-line: underline; 98 | } 99 | 100 | .actions { 101 | width: 100%; 102 | border-top: var(--post-border); 103 | padding-top: 0.625rem; 104 | font-size: 0.875rem; 105 | line-height: 1.25rem; 106 | display: flex; 107 | align-items: center; 108 | gap: 1.25rem; 109 | } 110 | 111 | .action { 112 | display: flex; 113 | align-items: center; 114 | gap: 0.5rem; 115 | cursor: pointer; 116 | } 117 | 118 | .actionIcon { 119 | width: 1.25rem; 120 | height: 1.25rem; 121 | min-width: 1.25rem; 122 | min-height: 1.25rem; 123 | } 124 | 125 | .actionText { 126 | color: var(--post-font-color-secondary); 127 | font-weight: 700; 128 | margin-bottom: 1px; 129 | } 130 | 131 | .replies { 132 | cursor: pointer; 133 | color: var(--post-color-blue-primary); 134 | font-weight: 700; 135 | } 136 | .replies:hover { 137 | text-decoration-line: underline; 138 | } 139 | 140 | .repliesCount { 141 | display: none; 142 | } 143 | 144 | .viewOnBluesky { 145 | display: inline; 146 | } 147 | 148 | @media (min-width: 450px) { 149 | .repliesCount { 150 | display: inline; 151 | } 152 | } 153 | 154 | @media (min-width: 450px) { 155 | .viewOnBluesky { 156 | display: none; 157 | } 158 | } 159 | 160 | .viewOnBlueskyText { 161 | display: none; 162 | } 163 | 164 | @media (min-width: 380px) { 165 | .viewOnBlueskyText { 166 | display: inline; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /packages/core/src/components/Post/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost } from "@atproto/api"; 2 | import { hasProp, isObj } from "../../utils"; 3 | 4 | export function isRecord(v: unknown): v is AppBskyFeedPost.Record { 5 | return ( 6 | isObj(v) && 7 | hasProp(v, "$type") && 8 | (v.$type === "app.bsky.feed.post#main" || v.$type === "app.bsky.feed.post") 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /packages/core/src/components/PostContent/index.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedPost } from "@atproto/api"; 2 | import { Link } from "../Link"; 3 | import s from "./post-content.module.css"; 4 | import { rtSegments } from "./utils"; 5 | 6 | export type PostContent = { 7 | record: AppBskyFeedPost.Record | null; 8 | }; 9 | 10 | export function PostContent({ record }: PostContent) { 11 | if (!record) return null; 12 | 13 | const richText = []; 14 | 15 | let counter = 0; 16 | for (const segment of rtSegments({ 17 | text: record.text, 18 | facets: record.facets, 19 | })) { 20 | if (segment.link) { 21 | richText.push( 22 | 31 | {segment.text} 32 | , 33 | ); 34 | } else if (segment.mention) { 35 | richText.push( 36 | 41 | {segment.text} 42 | , 43 | ); 44 | } else if (segment.tag) { 45 | richText.push( 46 | 51 | {segment.text} 52 | , 53 | ); 54 | } else { 55 | richText.push(segment.text); 56 | } 57 | 58 | counter++; 59 | } 60 | 61 | return

    {richText}

    ; 62 | } 63 | -------------------------------------------------------------------------------- /packages/core/src/components/PostContent/post-content.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | font-size: 1rem; 3 | line-height: 1.5rem; 4 | overflow-wrap: break-word; 5 | white-space: pre-wrap; 6 | width: 100%; 7 | } 8 | @media (min-width: 300px) { 9 | .content { 10 | font-size: 1.125rem; 11 | line-height: 1.75rem; 12 | } 13 | } 14 | 15 | .richText { 16 | color: var(--post-link-font-color); 17 | } 18 | .richText:hover { 19 | text-decoration-line: underline; 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/components/PostContent/unicode.ts: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | const decoder = new TextDecoder(); 3 | 4 | export class UnicodeString { 5 | utf16: string; 6 | utf8: Uint8Array; 7 | 8 | constructor(utf16: string) { 9 | this.utf16 = utf16; 10 | this.utf8 = encoder.encode(utf16); 11 | } 12 | 13 | get length() { 14 | return this.utf8.byteLength; 15 | } 16 | 17 | slice(start?: number, end?: number): string { 18 | return decoder.decode(this.utf8.slice(start, end)); 19 | } 20 | 21 | utf16IndexToUtf8Index(i: number) { 22 | return encoder.encode(this.utf16.slice(0, i)).byteLength; 23 | } 24 | 25 | toString() { 26 | return this.utf16; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/core/src/components/PostContent/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AppBskyRichtextFacet, 3 | Facet, 4 | FacetLink, 5 | FacetMention, 6 | FacetTag, 7 | RichTextProps, 8 | } from "@atproto/api"; 9 | import { hasProp, isObj } from "../../utils"; 10 | import { UnicodeString } from "./unicode"; 11 | 12 | class RichTextSegment { 13 | constructor( 14 | public text: string, 15 | public facet?: Facet, 16 | ) {} 17 | 18 | get link(): FacetLink | undefined { 19 | return this.facet?.features.find(isLink); 20 | } 21 | 22 | get mention(): FacetMention | undefined { 23 | return this.facet?.features.find(isMention); 24 | } 25 | 26 | get tag(): FacetTag | undefined { 27 | return this.facet?.features.find(isTag); 28 | } 29 | } 30 | 31 | export function* rtSegments(props: Pick) { 32 | // Setup 33 | const unicodeText = new UnicodeString(props.text); 34 | let facets = props.facets; 35 | if (facets) { 36 | facets = facets.filter(facetFilter).sort(facetSort); 37 | } 38 | 39 | // Segmentation 40 | facets = facets || []; 41 | if (!facets.length) { 42 | yield new RichTextSegment(unicodeText.utf16); 43 | return; 44 | } 45 | 46 | let textCursor = 0; 47 | let facetCursor = 0; 48 | do { 49 | const currFacet = facets[facetCursor]; 50 | if (textCursor < currFacet.index.byteStart) { 51 | yield new RichTextSegment( 52 | unicodeText.slice(textCursor, currFacet.index.byteStart), 53 | ); 54 | } else if (textCursor > currFacet.index.byteStart) { 55 | facetCursor++; 56 | continue; 57 | } 58 | if (currFacet.index.byteStart < currFacet.index.byteEnd) { 59 | const subtext = unicodeText.slice( 60 | currFacet.index.byteStart, 61 | currFacet.index.byteEnd, 62 | ); 63 | if (!subtext.trim()) { 64 | // dont empty string entities 65 | yield new RichTextSegment(subtext); 66 | } else { 67 | yield new RichTextSegment(subtext, currFacet); 68 | } 69 | } 70 | textCursor = currFacet.index.byteEnd; 71 | facetCursor++; 72 | } while (facetCursor < facets.length); 73 | if (textCursor < unicodeText.length) { 74 | yield new RichTextSegment( 75 | unicodeText.slice(textCursor, unicodeText.length), 76 | ); 77 | } 78 | } 79 | 80 | const facetSort = (a: Facet, b: Facet) => a.index.byteStart - b.index.byteStart; 81 | 82 | const facetFilter = (facet: Facet) => 83 | // discard negative-length facets. zero-length facets are valid 84 | facet.index.byteStart <= facet.index.byteEnd; 85 | 86 | function isLink(v: unknown): v is AppBskyRichtextFacet.Link { 87 | return ( 88 | isObj(v) && 89 | hasProp(v, "$type") && 90 | v.$type === "app.bsky.richtext.facet#link" 91 | ); 92 | } 93 | 94 | function isMention(v: unknown): v is AppBskyRichtextFacet.Mention { 95 | return ( 96 | isObj(v) && 97 | hasProp(v, "$type") && 98 | v.$type === "app.bsky.richtext.facet#mention" 99 | ); 100 | } 101 | 102 | function isTag(v: unknown): v is AppBskyRichtextFacet.Tag { 103 | return ( 104 | isObj(v) && hasProp(v, "$type") && v.$type === "app.bsky.richtext.facet#tag" 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /packages/core/src/components/PostError/index.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from "../../utils"; 2 | import s from "./post-error.module.css"; 3 | import "../../theme.css"; 4 | 5 | export type PostError = { 6 | error: string; 7 | }; 8 | 9 | export function PostError(props: PostError) { 10 | return ( 11 |
    12 |

    {props.error}

    13 |
    14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /packages/core/src/components/PostError/post-error.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | border-radius: 0.5rem; 4 | border: var(--post-error-border); 5 | background-color: var(--post-error-bg-color); 6 | padding-left: 1rem; 7 | padding-right: 1rem; 8 | padding-top: 0.75rem; 9 | padding-bottom: 0.625rem; 10 | user-select: none; 11 | } 12 | 13 | .text { 14 | text-align: center; 15 | color: var(--post-error-font-color); 16 | } 17 | -------------------------------------------------------------------------------- /packages/core/src/components/PostNotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "../Container"; 2 | import { Link } from "../Link"; 3 | import s from "./post-not-found.module.css"; 4 | 5 | export type PostNotFound = { 6 | error?: unknown; 7 | }; 8 | 9 | export const PostNotFound = (props: PostNotFound) => ( 10 | 11 | 12 | 18 | Bluesky Logo 19 | 23 | 24 | 25 |

    Post not found, it may have been deleted.

    26 |
    27 | ); 28 | -------------------------------------------------------------------------------- /packages/core/src/components/PostNotFound/post-not-found.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | position: absolute; 3 | top: 1rem; 4 | right: 1rem; 5 | transition-property: transform; 6 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 7 | transition-duration: 150ms; 8 | } 9 | .logo:hover { 10 | --tw-scale-x: 1.1; 11 | --tw-scale-y: 1.1; 12 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) 13 | rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) 14 | scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 15 | } 16 | 17 | .logoSvg { 18 | height: 1.5rem; 19 | } 20 | 21 | .text { 22 | margin-top: 4rem; 23 | margin-bottom: 4rem; 24 | text-align: center; 25 | width: 100%; 26 | color: var(--post-font-color-secondary); 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/components/PostSkeleton/index.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from "../../utils"; 2 | import { Container } from "../Container"; 3 | import s from "./post-loading.module.css"; 4 | 5 | export function PostSkeleton() { 6 | return ( 7 | 8 |
    9 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    21 |
    22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/src/components/PostSkeleton/post-loading.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 0.75rem; 6 | } 7 | 8 | @keyframes pulse { 9 | 50% { 10 | opacity: 0.5; 11 | } 12 | } 13 | 14 | .skeleton { 15 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 16 | background-color: var(--post-skeleton-bg); 17 | } 18 | 19 | .header { 20 | display: flex; 21 | align-items: center; 22 | gap: 0.625rem; 23 | } 24 | 25 | .nameAndHandle { 26 | width: 100%; 27 | display: flex; 28 | flex-direction: column; 29 | gap: 0.5rem; 30 | } 31 | 32 | .headerName { 33 | border-radius: 0.3rem; 34 | height: 20px; 35 | width: 40%; 36 | } 37 | 38 | .headerHandle { 39 | border-radius: 0.3rem; 40 | height: 20px; 41 | width: 80%; 42 | } 43 | 44 | .body { 45 | width: 100%; 46 | display: flex; 47 | flex-direction: column; 48 | gap: 0.5rem; 49 | } 50 | 51 | .bodyItem { 52 | border-radius: 0.3rem; 53 | height: 20px; 54 | } 55 | 56 | .bodyItem1 { 57 | width: 100%; 58 | } 59 | 60 | .bodyItem2 { 61 | width: 85%; 62 | } 63 | 64 | .bodyItem3 { 65 | width: 65%; 66 | } 67 | 68 | .avatar { 69 | height: 40px; 70 | width: 40px; 71 | min-height: 40px; 72 | min-width: 40px; 73 | border-radius: 9999px; 74 | } 75 | -------------------------------------------------------------------------------- /packages/core/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Container"; 2 | export * from "./Embed"; 3 | export * from "./Link"; 4 | export { Post as EmbeddedPost } from "./Post"; 5 | export * from "./PostContent"; 6 | export * from "./PostError"; 7 | export * from "./PostNotFound"; 8 | export * from "./PostSkeleton"; 9 | -------------------------------------------------------------------------------- /packages/core/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./usePost"; 2 | -------------------------------------------------------------------------------- /packages/core/src/hooks/usePost.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import type { AppBskyFeedDefs } from "@atproto/api"; 3 | import swr from "swr"; 4 | import type { PostHandleWithApiUrlProps, PostProps } from "../types"; 5 | 6 | // biome-ignore lint/suspicious/noExplicitAny: Avoids an error when used in the pages directory where useSWR might be in `default`. 7 | const useSWR = ((swr as any).default as typeof swr) || swr; 8 | const host = "https://bsky-react-post.rhinobase.io"; 9 | 10 | async function fetcher([url, fetchOptions]: [ 11 | string, 12 | RequestInit, 13 | ]): Promise { 14 | const res = await fetch(url, fetchOptions); 15 | const json = await res.json(); 16 | 17 | if (res.ok) return json.data; 18 | 19 | throw new Error( 20 | json.error ?? `Failed to fetch post at "${url}" with "${res.status}".`, 21 | ); 22 | } 23 | 24 | export type usePost = PostHandleWithApiUrlProps & 25 | Pick; 26 | 27 | /** 28 | * SWR hook for fetching a post in the browser. 29 | */ 30 | export const usePost = (props: usePost) => { 31 | const { fetchOptions, ...config } = props; 32 | 33 | let endpoint: string | null = null; 34 | 35 | if ("apiUrl" in config && config.apiUrl) { 36 | endpoint = config.apiUrl; 37 | } else if ("handle" in config && config.handle) { 38 | endpoint = `${host}/api/post?handle=${config.handle}&id=${config.id}`; 39 | } else if ("did" in config && config.did) { 40 | endpoint = `${host}/api/post?did=${config.did}&id=${config.id}`; 41 | } 42 | 43 | const { isLoading, data, error } = useSWR( 44 | () => [endpoint, fetchOptions], 45 | fetcher, 46 | { 47 | revalidateIfStale: false, 48 | revalidateOnFocus: false, 49 | shouldRetryOnError: false, 50 | }, 51 | ); 52 | 53 | return { 54 | // If data is `undefined` then it might be the first render where SWR hasn't started doing 55 | // any work, so we set `isLoading` to `true`. 56 | isLoading: Boolean(isLoading || (data === undefined && !error)), 57 | data, 58 | error, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /packages/core/src/index.client.ts: -------------------------------------------------------------------------------- 1 | export * from "./components"; 2 | export * from "./hooks"; 3 | export { Post } from "./Swr"; 4 | export type * from "./types"; 5 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components"; 2 | export * from "./hooks"; 3 | export { Post } from "./Post"; 4 | export type * from "./types"; 5 | -------------------------------------------------------------------------------- /packages/core/src/theme.css: -------------------------------------------------------------------------------- 1 | .bsky-react-post-theme { 2 | --post-container-margin: 0; 3 | 4 | /* Header */ 5 | --post-header-font-size: 0.9375rem; 6 | --post-header-line-height: 1.25rem; 7 | 8 | /* Text */ 9 | --post-body-font-size: 1.25rem; 10 | --post-body-font-weight: 400; 11 | --post-body-line-height: 1.5rem; 12 | --post-body-margin: 0; 13 | 14 | /* Quoted Post */ 15 | --post-quoted-container-margin: 0.75rem 0; 16 | --post-quoted-body-font-size: 0.938rem; 17 | --post-quoted-body-font-weight: 400; 18 | --post-quoted-body-line-height: 1.25rem; 19 | --post-quoted-body-margin: 0.25rem 0 0.75rem 0; 20 | 21 | /* Info */ 22 | --post-info-font-size: 0.9375rem; 23 | --post-info-line-height: 1.25rem; 24 | 25 | /* Actions like the like, reply and copy buttons */ 26 | --post-actions-font-size: 0.875rem; 27 | --post-actions-line-height: 1rem; 28 | --post-actions-font-weight: 700; 29 | --post-actions-icon-size: 1.25em; 30 | --post-actions-icon-wrapper-size: calc( 31 | var(--post-actions-icon-size) + 0.75em 32 | ); 33 | 34 | /* Reply button */ 35 | --post-replies-font-size: 0.875rem; 36 | --post-replies-line-height: 1rem; 37 | --post-replies-font-weight: 700; 38 | } 39 | 40 | :where(.bsky-react-post-theme) * { 41 | margin: 0; 42 | padding: 0; 43 | box-sizing: border-box; 44 | } 45 | 46 | :is([data-theme="light"], .light) :where(.bsky-react-post-theme), 47 | :where(.bsky-react-post-theme) { 48 | --post-skeleton-bg: rgb(212, 212, 216); 49 | --post-border: 1px solid rgb(212, 219, 226); 50 | --post-error-border: 1px solid rgb(239, 68, 68); 51 | --post-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 52 | Helvetica, Arial, sans-serif; 53 | --post-font-color: rgb(15, 20, 25); 54 | --post-error-font-color: rgb(239, 68, 68); 55 | --post-font-color-secondary: rgb(66, 87, 108); 56 | --post-bg-color: #fff; 57 | --post-bg-color-hover: rgba(241, 243, 245, 0.5); 58 | --post-error-bg-color: rgb(254, 242, 242); 59 | --post-color-blue-primary: rgb(10, 122, 255); 60 | --post-color-blue-primary-hover: rgb(26, 140, 216); 61 | --post-link-font-color: rgb(59 130 246); 62 | } 63 | 64 | :is([data-theme="dark"], .dark) :where(.bsky-react-post-theme) { 65 | --post-skeleton-bg: rgb(63, 63, 70); 66 | --post-border: 1px solid rgb(46, 64, 82); 67 | --post-error-border: 1px solid rgb(252 165 165); 68 | --post-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 69 | Helvetica, Arial, sans-serif; 70 | --post-font-color: rgb(247, 249, 249); 71 | --post-error-font-color: rgb(252 165 165); 72 | --post-font-color-secondary: rgb(66, 87, 108); 73 | --post-bg-color: rgb(22, 30, 39); 74 | --post-bg-color-hover: rgba(30, 41, 54, 0.5); 75 | --post-error-bg-color: rgb(69, 10, 10); 76 | --post-color-blue-primary: rgb(10, 122, 255); 77 | --post-color-blue-primary-hover: rgb(26, 140, 216); 78 | --post-link-font-color: rgb(147 197 253); 79 | } 80 | 81 | @media (prefers-color-scheme: dark) { 82 | :where(.bsky-react-post-theme) { 83 | --post-skeleton-bg: rgb(63, 63, 70); 84 | --post-border: 1px solid rgb(46, 64, 82); 85 | --post-error-border: 1px solid rgb(252 165 165); 86 | --post-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 87 | Helvetica, Arial, sans-serif; 88 | --post-font-color: rgb(247, 249, 249); 89 | --post-error-font-color: rgb(252 165 165); 90 | --post-font-color-secondary: rgb(66, 87, 108); 91 | --post-bg-color: rgb(22, 30, 39); 92 | --post-bg-color-hover: rgba(30, 41, 54, 0.5); 93 | --post-error-bg-color: rgb(69, 10, 10); 94 | --post-color-blue-primary: rgb(10, 122, 255); 95 | --post-color-blue-primary-hover: rgb(26, 140, 216); 96 | --post-link-font-color: rgb(147 197 253); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/core/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./post"; 2 | -------------------------------------------------------------------------------- /packages/core/src/types/post.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import type { PostNotFound } from "../components/PostNotFound"; 3 | 4 | /** 5 | * Custom components that the default Bluesky theme allows. 6 | * 7 | * Note: We only use these components in Server Components 8 | * component that uses them is a Server Component and you can't pass down functions to a 9 | * client component unless they're Server Actions. 10 | */ 11 | export type PostComponents = { 12 | PostNotFound?: typeof PostNotFound; 13 | }; 14 | 15 | export type PostProps = PostHandleWithApiUrlProps & { 16 | /** 17 | * Components to replace the default bluesky components. 18 | */ 19 | components?: PostComponents; 20 | /** 21 | * A function to handle errors when fetching the post 22 | */ 23 | onError?(error: unknown): unknown; 24 | /** 25 | * The fallback component to render while the post is loading. 26 | * @default PostSkeleton 27 | */ 28 | fallback?: ReactNode; 29 | /** 30 | * The options to pass to the fetch function. 31 | */ 32 | fetchOptions?: RequestInit; 33 | }; 34 | 35 | export type PostHandleProps = { 36 | /** 37 | * The post ID 38 | * @example "3laq6uzwjbc2t" 39 | */ 40 | id: string; 41 | } & ( 42 | | { 43 | /** 44 | * The profile handle of the post author 45 | * @example "adima7.bsky.social" 46 | */ 47 | handle: string; 48 | did?: never; 49 | } 50 | | { 51 | /** 52 | * The DID of the post author 53 | * @example "did:plc:xdwatsttsxnl5h65mf3ddxbq" 54 | */ 55 | did: string; 56 | handle?: never; 57 | } 58 | ); 59 | 60 | export type PostHandleWithApiUrlProps = 61 | | PostHandleProps 62 | | { 63 | /** 64 | * the API URL to fetch the post from when using the post client-side with SWR. 65 | * @default "https://bsky-react-post.rhinobase.io/api/post/?uri=${uri}" 66 | */ 67 | apiUrl: string; 68 | }; 69 | -------------------------------------------------------------------------------- /packages/core/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseSyntheticEvent } from "react"; 2 | export * from "./labels"; 3 | export * from "./validations"; 4 | 5 | export function niceDate(date: number | string | Date) { 6 | const d = new Date(date); 7 | return `${d.toLocaleDateString("en-us", { 8 | year: "numeric", 9 | month: "short", 10 | day: "numeric", 11 | })} at ${d.toLocaleTimeString(undefined, { 12 | hour: "numeric", 13 | minute: "2-digit", 14 | })}`; 15 | } 16 | 17 | const formatter = new Intl.NumberFormat("en-US", { 18 | notation: "compact", 19 | maximumFractionDigits: 1, 20 | roundingMode: "trunc", 21 | }); 22 | 23 | export function getRkey({ uri }: { uri: string }): string { 24 | return uri.split("/").pop() ?? ""; 25 | } 26 | 27 | export function prettyNumber(number: number) { 28 | return formatter.format(number); 29 | } 30 | 31 | export function classNames(...classes: unknown[]) { 32 | return classes.filter(Boolean).join(" "); 33 | } 34 | 35 | export function eventHandler(func: (event: BaseSyntheticEvent) => void) { 36 | return (event: BaseSyntheticEvent) => { 37 | if ("key" in event && event.key !== "Enter") return; 38 | 39 | event.preventDefault(); 40 | event.stopPropagation(); 41 | 42 | func(event); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /packages/core/src/utils/labels.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs } from "@atproto/api"; 2 | 3 | export const CONTENT_LABELS = ["porn", "sexual", "nudity", "graphic-media"]; 4 | 5 | export function labelsToInfo( 6 | labels?: AppBskyFeedDefs.PostView["labels"], 7 | ): string | undefined { 8 | const label = labels?.find((label) => CONTENT_LABELS.includes(label.val)); 9 | 10 | switch (label?.val) { 11 | case "porn": 12 | case "sexual": 13 | return "Adult Content"; 14 | case "nudity": 15 | return "Non-sexual Nudity"; 16 | case "graphic-media": 17 | return "Graphic Media"; 18 | default: 19 | return undefined; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/utils/validations.ts: -------------------------------------------------------------------------------- 1 | export function isObj(v: unknown): v is Record { 2 | return typeof v === "object" && v !== null; 3 | } 4 | 5 | export function hasProp>( 6 | v: T, 7 | prop: keyof T, 8 | ): v is T { 9 | return prop in v; 10 | } 11 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "allowJs": false, 5 | "esModuleInterop": false, 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true 8 | }, 9 | "files": [], 10 | "include": [], 11 | "references": [ 12 | { 13 | "path": "./tsconfig.lib.json" 14 | } 15 | ], 16 | "extends": "../../tsconfig.base.json" 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "node", 7 | "@nx/react/typings/cssmodule.d.ts", 8 | "@nx/react/typings/image.d.ts" 9 | ] 10 | }, 11 | "exclude": [ 12 | "jest.config.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.test.ts", 15 | "src/**/*.spec.tsx", 16 | "src/**/*.test.tsx", 17 | "src/**/*.spec.js", 18 | "src/**/*.test.js", 19 | "src/**/*.spec.jsx", 20 | "src/**/*.test.jsx" 21 | ], 22 | "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2021", 12 | "module": "esnext", 13 | "lib": [ 14 | "DOM", 15 | "DOM.Iterable", 16 | "ESNext" 17 | ], 18 | "skipLibCheck": true, 19 | "skipDefaultLibCheck": true, 20 | "baseUrl": ".", 21 | "paths": { 22 | "bsky-react-post": [ 23 | "packages/core/src/index.client.ts" 24 | ], 25 | "bsky-react-post/api": [ 26 | "packages/core/src/api.ts" 27 | ], 28 | "bsky-react-post/server": [ 29 | "packages/core/src/index.ts" 30 | ] 31 | } 32 | }, 33 | "exclude": [ 34 | "node_modules", 35 | "tmp" 36 | ] 37 | } 38 | --------------------------------------------------------------------------------