snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { Youtube } from './youtube.js'
export * from './youtube.js'
export default Youtube

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vite-plus/test'
import {
getAttributesFromYoutubeEmbedUrl,
getEmbedUrlFromYoutubeUrl,
isValidYoutubeUrl,
} from './utils.js'
describe('YouTube Live URL handling', () => {
it('generates correct embed URL for a YouTube Live URL', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/live/EkRHhOCdZjw',
controls: true,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw')
})
it('generates correct embed URL for a YouTube Live URL without www prefix', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://youtube.com/live/EkRHhOCdZjw',
controls: true,
autoplay: true,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1')
})
it('generates correct embed URL for a YouTube Live URL with multiple parameters', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/live/EkRHhOCdZjw',
autoplay: true,
controls: false,
rel: 0,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1&controls=0&rel=0')
})
it('generates correct embed URL for a YouTube Live URL with nocookie option', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/live/EkRHhOCdZjw',
nocookie: true,
controls: true,
rel: 1,
})
expect(result).toBe('https://www.youtube-nocookie.com/embed/EkRHhOCdZjw?rel=1')
})
it('keeps a live URL with a query string on the video id', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/live/EkRHhOCdZjw?feature=share',
controls: true,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw')
})
})
describe('YouTube path-like near misses', () => {
const nearMisses = [
'https://www.youtube.com/notlive/EkRHhOCdZjw',
'https://www.youtube.com/notshorts/EkRHhOCdZjw',
'https://www.youtube.com/watch?redirect=live/EkRHhOCdZjw',
]
nearMisses.forEach(url => {
it(`does not embed a video id for ${url}`, () => {
expect(getEmbedUrlFromYoutubeUrl({ url, controls: true })).toBe(null)
})
})
})
describe('YouTube Shorts URL handling', () => {
it('generates correct embed URL for YouTube Shorts with rel parameter', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
controls: true,
rel: 1,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?rel=1')
})
it('generates correct embed URL for YouTube Shorts without www prefix', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://youtube.com/shorts/EkRHhOCdZjw',
controls: true,
autoplay: true,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1')
})
it('generates correct embed URL for YouTube Shorts with multiple parameters', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
autoplay: true,
controls: false,
rel: 0,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1&controls=0&rel=0')
})
it('generates correct embed URL for YouTube Shorts with nocookie option', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
nocookie: true,
controls: true,
rel: 1,
})
expect(result).toBe('https://www.youtube-nocookie.com/embed/EkRHhOCdZjw?rel=1')
})
it('generates correct embed URL for YouTube Shorts without extra parameters', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/shorts/EkRHhOCdZjw',
controls: true,
})
expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw')
})
})
describe('YouTube Playlist URL handling', () => {
it('generates correct embed URL for playlist with additional parameters using & separator', () => {
const result = getEmbedUrlFromYoutubeUrl({
url: 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
controls: true,
autoplay: true,
})
// Playlist URLs use ?list= in base URL, so additional params should use &
expect(result).toBe(
'https://www.youtube-nocookie.com/embed/videoseries?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf&autoplay=1',
)
})
})
it.each([
'https://www.youtube.com/embed/',
'https://www.youtube.com/embed/videoseries',
'https://example.com/embed/dQw4w9WgXcQ',
])('returns null for unsupported youtube embed urls: %s', embedUrl => {
expect(getAttributesFromYoutubeEmbedUrl(embedUrl)).toBeNull()
})
describe('missing URLs', () => {
it('accepts a well-formed youtube url', () => {
expect(isValidYoutubeUrl('https://www.youtube.com/watch?v=EkRHhOCdZjw')).toBeTruthy()
})
it('returns null for a missing url instead of throwing', () => {
expect(isValidYoutubeUrl(null)).toBe(null)
})
it('returns null from getEmbedUrlFromYoutubeUrl when the url is missing', () => {
expect(getEmbedUrlFromYoutubeUrl({ url: null as unknown as string })).toBe(null)
})
})

View File

