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:
287
packages/extension-audio/src/audio.test.ts
Normal file
287
packages/extension-audio/src/audio.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { Audio } from './audio.js'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
describe('extension-audio', () => {
|
||||
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 destroyEditor = () => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
getEditorEl()?.remove()
|
||||
}
|
||||
|
||||
it('does not output html for javascript schema or invalid links', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'audio',
|
||||
attrs: {
|
||||
// We want to ensure script URLs are filtered out
|
||||
// oxlint-disable-next-line no-script-url
|
||||
src: 'javascript:alert(window.origin)//audio',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// oxlint-disable-next-line no-script-url
|
||||
expect(editor.getHTML()).not.toContain('javascript:alert')
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
|
||||
it('allows base64 sources only when enabled', () => {
|
||||
const dataUrl = 'data:audio/mp3;base64,aGVsbG8='
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'audio',
|
||||
attrs: {
|
||||
src: dataUrl,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).not.toContain(dataUrl)
|
||||
|
||||
destroyEditor()
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio.configure({ allowBase64: true })],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'audio',
|
||||
attrs: {
|
||||
src: dataUrl,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).toContain(dataUrl)
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
|
||||
it('respects audio attributes and options', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Audio.configure({
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
loop: true,
|
||||
muted: true,
|
||||
preload: 'auto',
|
||||
controlslist: 'nodownload',
|
||||
crossorigin: 'anonymous',
|
||||
disableRemotePlayback: true,
|
||||
}),
|
||||
],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'audio',
|
||||
attrs: {
|
||||
src: 'https://assets.tiptap.dev/sounds/loop.mp3',
|
||||
controls: true,
|
||||
preload: 'none',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toContain('<audio')
|
||||
expect(html).toContain('src="https://assets.tiptap.dev/sounds/loop.mp3"')
|
||||
expect(html).toContain('autoplay')
|
||||
expect(html).toContain('loop')
|
||||
expect(html).toContain('muted')
|
||||
expect(html).toContain('controlslist="nodownload"')
|
||||
expect(html).toContain('crossorigin="anonymous"')
|
||||
expect(html).toContain('disableremoteplayback')
|
||||
expect(html).toContain('preload="none"')
|
||||
expect(html).toContain('controls')
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
|
||||
it('does not insert invalid audio sources via command', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio],
|
||||
})
|
||||
|
||||
const succeeded = editor.commands.setAudio({
|
||||
// We want to ensure script URLs are filtered out
|
||||
// oxlint-disable-next-line no-script-url
|
||||
src: 'javascript:alert(1)',
|
||||
})
|
||||
|
||||
expect(succeeded).toBe(false)
|
||||
// oxlint-disable-next-line no-script-url
|
||||
expect(editor.getHTML()).not.toContain('javascript:alert')
|
||||
|
||||
const validInsert = editor.commands.setAudio({
|
||||
src: 'https://assets.tiptap.dev/sounds/loop.mp3',
|
||||
loop: true,
|
||||
muted: true,
|
||||
disableremoteplayback: true,
|
||||
controls: false,
|
||||
})
|
||||
|
||||
expect(validInsert).toBe(true)
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toContain('src="https://assets.tiptap.dev/sounds/loop.mp3"')
|
||||
expect(html).toContain('loop')
|
||||
expect(html).toContain('muted')
|
||||
expect(html).toContain('disableremoteplayback')
|
||||
expect(html).not.toContain('controls')
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
|
||||
it('creates audio node when pasting a valid audio URL', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio],
|
||||
})
|
||||
|
||||
const audioUrl = 'https://assets.tiptap.dev/sounds/loop.mp3'
|
||||
|
||||
// Simulate paste by inserting plain text content that matches the paste rule regex
|
||||
editor.view.pasteText(audioUrl)
|
||||
|
||||
const json = editor.getJSON()
|
||||
const audioNode = json.content?.find(node => node.type === 'audio')
|
||||
|
||||
expect(audioNode).toBeDefined()
|
||||
expect(audioNode?.attrs?.src).toBe(audioUrl)
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
|
||||
it('does not create audio node when paste handler is disabled', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio.configure({ addPasteHandler: false })],
|
||||
})
|
||||
|
||||
const audioUrl = 'https://assets.tiptap.dev/sounds/loop.mp3'
|
||||
|
||||
editor.commands.insertContent(audioUrl)
|
||||
|
||||
const json = editor.getJSON()
|
||||
const audioNode = json.content?.find(node => node.type === 'audio')
|
||||
|
||||
expect(audioNode).toBeUndefined()
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
|
||||
it('renders audio node as inline when inline option is enabled', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio.configure({ inline: true })],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Audio: ',
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
attrs: {
|
||||
src: 'https://assets.tiptap.dev/sounds/loop.mp3',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toContain('<p>Audio: <audio')
|
||||
expect(html).toContain('src="https://assets.tiptap.dev/sounds/loop.mp3"')
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
|
||||
it('renders audio node as block by default', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Audio],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
attrs: {
|
||||
src: 'https://assets.tiptap.dev/sounds/loop.mp3',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toContain('<p>Some text</p><audio')
|
||||
|
||||
destroyEditor()
|
||||
})
|
||||
})
|
||||
272
packages/extension-audio/src/audio.ts
Normal file
272
packages/extension-audio/src/audio.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { createAtomBlockMarkdownSpec, mergeAttributes, Node, nodePasteRule } from '@tiptap/core'
|
||||
|
||||
import { AUDIO_URL_REGEX_GLOBAL, isValidAudioUrl, sanitizeAudioSrc } from './utils.js'
|
||||
|
||||
export interface AudioOptions {
|
||||
/**
|
||||
* Controls if the paste handler for audio sources should be added.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
addPasteHandler: boolean
|
||||
|
||||
/**
|
||||
* Controls if the audio should autoplay.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
autoplay: boolean
|
||||
|
||||
/**
|
||||
* Controls if the audio element should render the native controls.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
controls: boolean
|
||||
|
||||
/**
|
||||
* Controls if the audio should loop.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
loop: boolean
|
||||
|
||||
/**
|
||||
* Controls if the audio should be muted.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
muted: boolean
|
||||
|
||||
/**
|
||||
* The preload behavior for the audio element.
|
||||
* @default 'metadata'
|
||||
* @example 'none'
|
||||
*/
|
||||
preload: 'auto' | 'metadata' | 'none' | null
|
||||
|
||||
/**
|
||||
* Provides the controlslist attribute for the audio element.
|
||||
* @default undefined
|
||||
* @example 'nodownload'
|
||||
*/
|
||||
controlslist?: string
|
||||
|
||||
/**
|
||||
* Sets the crossorigin attribute for the audio element.
|
||||
* @default undefined
|
||||
* @example 'anonymous'
|
||||
*/
|
||||
crossorigin?: '' | 'anonymous' | 'use-credentials'
|
||||
|
||||
/**
|
||||
* Controls if remote playback should be disabled on the audio element.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
disableRemotePlayback: boolean
|
||||
|
||||
/**
|
||||
* Allows data: URLs for audio sources.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
allowBase64: boolean
|
||||
|
||||
/**
|
||||
* The HTML attributes for an audio node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
|
||||
/**
|
||||
* Controls if the audio node should be inline or not.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
inline: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The options for setting an audio element.
|
||||
*/
|
||||
type SetAudioOptions = {
|
||||
src: string
|
||||
controls?: boolean
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
muted?: boolean
|
||||
preload?: 'auto' | 'metadata' | 'none' | null
|
||||
controlslist?: string
|
||||
crossorigin?: '' | 'anonymous' | 'use-credentials'
|
||||
disableremoteplayback?: boolean
|
||||
HTMLAttributes?: Record<string, any>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
audio: {
|
||||
/**
|
||||
* Insert an audio element
|
||||
* @param options The audio attributes
|
||||
* @example editor.commands.setAudio({ src: 'https://assets.tiptap.dev/sounds/loop.mp3' })
|
||||
*/
|
||||
setAudio: (options: SetAudioOptions) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension adds support for native audio elements.
|
||||
*/
|
||||
export const Audio = Node.create<AudioOptions>({
|
||||
name: 'audio',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
addPasteHandler: true,
|
||||
allowBase64: false,
|
||||
autoplay: false,
|
||||
controls: true,
|
||||
loop: false,
|
||||
muted: false,
|
||||
preload: 'metadata',
|
||||
controlslist: undefined,
|
||||
crossorigin: undefined,
|
||||
disableRemotePlayback: false,
|
||||
HTMLAttributes: {},
|
||||
inline: false,
|
||||
}
|
||||
},
|
||||
|
||||
inline() {
|
||||
return this.options.inline
|
||||
},
|
||||
|
||||
group() {
|
||||
return this.options.inline ? 'inline' : 'block'
|
||||
},
|
||||
|
||||
atom: true,
|
||||
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
controls: {
|
||||
default: this.options.controls,
|
||||
},
|
||||
autoplay: {
|
||||
default: this.options.autoplay,
|
||||
},
|
||||
loop: {
|
||||
default: this.options.loop,
|
||||
},
|
||||
muted: {
|
||||
default: this.options.muted,
|
||||
},
|
||||
preload: {
|
||||
default: this.options.preload,
|
||||
},
|
||||
controlslist: {
|
||||
default: this.options.controlslist,
|
||||
},
|
||||
crossorigin: {
|
||||
default: this.options.crossorigin,
|
||||
},
|
||||
disableremoteplayback: {
|
||||
default: this.options.disableRemotePlayback,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: this.options.allowBase64 ? 'audio[src]' : 'audio[src]:not([src^="data:"])',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setAudio:
|
||||
options =>
|
||||
({ commands }) => {
|
||||
if (!isValidAudioUrl(options.src, this.options.allowBase64)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
if (!this.options.addPasteHandler) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
nodePasteRule({
|
||||
find: AUDIO_URL_REGEX_GLOBAL,
|
||||
type: this.type,
|
||||
getAttributes: match => {
|
||||
return { src: match[0] }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const sanitizedSrc = sanitizeAudioSrc(HTMLAttributes.src, this.options.allowBase64)
|
||||
|
||||
const mergedAttributes = mergeAttributes(
|
||||
this.options.HTMLAttributes,
|
||||
{
|
||||
controls: this.options.controls,
|
||||
autoplay: this.options.autoplay,
|
||||
loop: this.options.loop,
|
||||
muted: this.options.muted,
|
||||
preload: this.options.preload,
|
||||
controlslist: this.options.controlslist,
|
||||
crossorigin: this.options.crossorigin,
|
||||
disableremoteplayback: this.options.disableRemotePlayback,
|
||||
},
|
||||
{
|
||||
...HTMLAttributes,
|
||||
src: sanitizedSrc,
|
||||
},
|
||||
)
|
||||
|
||||
const cleanedAttributes = Object.fromEntries(
|
||||
Object.entries(mergedAttributes).filter(
|
||||
([, value]) => value !== null && value !== undefined && value !== false,
|
||||
),
|
||||
)
|
||||
|
||||
return ['audio', cleanedAttributes]
|
||||
},
|
||||
|
||||
...createAtomBlockMarkdownSpec({
|
||||
nodeName: 'audio',
|
||||
allowedAttributes: [
|
||||
'src',
|
||||
'controls',
|
||||
'autoplay',
|
||||
'loop',
|
||||
'muted',
|
||||
'preload',
|
||||
'controlslist',
|
||||
'crossorigin',
|
||||
'disableremoteplayback',
|
||||
],
|
||||
}),
|
||||
})
|
||||
6
packages/extension-audio/src/index.ts
Normal file
6
packages/extension-audio/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Audio } from './audio.js'
|
||||
|
||||
export * from './audio.js'
|
||||
export * from './utils.js'
|
||||
|
||||
export default Audio
|
||||
70
packages/extension-audio/src/utils.ts
Normal file
70
packages/extension-audio/src/utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Regular expression to match URLs pointing to common audio file formats.
|
||||
* Matches http/https URLs ending in: mp3, wav, ogg, oga, flac, m4a, aac, opus, weba, webm.
|
||||
* Supports optional query strings. Case-insensitive, single match.
|
||||
*/
|
||||
export const AUDIO_URL_REGEX =
|
||||
/https?:\/\/[^\s]+?\.(?:mp3|wav|ogg|oga|flac|m4a|aac|opus|weba|webm)(?:\?[^\s]*)?/i
|
||||
|
||||
/**
|
||||
* Global variant of AUDIO_URL_REGEX for matching multiple audio URLs in a string.
|
||||
* Use this when you need to find all audio URLs in a block of text.
|
||||
*/
|
||||
export const AUDIO_URL_REGEX_GLOBAL =
|
||||
/https?:\/\/[^\s]+?\.(?:mp3|wav|ogg|oga|flac|m4a|aac|opus|weba|webm)(?:\?[^\s]*)?/gi
|
||||
|
||||
/**
|
||||
* Regular expression to match base64-encoded audio data URLs.
|
||||
*/
|
||||
const AUDIO_DATA_URL_REGEX = /^data:audio\/[a-zA-Z0-9.+-]+;base64,/i
|
||||
|
||||
/**
|
||||
* Validates whether a URL is safe for use in an audio element.
|
||||
* Allows http, https, protocol-relative, and relative paths by default.
|
||||
* Data URLs can be enabled via the allowBase64 flag.
|
||||
*
|
||||
* @param url - The URL string to validate
|
||||
* @param allowBase64 - Whether to allow base64 data URLs (default: false)
|
||||
* @returns True if the URL is valid and safe, false otherwise
|
||||
*/
|
||||
export const isValidAudioUrl = (url: string, allowBase64 = false) => {
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (allowBase64 && AUDIO_DATA_URL_REGEX.test(url)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (url.startsWith('//')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an audio source URL by validating it against allowed protocols.
|
||||
* Returns null for invalid or potentially unsafe URLs.
|
||||
*
|
||||
* @param url - The URL string to sanitize (can be null or undefined)
|
||||
* @param allowBase64 - Whether to allow base64 data URLs (default: false)
|
||||
* @returns The original URL if valid, or null if invalid/unsafe
|
||||
*/
|
||||
export const sanitizeAudioSrc = (url: string | null | undefined, allowBase64 = false) => {
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return isValidAudioUrl(url, allowBase64) ? url : null
|
||||
}
|
||||
Reference in New Issue
Block a user