@tiptap/extension-twitch 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/twitch.ts ADDED
@@ -0,0 +1,257 @@
1
+ import { createAtomBlockMarkdownSpec, mergeAttributes, Node, nodePasteRule } from '@tiptap/core'
2
+
3
+ import { getEmbedUrlFromTwitchUrl, isValidTwitchUrl, TWITCH_REGEX_GLOBAL } from './utils.js'
4
+
5
+ export interface TwitchOptions {
6
+ /**
7
+ * Controls if the paste handler for Twitch videos should be added.
8
+ * @default true
9
+ * @example false
10
+ */
11
+ addPasteHandler: boolean
12
+
13
+ /**
14
+ * Controls if the Twitch video should be allowed to go fullscreen.
15
+ * @default true
16
+ * @example false
17
+ */
18
+ allowFullscreen: boolean
19
+
20
+ /**
21
+ * Controls if the Twitch video should autoplay.
22
+ * @default false
23
+ * @example true
24
+ */
25
+ autoplay: boolean
26
+
27
+ /**
28
+ * Controls if the Twitch video should start muted.
29
+ * @default false
30
+ * @example true
31
+ */
32
+ muted: boolean
33
+
34
+ /**
35
+ * The time in the video where playback starts (format: 1h2m3s).
36
+ * Only works for video embeds, not for clips or channels.
37
+ * @default undefined
38
+ * @example '1h2m3s'
39
+ */
40
+ time?: string
41
+
42
+ /**
43
+ * The parent domain for the Twitch embed. Required for embed functionality.
44
+ * @default 'localhost'
45
+ * @example 'example.com'
46
+ */
47
+ parent: string
48
+
49
+ /**
50
+ * The height of the Twitch video.
51
+ * @default 480
52
+ * @example 720
53
+ */
54
+ height: number
55
+
56
+ /**
57
+ * The width of the Twitch video.
58
+ * @default 640
59
+ * @example 1280
60
+ */
61
+ width: number
62
+
63
+ /**
64
+ * The HTML attributes for a Twitch video node.
65
+ * @default {}
66
+ * @example { class: 'foo' }
67
+ */
68
+ HTMLAttributes: Record<string, any>
69
+
70
+ /**
71
+ * Controls if the Twitch node should be inline or not.
72
+ * @default false
73
+ * @example true
74
+ */
75
+ inline: boolean
76
+ }
77
+
78
+ /**
79
+ * The options for setting a Twitch video.
80
+ */
81
+ type SetTwitchVideoOptions = {
82
+ src: string
83
+ width?: number
84
+ height?: number
85
+ autoplay?: boolean
86
+ muted?: boolean
87
+ time?: string
88
+ }
89
+
90
+ declare module '@tiptap/core' {
91
+ interface Commands<ReturnType> {
92
+ twitch: {
93
+ /**
94
+ * Insert a Twitch video
95
+ * @param options The Twitch video attributes
96
+ * @example editor.commands.setTwitchVideo({ src: 'https://www.twitch.tv/videos/1234567890' })
97
+ */
98
+ setTwitchVideo: (options: SetTwitchVideoOptions) => ReturnType
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * This extension adds support for Twitch videos.
105
+ * @see https://www.tiptap.dev/api/nodes/twitch
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * import { useEditor, EditorContent } from '@tiptap/react'
110
+ * import { StarterKit } from '@tiptap/starter-kit'
111
+ * import { Twitch } from '@tiptap/extension-twitch'
112
+ *
113
+ * const editor = useEditor({
114
+ * extensions: [
115
+ * StarterKit,
116
+ * Twitch.configure({
117
+ * parent: 'example.com',
118
+ * allowFullscreen: true,
119
+ * }),
120
+ * ],
121
+ * content: '<p>Hello World!</p>',
122
+ * })
123
+ * ```
124
+ */
125
+ export const Twitch = Node.create<TwitchOptions>({
126
+ name: 'twitch',
127
+
128
+ addOptions() {
129
+ return {
130
+ addPasteHandler: true,
131
+ allowFullscreen: true,
132
+ autoplay: false,
133
+ muted: false,
134
+ time: undefined,
135
+ parent: 'localhost',
136
+ height: 480,
137
+ width: 640,
138
+ HTMLAttributes: {},
139
+ inline: false,
140
+ }
141
+ },
142
+
143
+ inline() {
144
+ return this.options.inline
145
+ },
146
+
147
+ group() {
148
+ return this.options.inline ? 'inline' : 'block'
149
+ },
150
+
151
+ draggable: true,
152
+
153
+ addAttributes() {
154
+ return {
155
+ src: {
156
+ default: null,
157
+ },
158
+ width: {
159
+ default: this.options.width,
160
+ },
161
+ height: {
162
+ default: this.options.height,
163
+ },
164
+ autoplay: {
165
+ default: this.options.autoplay,
166
+ },
167
+ muted: {
168
+ default: this.options.muted,
169
+ },
170
+ time: {
171
+ default: this.options.time,
172
+ },
173
+ }
174
+ },
175
+
176
+ parseHTML() {
177
+ return [
178
+ {
179
+ tag: 'div[data-twitch-video] iframe',
180
+ },
181
+ ]
182
+ },
183
+
184
+ addCommands() {
185
+ return {
186
+ setTwitchVideo:
187
+ (options: SetTwitchVideoOptions) =>
188
+ ({ commands }) => {
189
+ if (!isValidTwitchUrl(options.src)) {
190
+ return false
191
+ }
192
+
193
+ return commands.insertContent({
194
+ type: this.name,
195
+ attrs: options,
196
+ })
197
+ },
198
+ }
199
+ },
200
+
201
+ addPasteRules() {
202
+ if (!this.options.addPasteHandler) {
203
+ return []
204
+ }
205
+
206
+ return [
207
+ nodePasteRule({
208
+ find: TWITCH_REGEX_GLOBAL,
209
+ type: this.type,
210
+ getAttributes: match => {
211
+ return { src: match.input }
212
+ },
213
+ }),
214
+ ]
215
+ },
216
+
217
+ renderHTML({ HTMLAttributes }) {
218
+ const embedUrl = getEmbedUrlFromTwitchUrl({
219
+ url: HTMLAttributes.src,
220
+ allowFullscreen: this.options.allowFullscreen,
221
+ autoplay: HTMLAttributes.autoplay ?? this.options.autoplay,
222
+ muted: HTMLAttributes.muted ?? this.options.muted,
223
+ time: HTMLAttributes.time ?? this.options.time,
224
+ parent: this.options.parent,
225
+ })
226
+
227
+ if (!embedUrl) {
228
+ return ['div', 'Invalid Twitch URL']
229
+ }
230
+
231
+ HTMLAttributes.src = embedUrl
232
+
233
+ return [
234
+ 'div',
235
+ { 'data-twitch-video': '' },
236
+ [
237
+ 'iframe',
238
+ mergeAttributes(
239
+ this.options.HTMLAttributes,
240
+ {
241
+ width: this.options.width,
242
+ height: this.options.height,
243
+ allowfullscreen: this.options.allowFullscreen,
244
+ scrolling: 'no',
245
+ frameborder: '0',
246
+ },
247
+ HTMLAttributes,
248
+ ),
249
+ ],
250
+ ]
251
+ },
252
+
253
+ ...createAtomBlockMarkdownSpec({
254
+ nodeName: 'twitch',
255
+ allowedAttributes: ['src', 'width', 'height', 'autoplay', 'muted', 'time'],
256
+ }),
257
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Regex patterns for matching Twitch URLs
3
+ * Matches:
4
+ * - https://twitch.tv/videos/1234567890
5
+ * - https://www.twitch.tv/videos/1234567890
6
+ * - https://twitch.tv/examplechannel/clip/ClipName-123
7
+ * - https://www.twitch.tv/examplechannel/clip/ClipName-123
8
+ * - https://clips.twitch.tv/ClipName
9
+ * - https://www.clips.twitch.tv/ClipName
10
+ * - https://twitch.tv/examplechannel (channel)
11
+ * - https://www.twitch.tv/examplechannel
12
+ */
13
+ export const TWITCH_REGEX =
14
+ /^(https?:\/\/)?(www\.)?(twitch\.tv|clips\.twitch\.tv)\/(?:videos\/(\d+)|(\w+)\/clip\/([\w-]+)|([\w-]+)(?:\/)?)?(\?.*)?$/
15
+
16
+ export const TWITCH_REGEX_GLOBAL =
17
+ /^(https?:\/\/)?(www\.)?(twitch\.tv|clips\.twitch\.tv)\/(?:videos\/(\d+)|(\w+)\/clip\/([\w-]+)|([\w-]+)(?:\/)?)?(\?.*)?$/g
18
+
19
+ /**
20
+ * Validates if a URL is a valid Twitch video, clip or channel URL
21
+ *
22
+ * @param url - The URL to validate
23
+ * @returns true if the URL is a valid Twitch URL
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * isValidTwitchUrl('https://www.twitch.tv/videos/1234567890') // true
28
+ * isValidTwitchUrl('https://www.twitch.tv/examplechannel/clip/ExampleClipName') // true
29
+ * isValidTwitchUrl('https://www.twitch.tv/examplechannel') // true
30
+ * isValidTwitchUrl('https://clips.twitch.tv/ExampleClipName') // true
31
+ * isValidTwitchUrl('invalid') // false
32
+ * ```
33
+ */
34
+ export const isValidTwitchUrl = (url: string) => {
35
+ return url.match(TWITCH_REGEX)
36
+ }
37
+
38
+ export interface GetEmbedUrlOptions {
39
+ url: string
40
+ allowFullscreen?: boolean
41
+ autoplay?: boolean
42
+ muted?: boolean
43
+ time?: string
44
+ parent?: string
45
+ }
46
+
47
+ /**
48
+ * Extracts the video, clip or channel identifier from a Twitch URL
49
+ *
50
+ * @param url - The Twitch URL
51
+ * @returns Object containing type ('video', 'clip' or 'channel') and ID, or null if invalid
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * getTwitchIdentifier('https://www.twitch.tv/videos/1234567890')
56
+ * // { type: 'video', id: '1234567890' }
57
+ *
58
+ * getTwitchIdentifier('https://www.twitch.tv/examplechannel/clip/ExampleClipName-ABC123')
59
+ * // { type: 'clip', id: 'ExampleClipName-ABC123' }
60
+ *
61
+ * getTwitchIdentifier('https://www.twitch.tv/examplechannel')
62
+ * // { type: 'channel', id: 'examplechannel' }
63
+ * ```
64
+ */
65
+ export const getTwitchIdentifier = (url: string): { type: 'video' | 'clip' | 'channel'; id: string } | null => {
66
+ if (!isValidTwitchUrl(url)) {
67
+ return null
68
+ }
69
+
70
+ // Remove query parameters
71
+ const cleanUrl = url.split('?')[0]
72
+
73
+ // Handle clip URLs from clips.twitch.tv
74
+ if (cleanUrl.includes('clips.twitch.tv/')) {
75
+ const clipRegex = /clips\.twitch\.tv\/([\w-]+)/
76
+ const match = cleanUrl.match(clipRegex)
77
+ return match ? { type: 'clip', id: match[1] } : null
78
+ }
79
+
80
+ // Handle twitch.tv URLs
81
+ if (cleanUrl.includes('twitch.tv/')) {
82
+ // Check if it's a video URL (videos/ID)
83
+ const videoRegex = /twitch\.tv\/videos\/(\d+)/
84
+ const videoMatch = cleanUrl.match(videoRegex)
85
+ if (videoMatch) {
86
+ return { type: 'video', id: videoMatch[1] }
87
+ }
88
+
89
+ // Check if it's a clip URL (channel/clip/clipname)
90
+ const channelClipRegex = /twitch\.tv\/([\w-]+)\/clip\/([\w-]+)/
91
+ const clipMatch = cleanUrl.match(channelClipRegex)
92
+ if (clipMatch) {
93
+ return { type: 'clip', id: clipMatch[2] }
94
+ }
95
+
96
+ // Otherwise it's a channel URL
97
+ const channelRegex = /twitch\.tv\/([\w-]+)(?:\/)?$/
98
+ const channelMatch = cleanUrl.match(channelRegex)
99
+ if (channelMatch) {
100
+ return { type: 'channel', id: channelMatch[1] }
101
+ }
102
+ }
103
+
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * Generates an embed URL from a Twitch video, clip or channel URL with optional parameters
109
+ *
110
+ * @param options - The embed URL options
111
+ * @returns The embed URL or null if invalid
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * getEmbedUrlFromTwitchUrl({
116
+ * url: 'https://www.twitch.tv/videos/1234567890',
117
+ * parent: 'example.com',
118
+ * muted: true,
119
+ * time: '1h2m3s'
120
+ * })
121
+ * // Returns: https://player.twitch.tv/?video=1234567890&parent=example.com&muted=true&time=1h2m3s
122
+ *
123
+ * getEmbedUrlFromTwitchUrl({
124
+ * url: 'https://www.twitch.tv/examplechannel/clip/ExampleClipName-ABC123',
125
+ * parent: 'example.com',
126
+ * autoplay: true,
127
+ * muted: true
128
+ * })
129
+ * // Returns: https://clips.twitch.tv/embed?clip=ExampleClipName-ABC123&parent=example.com&autoplay=true&muted=true
130
+ *
131
+ * getEmbedUrlFromTwitchUrl({
132
+ * url: 'https://www.twitch.tv/examplechannel',
133
+ * parent: 'example.com'
134
+ * })
135
+ * // Returns: https://player.twitch.tv/?channel=examplechannel&parent=example.com
136
+ * ```
137
+ */
138
+ export const getEmbedUrlFromTwitchUrl = (options: GetEmbedUrlOptions): string | null => {
139
+ const { url, allowFullscreen = true, autoplay = false, muted = false, time, parent } = options
140
+
141
+ const identifier = getTwitchIdentifier(url)
142
+
143
+ if (!identifier) {
144
+ return null
145
+ }
146
+
147
+ const parentDomain = parent || 'localhost'
148
+
149
+ // Handle clip URLs
150
+ if (identifier.type === 'clip') {
151
+ const clipUrl = new URL('https://clips.twitch.tv/embed')
152
+ clipUrl.searchParams.set('clip', identifier.id)
153
+ clipUrl.searchParams.set('parent', parentDomain)
154
+
155
+ if (autoplay) {
156
+ clipUrl.searchParams.set('autoplay', 'true')
157
+ }
158
+
159
+ if (muted) {
160
+ clipUrl.searchParams.set('muted', 'true')
161
+ }
162
+
163
+ return clipUrl.toString()
164
+ }
165
+
166
+ // Handle video URLs
167
+ if (identifier.type === 'video') {
168
+ const videoUrl = new URL('https://player.twitch.tv/')
169
+ videoUrl.searchParams.set('video', identifier.id)
170
+ videoUrl.searchParams.set('parent', parentDomain)
171
+
172
+ if (allowFullscreen) {
173
+ videoUrl.searchParams.set('allowfullscreen', 'true')
174
+ }
175
+
176
+ if (autoplay) {
177
+ videoUrl.searchParams.set('autoplay', 'true')
178
+ }
179
+
180
+ if (muted) {
181
+ videoUrl.searchParams.set('muted', 'true')
182
+ }
183
+
184
+ if (time) {
185
+ videoUrl.searchParams.set('time', time)
186
+ }
187
+
188
+ return videoUrl.toString()
189
+ }
190
+
191
+ // Handle channel URLs
192
+ if (identifier.type === 'channel') {
193
+ const channelUrl = new URL('https://player.twitch.tv/')
194
+ channelUrl.searchParams.set('channel', identifier.id)
195
+ channelUrl.searchParams.set('parent', parentDomain)
196
+
197
+ if (allowFullscreen) {
198
+ channelUrl.searchParams.set('allowfullscreen', 'true')
199
+ }
200
+
201
+ if (autoplay) {
202
+ channelUrl.searchParams.set('autoplay', 'true')
203
+ }
204
+
205
+ if (muted) {
206
+ channelUrl.searchParams.set('muted', 'true')
207
+ }
208
+
209
+ return channelUrl.toString()
210
+ }
211
+
212
+ return null
213
+ }