@@ -0,0 +1,234 @@
export const YOUTUBE_REGEX =
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu\.be|youtube-nocookie\.com))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/
export const YOUTUBE_REGEX_GLOBAL =
/^((?:https?:)?\/\/)?((?:www|m|music)\.)?((?:youtube\.com|youtu\.be|youtube-nocookie\.com))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/g
/**
* Checks whether a url is a youtube url that the extension can embed.
*
* The `src` attribute of a youtube node is declared with `default: null`, so a node
* parsed from an iframe without a `src` attribute legitimately carries no url. Guard
* against that here instead of letting the caller dereference it.
*/
export const isValidYoutubeUrl = (url: string | null | undefined) => {
if (!url) {
return null
}
return url.match(YOUTUBE_REGEX)
}
export interface GetEmbedUrlOptions {
url: string
allowFullscreen?: boolean
autoplay?: boolean
ccLanguage?: string
ccLoadPolicy?: boolean
controls?: boolean
disableKBcontrols?: boolean
enableIFrameApi?: boolean
endTime?: number
interfaceLanguage?: string
ivLoadPolicy?: number
loop?: boolean
modestBranding?: boolean
nocookie?: boolean
origin?: string
playlist?: string
progressBarColor?: string
startAt?: number
rel?: number
}
export interface YoutubeEmbedAttributes {
src: string
start?: number
}
export const getYoutubeEmbedUrl = (nocookie?: boolean, isPlaylist?: boolean) => {
if (isPlaylist) {
return 'https://www.youtube-nocookie.com/embed/videoseries?list='
}
return nocookie ? 'https://www.youtube-nocookie.com/embed/' : 'https://www.youtube.com/embed/'
}
export const getEmbedUrlFromYoutubeUrl = (options: GetEmbedUrlOptions) => {
const {
url,
allowFullscreen,
autoplay,
ccLanguage,
ccLoadPolicy,
controls,
disableKBcontrols,
enableIFrameApi,
endTime,
interfaceLanguage,
ivLoadPolicy,
loop,
modestBranding,
nocookie,
origin,
playlist,
progressBarColor,
startAt,
rel,
} = options
if (!isValidYoutubeUrl(url)) {
return null
}
// if is already an embed url, return it
if (url.includes('/embed/')) {
return url
}
// if is a youtu.be url, get the id after the /
if (url.includes('youtu.be')) {
const id = url.split('/').pop()
if (!id) {
return null
}
return `${getYoutubeEmbedUrl(nocookie)}${id}`
}
const videoIdRegex = /(?:(v|list)=|\/(?:shorts|live)\/)([-\w]+)/gm
const matches = videoIdRegex.exec(url)
if (!matches || !matches[2]) {
return null
}
let outputUrl = `${getYoutubeEmbedUrl(nocookie, matches[1] === 'list')}${matches[2]}`
const params = []
if (allowFullscreen === false) {
params.push('fs=0')
}
if (autoplay) {
params.push('autoplay=1')
}
if (ccLanguage) {
params.push(`cc_lang_pref=${ccLanguage}`)
}
if (ccLoadPolicy) {
params.push('cc_load_policy=1')
}
if (!controls) {
params.push('controls=0')
}
if (disableKBcontrols) {
params.push('disablekb=1')
}
if (enableIFrameApi) {
params.push('enablejsapi=1')
}
if (endTime) {
params.push(`end=${endTime}`)
}
if (interfaceLanguage) {
params.push(`hl=${interfaceLanguage}`)
}
if (ivLoadPolicy) {
params.push(`iv_load_policy=${ivLoadPolicy}`)
}
if (loop) {
params.push('loop=1')
}
if (modestBranding) {
params.push('modestbranding=1')
}
if (origin) {
params.push(`origin=${origin}`)
}
if (playlist) {
params.push(`playlist=${playlist}`)
}
if (startAt) {
params.push(`start=${startAt}`)
}
if (progressBarColor) {
params.push(`color=${progressBarColor}`)
}
if (rel !== undefined) {
params.push(`rel=${rel}`)
}
if (params.length) {
outputUrl += `${matches[1] === 'list' ? '&' : '?'}${params.join('&')}`
}
return outputUrl
}
export const getAttributesFromYoutubeEmbedUrl = (url: string): YoutubeEmbedAttributes | null => {
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
return null
}
const hostname = parsedUrl.hostname.replace(/^www\./, '')
if (hostname !== 'youtube.com' && hostname !== 'youtube-nocookie.com') {
return null
}
let src: string | null = null
if (parsedUrl.pathname === '/embed/videoseries') {
const list = parsedUrl.searchParams.get('list')
if (!list) {
return null
}
src = `https://www.youtube.com/playlist?list=${list}`
} else {
const matches = parsedUrl.pathname.match(/^\/embed\/([\w-]+)$/)
if (!matches?.[1]) {
return null
}
src = `https://www.youtube.com/watch?v=${matches[1]}`
}
if (!isValidYoutubeUrl(src)) {
return null
}
const attributes: YoutubeEmbedAttributes = { src }
const start = parsedUrl.searchParams.get('start')
if (start) {
const parsedStart = Number.parseInt(start, 10)
if (!Number.isNaN(parsedStart)) {
attributes.start = parsedStart
}
}
return attributes
}

