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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-youtube
[![Version](https://img.shields.io/npm/v/@tiptap/extension-youtube.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-youtube)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-youtube.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-youtube.svg)](https://www.npmjs.com/package/@tiptap/extension-youtube)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,50 @@
{
"name": "@tiptap/extension-youtube",
"version": "3.30.3",
"description": "a youtube embed extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-youtube"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*"
}
}

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'],
}),
})

View File

@@ -0,0 +1,332 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Youtube from '@tiptap/extension-youtube'
import { describe, expect, it } from 'vite-plus/test'
describe('extension-youtube', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
const invalidUrls = [
// We have to disable the oxlint rule here because we're trying to purposely test eval urls
// oxlint-disable-next-line no-script-url
'javascript:alert(window.origin)//embed/',
'https://youtube.google.com/embed/fdsafsdf',
'https://youtube.com.bad/embed',
'https://www.youtu0be/embed',
]
invalidUrls.forEach(url => {
it(`does not output html for javascript schema or non-youtube links for url ${url}`, () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: {
type: 'doc',
content: [
{
type: 'youtube',
attrs: {
src: url,
},
},
],
},
})
expect(editor.getHTML()).not.toContain(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
it('when nocookie youtube url is passed, still outputs html with iframe with the url', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: {
type: 'doc',
content: [
{
type: 'youtube',
attrs: {
src: 'https://www.youtube-nocookie.com/embed/testvideoid',
},
},
],
},
})
expect(editor.getHTML()).toContain('https://www.youtube-nocookie.com/embed/testvideoid')
editor?.destroy()
getEditorEl()?.remove()
})
it.each([
[
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
42,
],
['https://youtu.be/dQw4w9WgXcQ', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 0],
[
'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
0,
],
])(
'preserves canonical youtube attrs when content is loaded back from rendered HTML for %s',
(originalSrc, expectedSrc, start) => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: {
type: 'doc',
content: [
{
type: 'youtube',
attrs: {
src: originalSrc,
start,
width: 720,
height: 405,
},
},
],
},
})
const html = editor.getHTML()
editor.destroy()
getEditorEl()?.remove()
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: html,
})
expect(editor.getJSON()).toMatchObject({
type: 'doc',
content: [
{
type: 'youtube',
attrs: {
src: expectedSrc,
start,
width: 720,
height: 405,
},
},
],
})
editor?.destroy()
getEditorEl()?.remove()
},
)
describe('iframe dimension handling', () => {
it('preserves percentage width and height when parsing HTML', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content:
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="100%" height="56.25%"></iframe></div>',
})
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
expect(attrs.width).toBe('100%')
expect(attrs.height).toBe('56.25%')
editor?.destroy()
getEditorEl()?.remove()
})
it('keeps percentage dimensions when content is loaded back from rendered HTML', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: {
type: 'doc',
content: [
{
type: 'youtube',
attrs: {
src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
width: '100%',
height: 480,
},
},
],
},
})
const html = editor.getHTML()
expect(html).toContain('width="100%"')
editor.destroy()
getEditorEl()?.remove()
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: html,
})
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
expect(attrs.width).toBe('100%')
expect(attrs.height).toBe(480)
editor?.destroy()
getEditorEl()?.remove()
})
it('still parses plain numeric dimensions as numbers', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content:
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="720" height="405"></iframe></div>',
})
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
expect(attrs.width).toBe(720)
expect(attrs.height).toBe(405)
editor?.destroy()
getEditorEl()?.remove()
})
it.each([
['100px', '200px'],
['20rem', '10em'],
['50vw', '30vh'],
])('preserves unit-based dimensions like %s when parsing HTML', (width, height) => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: `<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="${width}" height="${height}"></iframe></div>`,
})
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
expect(attrs.width).toBe(width)
expect(attrs.height).toBe(height)
editor?.destroy()
getEditorEl()?.remove()
})
it('preserves non-numeric strings instead of falling back to the default dimensions', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content:
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="not-a-size" height="auto"></iframe></div>',
})
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
expect(attrs.width).toBe('not-a-size')
expect(attrs.height).toBe('auto')
editor?.destroy()
getEditorEl()?.remove()
})
it('accepts a percentage width in the setYoutubeVideo command', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
})
editor.commands.setYoutubeVideo({
src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
width: '100%',
height: 405,
})
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
expect(attrs.width).toBe('100%')
expect(attrs.height).toBe(405)
editor?.destroy()
getEditorEl()?.remove()
})
})
it('does not persist NaN width or height when parsing iframe dimensions from HTML', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content:
'<div data-youtube-video><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="100%" height="auto"></iframe></div>',
})
const attrs = editor.getJSON().content?.[0]?.attrs ?? {}
const html = editor.getHTML()
expect(Number.isNaN(attrs.width)).toBe(false)
expect(Number.isNaN(attrs.height)).toBe(false)
expect(html).not.toContain('width="NaN"')
expect(html).not.toContain('height="NaN"')
editor?.destroy()
getEditorEl()?.remove()
})
describe('missing src attribute', () => {
// The `src` attribute is declared with `default: null` and its `parseHTML` returns
// `undefined` when the iframe carries no `src`, so a node with a null `src` is a
// legitimate parse result rather than a malformed document.
const iframeWithoutSrc =
'<div data-youtube-video=""><iframe class="w-full aspect-video" width="640" height="480" allowfullscreen="true" start="0"></iframe></div>'
it('refuses setYoutubeVideo with a missing src instead of throwing', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
})
expect(editor.commands.setYoutubeVideo({ src: null as unknown as string })).toBe(false)
editor?.destroy()
getEditorEl()?.remove()
})
it('parses an iframe without a src attribute without throwing', () => {
expect(() => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Youtube],
content: iframeWithoutSrc,
})
}).not.toThrow()
expect(editor?.getHTML()).not.toContain('src=')
editor?.destroy()
getEditorEl()?.remove()
})
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})