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,358 @@
# @tiptap/extension-twitch
## 3.30.3
### Patch Changes
- Updated dependencies [965a880]
- @tiptap/core@3.30.3
## 3.30.2
### Patch Changes
- Updated dependencies [3dffed5]
- Updated dependencies [214a140]
- @tiptap/core@3.30.2
## 3.30.1
### Patch Changes
- Updated dependencies [abc8828]
- @tiptap/core@3.30.1
## 3.30.0
### Patch Changes
- Updated dependencies [0247d39]
- Updated dependencies [51909d3]
- Updated dependencies [3099eef]
- @tiptap/core@3.30.0
## 3.29.2
### Patch Changes
- @tiptap/core@3.29.2
## 3.29.1
### Patch Changes
- @tiptap/core@3.29.1
## 3.29.0
### Patch Changes
- Updated dependencies [d26840f]
- Updated dependencies [935e63f]
- Updated dependencies [b4c5a2d]
- Updated dependencies [a963d48]
- Updated dependencies [51f45b6]
- Updated dependencies [0f63969]
- Updated dependencies [9acaa65]
- @tiptap/core@3.29.0
## 3.28.0
### Patch Changes
- @tiptap/core@3.28.0
## 3.27.4
### Patch Changes
- @tiptap/core@3.27.4
## 3.27.3
### Patch Changes
- Updated dependencies [023f98c]
- @tiptap/core@3.27.3
## 3.27.2
### Patch Changes
- @tiptap/core@3.27.2
## 3.27.1
### Patch Changes
- @tiptap/core@3.27.1
## 3.27.0
### Patch Changes
- Updated dependencies [0d0094d]
- Updated dependencies [795033c]
- Updated dependencies [0e0c4f9]
- Updated dependencies [6d12bb9]
- @tiptap/core@3.27.0
## 3.26.1
### Patch Changes
- @tiptap/core@3.26.1
## 3.26.0
### Patch Changes
- @tiptap/core@3.26.0
## 3.25.0
### Patch Changes
- Updated dependencies [ec291dd]
- Updated dependencies [454e9b8]
- Updated dependencies [9cf8db0]
- Updated dependencies [3d4f94c]
- @tiptap/core@3.25.0
## 3.24.0
### Patch Changes
- @tiptap/core@3.24.0
## 3.23.6
### Patch Changes
- Updated dependencies [d168376]
- @tiptap/core@3.23.6
## 3.23.5
### Patch Changes
- Updated dependencies [835caf5]
- Updated dependencies [95e138c]
- @tiptap/core@3.23.5
## 3.23.4
### Patch Changes
- @tiptap/core@3.23.4
## 3.23.3
### Patch Changes
- @tiptap/core@3.23.3
## 3.23.2
### Patch Changes
- 79fc8b7: Fix Twitch and YouTube embeds losing their canonical video, clip, channel, or playlist URL when HTML content is loaded back into the editor.
- Updated dependencies [f98eaaf]
- @tiptap/core@3.23.2
## 3.23.1
### Patch Changes
- @tiptap/core@3.23.1
## 3.23.0
### Patch Changes
- Updated dependencies [57f8d66]
- Updated dependencies [e64e5a7]
- Updated dependencies [207a2bc]
- @tiptap/core@3.23.0
## 3.22.5
### Patch Changes
- Updated dependencies [a375002]
- @tiptap/core@3.22.5
## 3.22.4
### Patch Changes
- 27ea931: Fix dependencies installation after packages updates producing peer dependency resolution conflicts
- Updated dependencies [27ea931]
- Updated dependencies [64f36b8]
- @tiptap/core@3.22.4
## 3.22.3
### Patch Changes
- Updated dependencies [cb28e7b]
- @tiptap/core@3.22.3
## 3.22.2
### Patch Changes
- Updated dependencies [f1d504c]
- Updated dependencies [404c683]
- @tiptap/core@3.22.2
## 3.22.1
### Patch Changes
- Updated dependencies [ee03ac0]
- Updated dependencies [b88f9ed]
- @tiptap/core@3.22.1
## 3.22.0
### Patch Changes
- Updated dependencies [912a49b]
- Updated dependencies [7d4fb9a]
- Updated dependencies [0c1c112]
- Updated dependencies [0c1c112]
- Updated dependencies [f99bdc2]
- @tiptap/core@3.22.0
## 3.21.0
### Patch Changes
- @tiptap/core@3.21.0
## 3.20.6
### Patch Changes
- @tiptap/core@3.20.6
## 3.20.5
### Patch Changes
- @tiptap/core@3.20.5
## 3.20.4
### Patch Changes
- Updated dependencies [0bcf3c2]
- @tiptap/core@3.20.4
## 3.20.3
### Patch Changes
- Updated dependencies [c94fac4]
- Updated dependencies [6b9ea92]
- @tiptap/core@3.20.3
## 3.20.2
### Patch Changes
- Updated dependencies [269823d]
- @tiptap/core@3.20.2
## 3.20.1
### Patch Changes
- Updated dependencies [25f57e4]
- @tiptap/core@3.20.1
## 3.20.0
### Patch Changes
- Updated dependencies [4b731e2]
- Updated dependencies [98546ac]
- Updated dependencies [57624a1]
- Updated dependencies [76ce47d]
- @tiptap/core@3.20.0
## 3.19.0
### Patch Changes
- @tiptap/core@3.19.0
## 3.18.0
### Patch Changes
- @tiptap/core@3.18.0
## 3.17.1
### Patch Changes
- Updated dependencies [aa9709e]
- Updated dependencies [b46e66a]
- @tiptap/core@3.17.1
## 3.17.0
### Patch Changes
- Updated dependencies [eecf1c9]
- @tiptap/core@3.17.0
## 3.16.0
### Patch Changes
- @tiptap/core@3.16.0
## 3.15.3
### Patch Changes
- Updated dependencies [8f86f06]
- @tiptap/core@3.15.3
## 3.15.2
### Patch Changes
- @tiptap/core@3.15.2
## 3.15.1
### Patch Changes
- @tiptap/core@3.15.1
## 3.15.0
### Patch Changes
- Updated dependencies [ac8361c]
- @tiptap/core@3.15.0
## 3.14.0
### Minor Changes
- 5717dcf: Add new Twitch extension for embedding Twitch videos, clips, and live channels in the editor. Supports customizable parameters like autoplay, muted, and start time, with attribute-level overrides for per-embed configuration.
### Patch Changes
- @tiptap/core@3.14.0
## 3.0.0
### Major Changes
- Initial release of Twitch extension for Tiptap

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-twitch
[![Version](https://img.shields.io/npm/v/@tiptap/extension-twitch.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-twitch)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-twitch.svg)](https://npmcharts.com/compare/@tiptap/extension-twitch?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-twitch.svg)](https://www.npmjs.com/package/@tiptap/extension-twitch)
[![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-twitch",
"version": "3.30.3",
"description": "a twitch 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-twitch"
},
"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,6 @@
import { Twitch } from './twitch.js'
export * from './twitch.js'
export * from './utils.js'
export default Twitch

View File

@@ -0,0 +1,302 @@
import { createAtomBlockMarkdownSpec, mergeAttributes, Node, nodePasteRule } from '@tiptap/core'
import {
getAttributesFromTwitchEmbedUrl,
getEmbedUrlFromTwitchUrl,
isValidTwitchUrl,
TWITCH_REGEX_GLOBAL,
} from './utils.js'
const getParsedDimension = (value: string | null) => {
if (!value) {
return null
}
const parsedValue = Number.parseInt(value, 10)
return Number.isNaN(parsedValue) ? null : parsedValue
}
const getParsedTwitchAttributes = (element: HTMLElement) => {
const src = element.getAttribute('src')
if (!src) {
return null
}
const embedAttributes = getAttributesFromTwitchEmbedUrl(src)
if (embedAttributes) {
return embedAttributes
}
if (isValidTwitchUrl(src)) {
return {
src,
}
}
return {
src,
}
}
export interface TwitchOptions {
/**
* Controls if the paste handler for Twitch videos should be added.
* @default true
* @example false
*/
addPasteHandler: boolean
/**
* Controls if the Twitch video should be allowed to go fullscreen.
* @default true
* @example false
*/
allowFullscreen: boolean
/**
* Controls if the Twitch video should autoplay.
* @default false
* @example true
*/
autoplay: boolean
/**
* Controls if the Twitch video should start muted.
* @default false
* @example true
*/
muted: boolean
/**
* The time in the video where playback starts (format: 1h2m3s).
* Only works for video embeds, not for clips or channels.
* @default undefined
* @example '1h2m3s'
*/
time?: string
/**
* The parent domain for the Twitch embed. Required for embed functionality.
* @default 'localhost'
* @example 'example.com'
*/
parent: string
/**
* The height of the Twitch video.
* @default 480
* @example 720
*/
height: number
/**
* The width of the Twitch video.
* @default 640
* @example 1280
*/
width: number
/**
* The HTML attributes for a Twitch video node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Controls if the Twitch node should be inline or not.
* @default false
* @example true
*/
inline: boolean
}
/**
* The options for setting a Twitch video.
*/
type SetTwitchVideoOptions = {
src: string
width?: number
height?: number
autoplay?: boolean
muted?: boolean
time?: string
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
twitch: {
/**
* Insert a Twitch video
* @param options The Twitch video attributes
* @example editor.commands.setTwitchVideo({ src: 'https://www.twitch.tv/videos/1234567890' })
*/
setTwitchVideo: (options: SetTwitchVideoOptions) => ReturnType
}
}
}
/**
* This extension adds support for Twitch videos.
* @see https://www.tiptap.dev/api/nodes/twitch
*
* @example
* ```ts
* import { useEditor, EditorContent } from '@tiptap/react'
* import { StarterKit } from '@tiptap/starter-kit'
* import { Twitch } from '@tiptap/extension-twitch'
*
* const editor = useEditor({
* extensions: [
* StarterKit,
* Twitch.configure({
* parent: 'example.com',
* allowFullscreen: true,
* }),
* ],
* content: '<p>Hello World!</p>',
* })
* ```
*/
export const Twitch = Node.create<TwitchOptions>({
name: 'twitch',
addOptions() {
return {
addPasteHandler: true,
allowFullscreen: true,
autoplay: false,
muted: false,
time: undefined,
parent: 'localhost',
height: 480,
width: 640,
HTMLAttributes: {},
inline: false,
}
},
inline() {
return this.options.inline
},
group() {
return this.options.inline ? 'inline' : 'block'
},
draggable: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: element => getParsedTwitchAttributes(element)?.src,
},
width: {
default: this.options.width,
parseHTML: element => getParsedDimension(element.getAttribute('width')),
},
height: {
default: this.options.height,
parseHTML: element => getParsedDimension(element.getAttribute('height')),
},
autoplay: {
default: this.options.autoplay,
parseHTML: element => getParsedTwitchAttributes(element)?.autoplay,
},
muted: {
default: this.options.muted,
parseHTML: element => getParsedTwitchAttributes(element)?.muted,
},
time: {
default: this.options.time,
parseHTML: element => getParsedTwitchAttributes(element)?.time,
},
}
},
parseHTML() {
return [
{
tag: 'div[data-twitch-video] iframe',
},
]
},
addCommands() {
return {
setTwitchVideo:
(options: SetTwitchVideoOptions) =>
({ commands }) => {
if (!isValidTwitchUrl(options.src)) {
return false
}
return commands.insertContent({
type: this.name,
attrs: options,
})
},
}
},
addPasteRules() {
if (!this.options.addPasteHandler) {
return []
}
return [
nodePasteRule({
find: TWITCH_REGEX_GLOBAL,
type: this.type,
getAttributes: match => {
return { src: match.input }
},
}),
]
},
renderHTML({ HTMLAttributes }) {
const embedUrl = getEmbedUrlFromTwitchUrl({
url: HTMLAttributes.src,
allowFullscreen: this.options.allowFullscreen,
autoplay: HTMLAttributes.autoplay ?? this.options.autoplay,
muted: HTMLAttributes.muted ?? this.options.muted,
time: HTMLAttributes.time ?? this.options.time,
parent: this.options.parent,
})
if (!embedUrl) {
return ['div', 'Invalid Twitch URL']
}
HTMLAttributes.src = embedUrl
return [
'div',
{ 'data-twitch-video': '' },
[
'iframe',
mergeAttributes(
this.options.HTMLAttributes,
{
width: this.options.width,
height: this.options.height,
allowfullscreen: this.options.allowFullscreen,
scrolling: 'no',
frameborder: '0',
},
HTMLAttributes,
),
],
]
},
...createAtomBlockMarkdownSpec({
nodeName: 'twitch',
allowedAttributes: ['src', 'width', 'height', 'autoplay', 'muted', 'time'],
}),
})

View File

@@ -0,0 +1,11 @@
import { expect, it } from 'vite-plus/test'
import { getAttributesFromTwitchEmbedUrl } from './utils.js'
it.each([
'https://player.twitch.tv/?video=abc&parent=example.com',
'https://player.twitch.tv/?channel=foo%2Fbar&parent=example.com',
'https://clips.twitch.tv/embed?clip=bad%20clip&parent=example.com',
])('returns null for malformed embed identifiers: %s', embedUrl => {
expect(getAttributesFromTwitchEmbedUrl(embedUrl)).toBeNull()
})

View File

@@ -0,0 +1,295 @@
/**
* Regex patterns for matching Twitch URLs
* Matches:
* - https://twitch.tv/videos/1234567890
* - https://www.twitch.tv/videos/1234567890
* - https://twitch.tv/examplechannel/clip/ClipName-123
* - https://www.twitch.tv/examplechannel/clip/ClipName-123
* - https://clips.twitch.tv/ClipName
* - https://www.clips.twitch.tv/ClipName
* - https://twitch.tv/examplechannel (channel)
* - https://www.twitch.tv/examplechannel
*/
export const TWITCH_REGEX =
/^(https?:\/\/)?(www\.)?(twitch\.tv|clips\.twitch\.tv)\/(?:videos\/(\d+)|(\w+)\/clip\/([\w-]+)|([\w-]+)(?:\/)?)?(\?.*)?$/
export const TWITCH_REGEX_GLOBAL =
/^(https?:\/\/)?(www\.)?(twitch\.tv|clips\.twitch\.tv)\/(?:videos\/(\d+)|(\w+)\/clip\/([\w-]+)|([\w-]+)(?:\/)?)?(\?.*)?$/g
/**
* Validates if a URL is a valid Twitch video, clip or channel URL
*
* @param url - The URL to validate
* @returns true if the URL is a valid Twitch URL
*
* @example
* ```ts
* isValidTwitchUrl('https://www.twitch.tv/videos/1234567890') // true
* isValidTwitchUrl('https://www.twitch.tv/examplechannel/clip/ExampleClipName') // true
* isValidTwitchUrl('https://www.twitch.tv/examplechannel') // true
* isValidTwitchUrl('https://clips.twitch.tv/ExampleClipName') // true
* isValidTwitchUrl('invalid') // false
* ```
*/
export const isValidTwitchUrl = (url: string) => {
return url.match(TWITCH_REGEX)
}
export interface GetEmbedUrlOptions {
url: string
allowFullscreen?: boolean
autoplay?: boolean
muted?: boolean
time?: string
parent?: string
}
export interface TwitchEmbedAttributes {
src: string
autoplay?: boolean
muted?: boolean
time?: string
}
/**
* Extracts the video, clip or channel identifier from a Twitch URL
*
* @param url - The Twitch URL
* @returns Object containing type ('video', 'clip' or 'channel') and ID, or null if invalid
*
* @example
* ```ts
* getTwitchIdentifier('https://www.twitch.tv/videos/1234567890')
* // { type: 'video', id: '1234567890' }
*
* getTwitchIdentifier('https://www.twitch.tv/examplechannel/clip/ExampleClipName-ABC123')
* // { type: 'clip', id: 'ExampleClipName-ABC123' }
*
* getTwitchIdentifier('https://www.twitch.tv/examplechannel')
* // { type: 'channel', id: 'examplechannel' }
* ```
*/
export const getTwitchIdentifier = (
url: string,
): { type: 'video' | 'clip' | 'channel'; id: string } | null => {
if (!isValidTwitchUrl(url)) {
return null
}
// Remove query parameters
const cleanUrl = url.split('?')[0]
// Handle clip URLs from clips.twitch.tv
if (cleanUrl.includes('clips.twitch.tv/')) {
const clipRegex = /clips\.twitch\.tv\/([\w-]+)/
const match = cleanUrl.match(clipRegex)
return match ? { type: 'clip', id: match[1] } : null
}
// Handle twitch.tv URLs
if (cleanUrl.includes('twitch.tv/')) {
// Check if it's a video URL (videos/ID)
const videoRegex = /twitch\.tv\/videos\/(\d+)/
const videoMatch = cleanUrl.match(videoRegex)
if (videoMatch) {
return { type: 'video', id: videoMatch[1] }
}
// Check if it's a clip URL (channel/clip/clipname)
const channelClipRegex = /twitch\.tv\/([\w-]+)\/clip\/([\w-]+)/
const clipMatch = cleanUrl.match(channelClipRegex)
if (clipMatch) {
return { type: 'clip', id: clipMatch[2] }
}
// Otherwise it's a channel URL
const channelRegex = /twitch\.tv\/([\w-]+)(?:\/)?$/
const channelMatch = cleanUrl.match(channelRegex)
if (channelMatch) {
return { type: 'channel', id: channelMatch[1] }
}
}
return null
}
/**
* Generates an embed URL from a Twitch video, clip or channel URL with optional parameters
*
* @param options - The embed URL options
* @returns The embed URL or null if invalid
*
* @example
* ```ts
* getEmbedUrlFromTwitchUrl({
* url: 'https://www.twitch.tv/videos/1234567890',
* parent: 'example.com',
* muted: true,
* time: '1h2m3s'
* })
* // Returns: https://player.twitch.tv/?video=1234567890&parent=example.com&muted=true&time=1h2m3s
*
* getEmbedUrlFromTwitchUrl({
* url: 'https://www.twitch.tv/examplechannel/clip/ExampleClipName-ABC123',
* parent: 'example.com',
* autoplay: true,
* muted: true
* })
* // Returns: https://clips.twitch.tv/embed?clip=ExampleClipName-ABC123&parent=example.com&autoplay=true&muted=true
*
* getEmbedUrlFromTwitchUrl({
* url: 'https://www.twitch.tv/examplechannel',
* parent: 'example.com'
* })
* // Returns: https://player.twitch.tv/?channel=examplechannel&parent=example.com
* ```
*/
export const getEmbedUrlFromTwitchUrl = (options: GetEmbedUrlOptions): string | null => {
const { url, allowFullscreen = true, autoplay = false, muted = false, time, parent } = options
const identifier = getTwitchIdentifier(url)
if (!identifier) {
return null
}
const parentDomain = parent || 'localhost'
// Handle clip URLs
if (identifier.type === 'clip') {
const clipUrl = new URL('https://clips.twitch.tv/embed')
clipUrl.searchParams.set('clip', identifier.id)
clipUrl.searchParams.set('parent', parentDomain)
if (autoplay) {
clipUrl.searchParams.set('autoplay', 'true')
}
if (muted) {
clipUrl.searchParams.set('muted', 'true')
}
return clipUrl.toString()
}
// Handle video URLs
if (identifier.type === 'video') {
const videoUrl = new URL('https://player.twitch.tv/')
videoUrl.searchParams.set('video', identifier.id)
videoUrl.searchParams.set('parent', parentDomain)
if (allowFullscreen) {
videoUrl.searchParams.set('allowfullscreen', 'true')
}
if (autoplay) {
videoUrl.searchParams.set('autoplay', 'true')
}
if (muted) {
videoUrl.searchParams.set('muted', 'true')
}
if (time) {
videoUrl.searchParams.set('time', time)
}
return videoUrl.toString()
}
// Handle channel URLs
if (identifier.type === 'channel') {
const channelUrl = new URL('https://player.twitch.tv/')
channelUrl.searchParams.set('channel', identifier.id)
channelUrl.searchParams.set('parent', parentDomain)
if (allowFullscreen) {
channelUrl.searchParams.set('allowfullscreen', 'true')
}
if (autoplay) {
channelUrl.searchParams.set('autoplay', 'true')
}
if (muted) {
channelUrl.searchParams.set('muted', 'true')
}
return channelUrl.toString()
}
return null
}
/**
* Parses a Twitch embed URL into canonical Twitch node attributes.
*
* @param url - The Twitch embed URL
* @returns Canonical node attributes or null if the URL is not a supported Twitch embed URL
*
* @example
* ```ts
* getAttributesFromTwitchEmbedUrl('https://player.twitch.tv/?video=1234567890&parent=example.com&muted=true')
* // Returns: { src: 'https://www.twitch.tv/videos/1234567890', muted: true }
*
* getAttributesFromTwitchEmbedUrl('https://clips.twitch.tv/embed?clip=ExampleClipName-ABC123&parent=example.com')
* // Returns: { src: 'https://clips.twitch.tv/ExampleClipName-ABC123' }
* ```
*/
export const getAttributesFromTwitchEmbedUrl = (url: string): TwitchEmbedAttributes | null => {
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
return null
}
const hostname = parsedUrl.hostname.replace(/^www\./, '')
const result: TwitchEmbedAttributes = {
src: '',
}
if (hostname === 'player.twitch.tv') {
const videoId = parsedUrl.searchParams.get('video')
const channelName = parsedUrl.searchParams.get('channel')
if (videoId) {
result.src = `https://www.twitch.tv/videos/${videoId}`
} else if (channelName) {
result.src = `https://www.twitch.tv/${channelName}`
} else {
return null
}
} else if (hostname === 'clips.twitch.tv' && parsedUrl.pathname === '/embed') {
const clipId = parsedUrl.searchParams.get('clip')
if (!clipId) {
return null
}
result.src = `https://clips.twitch.tv/${clipId}`
} else {
return null
}
if (parsedUrl.searchParams.get('autoplay') === 'true') {
result.autoplay = true
}
if (parsedUrl.searchParams.get('muted') === 'true') {
result.muted = true
}
const time = parsedUrl.searchParams.get('time')
if (time) {
result.time = time
}
if (!isValidTwitchUrl(result.src)) {
return null
}
return result
}

View File

@@ -0,0 +1,593 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import Twitch from '@tiptap/extension-twitch'
import { describe, expect, it } from 'vite-plus/test'
describe('extension-twitch', () => {
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://twitch.google.com/videos/1234567890',
'https://twitch.com.bad/videos/1234567890',
'https://www.twtich.tv/videos/1234567890',
]
invalidUrls.forEach(url => {
it(`does not output html for javascript schema or non-twitch links for url ${url}`, () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Twitch],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: url,
},
},
],
},
})
expect(editor.getHTML()).not.toContain(url)
editor?.destroy()
getEditorEl()?.remove()
})
})
it('outputs html with iframe for valid twitch video id', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
},
},
],
},
})
expect(editor.getHTML()).toContain('player.twitch.tv')
expect(editor.getHTML()).toContain('1234567890')
expect(editor.getHTML()).toContain('example.com')
editor?.destroy()
getEditorEl()?.remove()
})
it('outputs html with iframe for twitch clip url', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://clips.twitch.tv/ExampleClipName-ABC123',
},
},
],
},
})
expect(editor.getHTML()).toContain('clips.twitch.tv/embed')
expect(editor.getHTML()).toContain('ExampleClipName-ABC123')
expect(editor.getHTML()).toContain('example.com')
editor?.destroy()
getEditorEl()?.remove()
})
it('outputs html with iframe for twitch channel url', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/examplechannel',
},
},
],
},
})
expect(editor.getHTML()).toContain('player.twitch.tv')
expect(editor.getHTML()).toContain('examplechannel')
editor?.destroy()
getEditorEl()?.remove()
})
it('respects allowFullscreen option', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
allowFullscreen: true,
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
},
},
],
},
})
const html = editor.getHTML()
expect(html).toContain('allowfullscreen="true"')
editor?.destroy()
getEditorEl()?.remove()
})
it('respects custom width and height', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
width: 800,
height: 600,
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
width: 800,
height: 600,
},
},
],
},
})
const html = editor.getHTML()
expect(html).toContain('width="800"')
expect(html).toContain('height="600"')
editor?.destroy()
getEditorEl()?.remove()
})
it('can insert a video using the setTwitchVideo command', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
})
editor.commands.setTwitchVideo({
src: 'https://www.twitch.tv/videos/1234567890',
})
expect(editor.getHTML()).toContain('data-twitch-video')
editor?.destroy()
getEditorEl()?.remove()
})
it('does not insert video with invalid URL using setTwitchVideo command', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Twitch],
})
const result = editor.commands.setTwitchVideo({
src: 'https://invalid.com/video',
})
expect(result).toBe(false)
editor?.destroy()
getEditorEl()?.remove()
})
it('handles video URL with search parameters', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890?filter=archives&sort=time',
},
},
],
},
})
expect(editor.getHTML()).toContain('player.twitch.tv')
expect(editor.getHTML()).toContain('1234567890')
expect(editor.getHTML()).not.toContain('filter=archives')
editor?.destroy()
getEditorEl()?.remove()
})
it('handles clip URL with search parameters', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://clips.twitch.tv/ExampleClipName-ABC123?foo=bar&baz=qux',
},
},
],
},
})
expect(editor.getHTML()).toContain('clips.twitch.tv/embed')
expect(editor.getHTML()).toContain('ExampleClipName-ABC123')
expect(editor.getHTML()).not.toContain('foo=bar')
editor?.destroy()
getEditorEl()?.remove()
})
it('respects muted option', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
muted: true,
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
},
},
],
},
})
const html = editor.getHTML()
expect(html).toContain('muted=true')
editor?.destroy()
getEditorEl()?.remove()
})
it('respects time option for videos', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
time: '1h2m3s',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
},
},
],
},
})
const html = editor.getHTML()
expect(html).toContain('time=1h2m3s')
editor?.destroy()
getEditorEl()?.remove()
})
it('respects attribute overrides for autoplay', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
autoplay: true,
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
autoplay: false,
},
},
],
},
})
const html = editor.getHTML()
// Should NOT have autoplay=true since attribute overrides option
expect(html).not.toContain('autoplay=true')
editor?.destroy()
getEditorEl()?.remove()
})
it('respects attribute overrides for muted', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
muted: false,
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
muted: true,
},
},
],
},
})
const html = editor.getHTML()
expect(html).toContain('muted=true')
editor?.destroy()
getEditorEl()?.remove()
})
it('respects attribute overrides for time', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
time: '1h0m0s',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: 'https://www.twitch.tv/videos/1234567890',
time: '2h30m15s',
},
},
],
},
})
const html = editor.getHTML()
expect(html).toContain('time=2h30m15s')
expect(html).not.toContain('1h0m0s')
editor?.destroy()
getEditorEl()?.remove()
})
it.each([
'https://www.twitch.tv/videos/1234567890',
'https://clips.twitch.tv/ExampleClipName-ABC123',
'https://www.twitch.tv/examplechannel',
])(
'preserves the twitch src when content is loaded back from rendered HTML for %s',
originalSrc => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
content: {
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: originalSrc,
},
},
],
},
})
const html = editor.getHTML()
editor.destroy()
getEditorEl()?.remove()
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Twitch.configure({
parent: 'example.com',
}),
],
content: html,
})
expect(editor.getJSON()).toMatchObject({
type: 'doc',
content: [
{
type: 'twitch',
attrs: {
src: originalSrc,
},
},
],
})
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,
Twitch.configure({
parent: 'example.com',
}),
],
content:
'<div data-twitch-video><iframe src="https://player.twitch.tv/?video=1234567890&parent=example.com" 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()
})
it('renders invalid Twitch content without crashing when parsed HTML contains an unsupported src', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Text, Paragraph, Twitch],
content:
'<div data-twitch-video><iframe src="https://example.com/not-a-twitch-url"></iframe></div>',
})
expect(() => editor?.getHTML()).not.toThrow()
expect(editor.getHTML()).toContain('Invalid Twitch URL')
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(),
},
})