View File

@@ -0,0 +1,399 @@
import { createAtomBlockMarkdownSpec, mergeAttributes, Node, nodePasteRule } from '@tiptap/core'
import {
getAttributesFromYoutubeEmbedUrl,
getEmbedUrlFromYoutubeUrl,
isValidYoutubeUrl,
YOUTUBE_REGEX_GLOBAL,
} from './utils.js'
const getParsedDimension = (value: string | null) => {
if (!value) {
return null
}
const trimmedValue = value.trim()
if (trimmedValue === '') {
return null
}
const parsedValue = Number(trimmedValue)
return Number.isNaN(parsedValue) ? trimmedValue : parsedValue
}
const getParsedYoutubeAttributes = (element: HTMLElement) => {
const src = element.getAttribute('src')
if (!src) {
return null
}
const embedAttributes = getAttributesFromYoutubeEmbedUrl(src)
if (embedAttributes) {
return embedAttributes
}
return {
src,
}
}
export type { GetEmbedUrlOptions } from './utils.js'
export {
getAttributesFromYoutubeEmbedUrl,
getEmbedUrlFromYoutubeUrl,
isValidYoutubeUrl,
} from './utils.js'
export interface YoutubeOptions {
/**
* Controls if the paste handler for youtube videos should be added.
* @default true
* @example false
*/
addPasteHandler: boolean
/**
* Controls if the youtube video should be allowed to go fullscreen.
* @default true
* @example false
*/
allowFullscreen: boolean
/**
* Controls if the youtube video should autoplay.
* @default false
* @example true
*/
autoplay: boolean
/**
* The language of the captions shown in the youtube video.
* @default undefined
* @example 'en'
*/
ccLanguage?: string
/**
* Controls if the captions should be shown in the youtube video.
* @default undefined
* @example true
*/
ccLoadPolicy?: boolean
/**
* Controls if the controls should be shown in the youtube video.
* @default true
* @example false
*/
controls: boolean
/**
* Controls if the keyboard controls should be disabled in the youtube video.
* @default false
* @example true
*/
disableKBcontrols: boolean
/**
* Controls if the iframe api should be enabled in the youtube video.
* @default false
* @example true
*/
enableIFrameApi: boolean
/**
* The end time of the youtube video.
* @default 0
* @example 120
*/
endTime: number
/**
* The height of the youtube video.
* @default 480
* @example 720
* @example '100%'
*/
height: number | string
/**
* The language of the youtube video.
* @default undefined
* @example 'en'
*/
interfaceLanguage?: string
/**
* Controls if the video annotations should be shown in the youtube video.
* @default 0
* @example 1
*/
ivLoadPolicy: number
/**
* Controls if the youtube video should loop.
* @default false
* @example true
*/
loop: boolean
/**
* Controls if the youtube video should show a small youtube logo.
* @default false
* @example true
*/
modestBranding: boolean
/**
* The HTML attributes for a youtube video node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Controls if the youtube node should be inline or not.
* @default false
* @example true
*/
inline: boolean
/**
* Controls if the youtube video should be loaded from youtube-nocookie.com.
* @default false
* @example true
*/
nocookie: boolean
/**
* The origin of the youtube video.
* @default ''
* @example 'https://tiptap.dev'
*/
origin: string
/**
* The playlist of the youtube video.
* @default ''
* @example 'PLQg6GaokU5CwiVmsZ0dZm6VeIg0V5z1tK'
*/
playlist: string
/**
* The color of the youtube video progress bar.
* @default undefined
* @example 'red'
*/
progressBarColor?: string
/**
* The width of the youtube video.
* @default 640
* @example 1280
* @example '100%'
*/
width: number | string
/**
* Controls if the related youtube videos at the end are from the same channel.
* @default 1
* @example 0
*/
rel: number
}
/**
* The options for setting a youtube video.
*/
type SetYoutubeVideoOptions = {
src: string
width?: number | string
height?: number | string
start?: number
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
youtube: {
/**
* Insert a youtube video
* @param options The youtube video attributes
* @example editor.commands.setYoutubeVideo({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' })
*/
setYoutubeVideo: (options: SetYoutubeVideoOptions) => ReturnType
}
}
}
/**
* This extension adds support for youtube videos.
* @see https://www.tiptap.dev/api/nodes/youtube
*/
export const Youtube = Node.create<YoutubeOptions>({
name: 'youtube',
addOptions() {
return {
addPasteHandler: true,
allowFullscreen: true,
autoplay: false,
ccLanguage: undefined,
ccLoadPolicy: undefined,
controls: true,
disableKBcontrols: false,
enableIFrameApi: false,
endTime: 0,
height: 480,
interfaceLanguage: undefined,
ivLoadPolicy: 0,
loop: false,
modestBranding: false,
HTMLAttributes: {},
inline: false,
nocookie: false,
origin: '',
playlist: '',
progressBarColor: undefined,
width: 640,
rel: 1,
}
},
inline() {
return this.options.inline
},
group() {
return this.options.inline ? 'inline' : 'block'
},
draggable: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: element => getParsedYoutubeAttributes(element)?.src,
},
start: {
default: 0,
parseHTML: element => getParsedYoutubeAttributes(element)?.start,
},
width: {
default: this.options.width,
parseHTML: element => getParsedDimension(element.getAttribute('width')),
},
height: {
default: this.options.height,
parseHTML: element => getParsedDimension(element.getAttribute('height')),
},
}
},
parseHTML() {
return [
{
tag: 'div[data-youtube-video] iframe',
},
]
},
addCommands() {
return {
setYoutubeVideo:
(options: SetYoutubeVideoOptions) =>
({ commands }) => {
if (!isValidYoutubeUrl(options.src)) {
return false
}
return commands.insertContent({
type: this.name,
attrs: options,
})
},
}
},
addPasteRules() {
if (!this.options.addPasteHandler) {
return []
}
return [
nodePasteRule({
find: YOUTUBE_REGEX_GLOBAL,
type: this.type,
getAttributes: match => {
return { src: match.input }
},
}),
]
},
renderHTML({ HTMLAttributes }) {
const embedUrl = getEmbedUrlFromYoutubeUrl({
url: HTMLAttributes.src,
allowFullscreen: this.options.allowFullscreen,
autoplay: this.options.autoplay,
ccLanguage: this.options.ccLanguage,
ccLoadPolicy: this.options.ccLoadPolicy,
controls: this.options.controls,
disableKBcontrols: this.options.disableKBcontrols,
enableIFrameApi: this.options.enableIFrameApi,
endTime: this.options.endTime,
interfaceLanguage: this.options.interfaceLanguage,
ivLoadPolicy: this.options.ivLoadPolicy,
loop: this.options.loop,
modestBranding: this.options.modestBranding,
nocookie: this.options.nocookie,
origin: this.options.origin,
playlist: this.options.playlist,
progressBarColor: this.options.progressBarColor,
startAt: HTMLAttributes.start || 0,
rel: this.options.rel,
})
HTMLAttributes.src = embedUrl
return [
'div',
{ 'data-youtube-video': '' },
[
'iframe',
mergeAttributes(
this.options.HTMLAttributes,
{
width: this.options.width,
height: this.options.height,
allowfullscreen: this.options.allowFullscreen,
autoplay: this.options.autoplay,
ccLanguage: this.options.ccLanguage,
ccLoadPolicy: this.options.ccLoadPolicy,
disableKBcontrols: this.options.disableKBcontrols,
enableIFrameApi: this.options.enableIFrameApi,
endTime: this.options.endTime,
interfaceLanguage: this.options.interfaceLanguage,
ivLoadPolicy: this.options.ivLoadPolicy,
loop: this.options.loop,
modestBranding: this.options.modestBranding,
origin: this.options.origin,
playlist: this.options.playlist,
progressBarColor: this.options.progressBarColor,
rel: this.options.rel,
},
HTMLAttributes,
),
],
]
},
...createAtomBlockMarkdownSpec({
nodeName: 'youtube',
allowedAttributes: ['src', 'width', 'height', 'start'],
}),
})