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,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()
})
})

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

View File

@@ -0,0 +1,6 @@
import { Audio } from './audio.js'
export * from './audio.js'
export * from './utils.js'
export default Audio

View 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
}