-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(extension): add new bluesky embed extension #7312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…RL resolution and error handling
🦋 Changeset detectedLatest commit: 7e87c3d The changes in this PR will be included in the next version bump. This PR includes changesets to release 72 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
✅ Deploy Preview for tiptap-embed ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a new @tiptap/extension-embed-bluesky extension to enable Bluesky post embeds in Tiptap editors. The implementation follows the pattern established by YouTube and Twitch embed extensions, with support for async metadata resolution via Bluesky's public API, configurable loading states, and collaborative editing optimization.
Key Changes:
- New extension package with URL validation, API resolution utilities, and ProseMirror node implementation
- React and Vue demos showcasing custom loading states and configuration options
- Unit tests for utilities and extension configuration (37 tests total)
Critical Issues Found:
- Systematic spelling error: "Blueskey" used instead of "Bluesky" throughout function names, interfaces, types, and variables
- Unintended deletion of the Twitch extension's CHANGELOG.md file
- Logic errors in script reloading and node update functions
- Missing Cypress e2e tests for demos (other embed extensions have these)
Reviewed changes
Copilot reviewed 13 out of 16 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
packages/extension-twitch/CHANGELOG.md |
Deleted entirely - appears to be unintentional |
packages/extension-embed-bluesky/package.json |
Package configuration following repository conventions |
packages/extension-embed-bluesky/tsup.config.ts |
Build configuration for ESM/CJS output |
packages/extension-embed-bluesky/src/utils.ts |
Utility functions with systematic "Blueskey" misspelling |
packages/extension-embed-bluesky/src/embed-bluesky.ts |
Main extension with "Blueskey" misspelling and logic issues in script loading/node updates |
packages/extension-embed-bluesky/src/index.ts |
Package exports |
packages/extension-embed-bluesky/__tests__/utils.test.ts |
20 utility tests with misspelled function names |
packages/extension-embed-bluesky/__tests__/embed-bluesky.test.ts |
17 configuration tests |
packages/extension-embed-bluesky/README.md |
Standard extension README |
demos/src/Nodes/EmbedBluesky/React/index.jsx |
React demo - missing Cypress e2e tests |
demos/src/Nodes/EmbedBluesky/React/styles.scss |
Demo styling |
demos/src/Nodes/EmbedBluesky/Vue/index.vue |
Vue demo - missing Cypress e2e tests |
.changeset/add-bluesky-extension--sparkling-star.md |
Changeset for version management |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
packages/extension-twitch/CHANGELOG.md:1
- The entire Twitch extension changelog has been deleted. This appears to be an unintentional change that should not be part of this PR. The changelog should be restored.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * // Returns: { uri: 'at://...', cid: 'bafy...', profileHandle: 'user', postId: '123' } | ||
| * ``` | ||
| */ | ||
| export const extractBlueskeyDataFromUrl = (url: string): BlueskeyEmbedData | null => { |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: Function name should be extractBlueskDataFromUrl not extractBlueskeyDataFromUrl.
| uri = resolved.uri | ||
| cid = resolved.cid | ||
|
|
||
| const blueskeyData = extractBlueskeyDataFromUrl(node.attrs.src) |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: Variable name should be blueskData not blueskeyData. This misspelling appears multiple times in this file (lines 287, 340, 361, 424).
| * @param url - The URL to validate | ||
| * @returns true if the URL is valid, false otherwise | ||
| */ | ||
| export const isValidBlueskeyUrl = (url: string): boolean => { |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: Function name should be isValidBlueskUrl not isValidBlueskeyUrl. "Bluesky" is missing the 'e' and has an extra 'e' in the wrong place. This misspelling is used consistently throughout the codebase (interface names, function names, variable names).
| * It returns the input URL as-is since Bluesky embeds render via the embed.js script | ||
| * and don't require transformation like YouTube or Twitch embeds. | ||
| */ | ||
| export const getEmbedUrlFromBlueskeyUrl = (url: string): string | null => { |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: Function name should be getEmbedUrlFromBlueskUrl not getEmbedUrlFromBlueskeyUrl.
| export const getEmbedUrlFromBlueskeyUrl = (url: string): string | null => { | |
| export const getEmbedUrlFromBlueskyUrl = (url: string): string | null => { |
| // Use stored handle/postId or extract from src | ||
| const handle = node.attrs.handle || extractBlueskeyDataFromUrl(node.attrs.src)?.profileHandle | ||
| const postId = node.attrs.postId || extractBlueskeyDataFromUrl(node.attrs.src)?.postId |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential null reference: When this code executes after setEmbedBlueskeyResolved has been called, node.attrs.src will be null (set at line 230), but extractBlueskeyDataFromUrl(node.attrs.src) is called as a fallback. While the function handles null/invalid URLs gracefully by returning null, it would be clearer to check if node.attrs.src exists before calling the extraction function, or rely solely on the stored handle/postId attributes when available.
| // Use stored handle/postId or extract from src | |
| const handle = node.attrs.handle || extractBlueskeyDataFromUrl(node.attrs.src)?.profileHandle | |
| const postId = node.attrs.postId || extractBlueskeyDataFromUrl(node.attrs.src)?.postId | |
| // Use stored handle/postId, or extract from src if available | |
| let handle = node.attrs.handle | |
| let postId = node.attrs.postId | |
| if ((!handle || !postId) && node.attrs.src) { | |
| const blueskeyData = extractBlueskeyDataFromUrl(node.attrs.src) | |
| if (blueskeyData) { | |
| if (!handle) handle = blueskeyData.profileHandle | |
| if (!postId) postId = blueskeyData.postId | |
| } | |
| } |
| * // Returns: { uri: 'at://did:plc:xxxx/app.bsky.feed.post/3m7bl6h22qk26', cid: 'bafy...' } | ||
| * ``` | ||
| */ | ||
| export const resolveBlueskeyEmbed = async (url: string): Promise<{ uri: string; cid: string } | null> => { |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: Function name should be resolveBlueskEmbed not resolveBlueskeyEmbed.
| <template> | ||
| <div v-if="editor" class="container"> | ||
| <div class="control-group"> | ||
| <div class="button-group"> | ||
| <button id="add" @click="addEmbed">Add Bluesky embed</button> | ||
| </div> | ||
| </div> | ||
| <editor-content :editor="editor" /> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script> | ||
| import Document from '@tiptap/extension-document' | ||
| import EmbedBluesky from '@tiptap/extension-embed-bluesky' | ||
| import Heading from '@tiptap/extension-heading' | ||
| import { ListKit } from '@tiptap/extension-list' | ||
| import Paragraph from '@tiptap/extension-paragraph' | ||
| import Text from '@tiptap/extension-text' | ||
| import { Editor, EditorContent } from '@tiptap/vue-3' | ||
|
|
||
| export default { | ||
| components: { | ||
| EditorContent, | ||
| }, | ||
|
|
||
| data() { | ||
| return { | ||
| editor: null, | ||
| } | ||
| }, | ||
|
|
||
| mounted() { | ||
| this.editor = new Editor({ | ||
| extensions: [ | ||
| Document, | ||
| Heading, | ||
| Paragraph, | ||
| ListKit, | ||
| Text, | ||
| EmbedBluesky.configure({ | ||
| addPasteHandler: true, | ||
| colorMode: 'system', | ||
| // Custom loading HTML - you can use any HTML here (spinner, skeleton, etc.) | ||
| loadingHTML: '<div class="bluesky-spinner"><span></span></div>', | ||
| }), | ||
| ], | ||
| content: ` | ||
| <p>Tiptap now supports Bluesky embeds! Awesome!</p> | ||
| <p>The extension automatically resolves post metadata from the Bluesky API and renders the embed.</p> | ||
| <h2>Features</h2> | ||
| <ul> | ||
| <li>Paste Bluesky URLs directly</li> | ||
| <li>Automatic metadata resolution via public API</li> | ||
| <li>Configurable color mode (light, dark, system)</li> | ||
| <li>Fallback link if resolution fails</li> | ||
| </ul> | ||
| <p>Here's an example of a resolved Bluesky embed:</p> | ||
| <blockquote class="bluesky-embed" data-bluesky-uri="at://did:plc:vod7n54e7zoorlj53df3sgez/app.bsky.feed.post/3lnzavsbkvs2x" data-bluesky-cid="bafyreicv3qaeah2dsjm6uymt6ld3sphs3oru7fd6472w4n3vuiyi2hvhze" data-bluesky-embed-color-mode="system"><p lang="en"><a href="https://bsky.app/profile/tiptap.dev/post/3lnzavsbkvs2x?ref_src=embed">View on Bluesky</a></p></blockquote> | ||
| `, | ||
| editorProps: { | ||
| attributes: { | ||
| spellcheck: 'false', | ||
| }, | ||
| }, | ||
| }) | ||
| }, | ||
|
|
||
| methods: { | ||
| addEmbed() { | ||
| const url = prompt('Enter Bluesky post URL (e.g., https://bsky.app/profile/user/post/123)') | ||
|
|
||
| if (url) { | ||
| this.editor.commands.setEmbedBluesky({ | ||
| src: url, | ||
| }) | ||
| } | ||
| }, | ||
| }, | ||
|
|
||
| beforeUnmount() { | ||
| this.editor.destroy() | ||
| }, | ||
| } | ||
| </script> | ||
|
|
||
| <style lang="scss"> | ||
| /* Basic editor styles */ | ||
| .tiptap { | ||
| :first-child { | ||
| margin-top: 0; | ||
| } | ||
|
|
||
| /* Bluesky embed - wrapper and both blockquote (loading) and div (rendered) */ | ||
| blockquote[data-bluesky-src], | ||
| blockquote[data-bluesky-uri], | ||
| div.bluesky-embed { | ||
| cursor: move; | ||
| transition: outline 0.15s; | ||
| } | ||
|
|
||
| /* Blockquote specific styles (before embed loads) */ | ||
| blockquote[data-bluesky-src], | ||
| blockquote[data-bluesky-uri] { | ||
| padding: 1rem; | ||
| border-left: 4px solid var(--purple); | ||
| background-color: var(--gray-light); | ||
| border-radius: 0.25rem; | ||
|
|
||
| /* Loading state */ | ||
| &[data-state='loading'], | ||
| &[data-state='resolving'] { | ||
| background-color: var(--gray-light); | ||
| opacity: 0.6; | ||
| } | ||
|
|
||
| /* Error state */ | ||
| &[data-state='error'] { | ||
| background-color: rgba(239, 68, 68, 0.1); | ||
| border-left-color: #ef4444; | ||
|
|
||
| p { | ||
| color: #7f1d1d; | ||
| } | ||
| } | ||
|
|
||
| /* Resolved state */ | ||
| &[data-state='resolved'] { | ||
| background-color: transparent; | ||
| border-left: none; | ||
| padding: 0; | ||
| } | ||
| } | ||
|
|
||
| /* Loading text styling */ | ||
| .bluesky-embed-loading { | ||
| color: var(--gray); | ||
| font-size: 0.875rem; | ||
| animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; | ||
| } | ||
|
|
||
| /* Custom spinner example */ | ||
| .bluesky-spinner { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| height: 100px; | ||
|
|
||
| span { | ||
| display: inline-block; | ||
| width: 12px; | ||
| height: 12px; | ||
| border-radius: 50%; | ||
| background-color: var(--purple); | ||
| animation: spinner-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; | ||
|
|
||
| &::before { | ||
| content: ''; | ||
| display: block; | ||
| position: absolute; | ||
| width: 12px; | ||
| height: 12px; | ||
| border-radius: 50%; | ||
| background-color: var(--purple); | ||
| animation: spinner-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @keyframes pulse { | ||
| 0%, | ||
| 100% { | ||
| opacity: 1; | ||
| } | ||
| 50% { | ||
| opacity: 0.5; | ||
| } | ||
| } | ||
|
|
||
| @keyframes spinner-pulse { | ||
| 0%, | ||
| 100% { | ||
| opacity: 1; | ||
| } | ||
| 50% { | ||
| opacity: 0.5; | ||
| } | ||
| } | ||
|
|
||
| @keyframes spinner-ping { | ||
| 0% { | ||
| transform: scale(1); | ||
| opacity: 1; | ||
| } | ||
| 75% { | ||
| transform: scale(2); | ||
| opacity: 0; | ||
| } | ||
| 100% { | ||
| transform: scale(2); | ||
| opacity: 0; | ||
| } | ||
| } | ||
|
|
||
| /* Selected state - apply outline to wrapper and child div */ | ||
| .ProseMirror-selectednode { | ||
| & > div.bluesky-embed { | ||
| outline: 3px solid var(--purple); | ||
| outline-offset: 2px; | ||
| } | ||
|
|
||
| &blockquote[data-bluesky-src], | ||
| &blockquote[data-bluesky-uri] { | ||
| outline: 3px solid var(--purple); | ||
| outline-offset: 2px; | ||
| } | ||
| } | ||
| } | ||
| </style> |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing Cypress e2e test file. Following the repository convention, demo files should include .spec.js files for e2e tests alongside the implementation files (see demos/src/Nodes/Youtube/Vue/index.spec.js for reference). These tests should verify basic functionality like adding embeds via the button, pasting URLs, and checking the rendered output.
| const blueskeyData = extractBlueskeyDataFromUrl(HTMLAttributes.src) | ||
|
|
||
| if (!blueskeyData) { | ||
| return ['div', 'Invalid Bluesky URL'] | ||
| } | ||
|
|
||
| const { profileHandle, postId } = blueskeyData |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spelling error: Variable name should be blueskData not blueskeyData.
| const blueskeyData = extractBlueskeyDataFromUrl(HTMLAttributes.src) | |
| if (!blueskeyData) { | |
| return ['div', 'Invalid Bluesky URL'] | |
| } | |
| const { profileHandle, postId } = blueskeyData | |
| const blueskyData = extractBlueskeyDataFromUrl(HTMLAttributes.src) | |
| if (!blueskyData) { | |
| return ['div', 'Invalid Bluesky URL'] | |
| } | |
| const { profileHandle, postId } = blueskyData |
| // Reuse the same DOM if src hasn't changed | ||
| return updatedNode.type === node.type && updatedNode.attrs.src === node.attrs.src |
Copilot
AI
Dec 6, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential update logic issue: The update function checks if src hasn't changed, but src is set to null after resolution (line 230). This means when setEmbedBlueskeyResolved is called, the node will have a different src value (null vs original URL), causing the update function to return false and potentially recreating the node view unnecessarily. Consider checking if the uri/cid are consistent rather than just src, or use a different strategy for determining if the node can be reused.
| // Reuse the same DOM if src hasn't changed | |
| return updatedNode.type === node.type && updatedNode.attrs.src === node.attrs.src | |
| // Reuse the same DOM if src hasn't changed, or if resolved uri/cid are consistent | |
| const sameSrc = updatedNode.attrs.src === node.attrs.src; | |
| const sameResolved = updatedNode.attrs.uri === node.attrs.uri && updatedNode.attrs.cid === node.attrs.cid; | |
| return updatedNode.type === node.type && (sameSrc || sameResolved); |
Changes Overview
@tiptap/extension-embed-blueskyextension for embedding Bluesky postsImplementation Approach
Built the extension following Tiptap patterns with async node view, singleton embed script loading, and collaborative optimization. Used Bluesky public API for metadata resolution with two-step process: profile lookup to get DID, then post fetch. Stored resolved metadata in document to avoid redundant API calls.
Testing Done
Verification Steps
pnpm test --filter="@tiptap/extension-embed-bluesky"to verify all tests passpnpm build --filter="@tiptap/extension-embed-bluesky"to verify build succeedspnpm devand navigate to/Nodes/EmbedBlueskyto see working embeds/editor/extensions/nodes/embed-blueskyAdditional Notes
Extension supports collaborative editing where only the inserting user fetches metadata. Other collaborators use cached data from document attributes.
Checklist