Skip to content

Conversation

@bdbch
Copy link
Member

@bdbch bdbch commented Dec 6, 2025

Changes Overview

  • Add new @tiptap/extension-embed-bluesky extension for embedding Bluesky posts
  • Create React and Vue demos with custom loading states
  • Add documentation to tiptap-docs
  • Add tests covering configuration, URL validation, API resolution, and script loading
  • Create changeset for version management

Implementation 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

  • 37 tests pass (17 config + 20 utils)
  • Tests cover URL validation, data extraction, API resolution with mocked fetch, and script loading
  • Verified build succeeds with no errors
  • Confirmed lint passes

Verification Steps

  1. Run pnpm test --filter="@tiptap/extension-embed-bluesky" to verify all tests pass
  2. Run pnpm build --filter="@tiptap/extension-embed-bluesky" to verify build succeeds
  3. Check demos at pnpm dev and navigate to /Nodes/EmbedBluesky to see working embeds
  4. Verify documentation appears in sidebar at /editor/extensions/nodes/embed-bluesky

Additional Notes

Extension supports collaborative editing where only the inserting user fetches metadata. Other collaborators use cached data from document attributes.

Checklist

  • I have created a changeset for this PR if necessary.
  • My changes do not break the library.
  • I have added tests where applicable.
  • I have followed the project guidelines.
  • I have fixed any lint issues.

@bdbch bdbch marked this pull request as ready for review December 6, 2025 00:04
@changeset-bot
Copy link

changeset-bot bot commented Dec 6, 2025

🦋 Changeset detected

Latest commit: 7e87c3d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 72 packages
Name Type
@tiptap/extension-embed-bluesky Major
@tiptap/core Major
@tiptap/extension-blockquote Major
@tiptap/extension-bold Major
@tiptap/extension-bubble-menu Major
@tiptap/extension-bullet-list Major
@tiptap/extension-code-block-lowlight Major
@tiptap/extension-code-block Major
@tiptap/extension-code Major
@tiptap/extension-collaboration-caret Major
@tiptap/extension-collaboration Major
@tiptap/extension-color Major
@tiptap/extension-details Major
@tiptap/extension-document Major
@tiptap/extension-drag-handle-react Major
@tiptap/extension-drag-handle-vue-2 Major
@tiptap/extension-drag-handle-vue-3 Major
@tiptap/extension-drag-handle Major
@tiptap/extension-emoji Major
@tiptap/extension-file-handler Major
@tiptap/extension-floating-menu Major
@tiptap/extension-font-family Major
@tiptap/extension-hard-break Major
@tiptap/extension-heading Major
@tiptap/extension-highlight Major
@tiptap/extension-horizontal-rule Major
@tiptap/extension-image Major
@tiptap/extension-invisible-characters Major
@tiptap/extension-italic Major
@tiptap/extension-link Major
@tiptap/extension-list Major
@tiptap/extension-mathematics Major
@tiptap/extension-mention Major
@tiptap/extension-node-range Major
@tiptap/extension-ordered-list Major
@tiptap/extension-paragraph Major
@tiptap/extension-strike Major
@tiptap/extension-subscript Major
@tiptap/extension-superscript Major
@tiptap/extension-table-of-contents Major
@tiptap/extension-table Major
@tiptap/extension-text-align Major
@tiptap/extension-text-style Major
@tiptap/extension-text Major
@tiptap/extension-twitch Major
@tiptap/extension-typography Major
@tiptap/extension-underline Major
@tiptap/extension-unique-id Major
@tiptap/extension-youtube Major
@tiptap/extensions Major
@tiptap/html Major
@tiptap/markdown Major
@tiptap/pm Major
@tiptap/react Major
@tiptap/starter-kit Major
@tiptap/static-renderer Major
@tiptap/suggestion Major
@tiptap/vue-2 Major
@tiptap/vue-3 Major
@tiptap/extension-character-count Major
@tiptap/extension-dropcursor Major
@tiptap/extension-focus Major
@tiptap/extension-gapcursor Major
@tiptap/extension-history Major
@tiptap/extension-list-item Major
@tiptap/extension-list-keymap Major
@tiptap/extension-placeholder Major
@tiptap/extension-table-cell Major
@tiptap/extension-table-header Major
@tiptap/extension-table-row Major
@tiptap/extension-task-item Major
@tiptap/extension-task-list Major

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

Copilot AI review requested due to automatic review settings December 6, 2025 00:04
@netlify
Copy link

netlify bot commented Dec 6, 2025

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit 7e87c3d
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/69337390b19774000844dd09
😎 Deploy Preview https://deploy-preview-7312--tiptap-embed.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Contributor

Copilot AI left a 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 => {
Copy link

Copilot AI Dec 6, 2025

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.

Copilot uses AI. Check for mistakes.
uri = resolved.uri
cid = resolved.cid

const blueskeyData = extractBlueskeyDataFromUrl(node.attrs.src)
Copy link

Copilot AI Dec 6, 2025

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).

Copilot uses AI. Check for mistakes.
* @param url - The URL to validate
* @returns true if the URL is valid, false otherwise
*/
export const isValidBlueskeyUrl = (url: string): boolean => {
Copy link

Copilot AI Dec 6, 2025

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).

Copilot uses AI. Check for mistakes.
* 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 => {
Copy link

Copilot AI Dec 6, 2025

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.

Suggested change
export const getEmbedUrlFromBlueskeyUrl = (url: string): string | null => {
export const getEmbedUrlFromBlueskyUrl = (url: string): string | null => {

Copilot uses AI. Check for mistakes.
Comment on lines +303 to +305
// 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
Copy link

Copilot AI Dec 6, 2025

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.

Suggested change
// 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
}
}

Copilot uses AI. Check for mistakes.
* // 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> => {
Copy link

Copilot AI Dec 6, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +218
<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>
Copy link

Copilot AI Dec 6, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +424 to +430
const blueskeyData = extractBlueskeyDataFromUrl(HTMLAttributes.src)

if (!blueskeyData) {
return ['div', 'Invalid Bluesky URL']
}

const { profileHandle, postId } = blueskeyData
Copy link

Copilot AI Dec 6, 2025

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +386 to +387
// Reuse the same DOM if src hasn't changed
return updatedNode.type === node.type && updatedNode.attrs.src === node.attrs.src
Copy link

Copilot AI Dec 6, 2025

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.

Suggested change
// 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);

Copilot uses AI. Check for mistakes.
@bdbch bdbch closed this Jan 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants