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
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:
358
packages/extension-twitch/CHANGELOG.md
Normal file
358
packages/extension-twitch/CHANGELOG.md
Normal 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
|
||||
18
packages/extension-twitch/README.md
Normal file
18
packages/extension-twitch/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-twitch
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-twitch)
|
||||
[](https://npmcharts.com/compare/@tiptap/extension-twitch?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-twitch)
|
||||
[](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).
|
||||
50
packages/extension-twitch/package.json
Normal file
50
packages/extension-twitch/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
6
packages/extension-twitch/src/index.ts
Normal file
6
packages/extension-twitch/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Twitch } from './twitch.js'
|
||||
|
||||
export * from './twitch.js'
|
||||
export * from './utils.js'
|
||||
|
||||
export default Twitch
|
||||
302
packages/extension-twitch/src/twitch.ts
Normal file
302
packages/extension-twitch/src/twitch.ts
Normal 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'],
|
||||
}),
|
||||
})
|
||||
11
packages/extension-twitch/src/utils.test.ts
Normal file
11
packages/extension-twitch/src/utils.test.ts
Normal 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()
|
||||
})
|
||||
295
packages/extension-twitch/src/utils.ts
Normal file
295
packages/extension-twitch/src/utils.ts
Normal 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
|
||||
}
|
||||
593
packages/extension-twitch/tests/integration/twitch.test.ts
Normal file
593
packages/extension-twitch/tests/integration/twitch.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
9
packages/extension-twitch/vite.config.ts
Normal file
9
packages/extension-twitch/vite.config.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user