🐛 (markdown) redo the lib for audio media
This commit is contained in:
@@ -4,7 +4,7 @@ import blockEmbedPlugin from 'markdown-it-block-embed'
|
|||||||
import markdownItCheckbox from 'markdown-it-checkbox'
|
import markdownItCheckbox from 'markdown-it-checkbox'
|
||||||
import markdownItSvgCodeCopy from 'markdown-it-svg-code-copy'
|
import markdownItSvgCodeCopy from 'markdown-it-svg-code-copy'
|
||||||
import markdownItFootnote from 'markdown-it-footnote'
|
import markdownItFootnote from 'markdown-it-footnote'
|
||||||
import htmlMedia from 'markdown-it-html5-media'
|
import { html5Media } from '@/utils/markdown/markdown-html5-media'
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
typographer: true,
|
typographer: true,
|
||||||
@@ -19,6 +19,7 @@ const md = new MarkdownIt({
|
|||||||
h6: ['title', 'is-6'],
|
h6: ['title', 'is-6'],
|
||||||
table: ['table', 'is-fullwidth']
|
table: ['table', 'is-fullwidth']
|
||||||
})
|
})
|
||||||
|
.use(html5Media)
|
||||||
.use(blockEmbedPlugin, {
|
.use(blockEmbedPlugin, {
|
||||||
youtube: {
|
youtube: {
|
||||||
width: '100%'
|
width: '100%'
|
||||||
@@ -34,7 +35,6 @@ const md = new MarkdownIt({
|
|||||||
buttonClass: 'button is-light'
|
buttonClass: 'button is-light'
|
||||||
})
|
})
|
||||||
.use(markdownItFootnote)
|
.use(markdownItFootnote)
|
||||||
.use(htmlMedia.html5Media)
|
|
||||||
|
|
||||||
export const useMarkdown = (defaultPrefix?: string) => {
|
export const useMarkdown = (defaultPrefix?: string) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
388
src/utils/markdown/markdown-html5-media.ts
Normal file
388
src/utils/markdown/markdown-html5-media.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// We can only detect video/audio files from the extension in the URL.
|
||||||
|
// We ignore MP1 and MP2 (not in active use) and default to video for ambiguous
|
||||||
|
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
|
// extensions (MPG, MP4)
|
||||||
|
const validAudioExtensions = ['aac', 'm4a', 'mp3', 'oga', 'ogg', 'wav']
|
||||||
|
const validVideoExtensions = ['mp4', 'm4v', 'ogv', 'webm', 'mpg', 'mpeg']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property {Object} messages
|
||||||
|
* @property {Object} messages.languageCode
|
||||||
|
* a set of messages identified with a language code, typically an ISO639 code
|
||||||
|
* @property {String} messages.languageCode.messageKey
|
||||||
|
* an individual translation of a message to that language, identified with a
|
||||||
|
* message key
|
||||||
|
* @typedef {Object} MessagesObj
|
||||||
|
*/
|
||||||
|
let messages: { [key: string]: any } = {
|
||||||
|
en: {
|
||||||
|
'html5 video not supported':
|
||||||
|
'Your browser does not support playing HTML5 video.',
|
||||||
|
'html5 audio not supported':
|
||||||
|
'Your browser does not support playing HTML5 audio.',
|
||||||
|
'html5 media fallback link':
|
||||||
|
'You can <a href="%s" download>download the file</a> instead.',
|
||||||
|
'html5 media description': 'Here is a description of the content: %s'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let translate = (
|
||||||
|
language: string,
|
||||||
|
messageKey: string,
|
||||||
|
messageParams?: unknown[]
|
||||||
|
): string => {
|
||||||
|
// Revert back to English default if no message object, or no translation
|
||||||
|
// for this language
|
||||||
|
if (!messages[language] || !messages[language][messageKey]) {
|
||||||
|
language = 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!messages[language]) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = messages[language][messageKey] || ''
|
||||||
|
|
||||||
|
if (messageParams)
|
||||||
|
for (const param of messageParams) {
|
||||||
|
message = message.replace('%s', param)
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fork of the built-in image tokenizer which guesses video/audio files based
|
||||||
|
* on their extension, and tokenizes them accordingly.
|
||||||
|
*
|
||||||
|
* @param {Object} state
|
||||||
|
* Markdown-It state
|
||||||
|
* @param {Boolean} silent
|
||||||
|
* if true, only validate, don't tokenize
|
||||||
|
* @param {MarkdownIt} md
|
||||||
|
* instance of Markdown-It used for utility functions
|
||||||
|
* @returns {Boolean}
|
||||||
|
* @memberof HTML5Media
|
||||||
|
*/
|
||||||
|
function tokenizeImagesAndMedia(
|
||||||
|
state: {
|
||||||
|
pos: number
|
||||||
|
posMax: number
|
||||||
|
src: string
|
||||||
|
md: {
|
||||||
|
helpers: {
|
||||||
|
parseLinkLabel: (arg0: any, arg1: any, arg2?: boolean) => any
|
||||||
|
parseLinkDestination: (arg0: any, arg1: any, arg2: any) => any
|
||||||
|
parseLinkTitle: (arg0: any, arg1: any, arg2: any) => any
|
||||||
|
}
|
||||||
|
normalizeLink: (arg0: any) => string
|
||||||
|
validateLink: (arg0: string) => any
|
||||||
|
inline: {
|
||||||
|
parse: (arg0: any, arg1: any, arg2: any, arg3: never[]) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env: { references: { [x: string]: any } }
|
||||||
|
push: (arg0: string, arg1: string, arg2: number) => any
|
||||||
|
},
|
||||||
|
silent: any,
|
||||||
|
md: {
|
||||||
|
utils: {
|
||||||
|
isSpace: (arg0: any) => any
|
||||||
|
normalizeReference: (arg0: any) => string | number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
let attrs, code, label, pos, ref, res, title, tokens: never[], start
|
||||||
|
let href = ''
|
||||||
|
const oldPos = state.pos
|
||||||
|
const max = state.posMax
|
||||||
|
|
||||||
|
// Exclamation mark followed by open square bracket - ![ - otherwise abort
|
||||||
|
if (
|
||||||
|
state.src.charCodeAt(state.pos) !== 0x21 ||
|
||||||
|
state.src.charCodeAt(state.pos + 1) !== 0x5b
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const labelStart = state.pos + 2
|
||||||
|
const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, false)
|
||||||
|
|
||||||
|
// Parser failed to find ']', so it's not a valid link
|
||||||
|
if (labelEnd < 0) return false
|
||||||
|
|
||||||
|
pos = labelEnd + 1
|
||||||
|
if (pos < max && state.src.charCodeAt(pos) === 0x28) {
|
||||||
|
// Parenthesis: (
|
||||||
|
//
|
||||||
|
// Inline link
|
||||||
|
//
|
||||||
|
|
||||||
|
// [link]( <href> "title" )
|
||||||
|
// ^^ skipping these spaces
|
||||||
|
pos++
|
||||||
|
for (; pos < max; pos++) {
|
||||||
|
code = state.src.charCodeAt(pos)
|
||||||
|
if (!md.utils.isSpace(code) && code !== 0x0a)
|
||||||
|
// LF \n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pos >= max) return false
|
||||||
|
|
||||||
|
// [link]( <href> "title" )
|
||||||
|
// ^^^^^^ parsing link destination
|
||||||
|
start = pos
|
||||||
|
res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax)
|
||||||
|
if (res.ok) {
|
||||||
|
href = state.md.normalizeLink(res.str)
|
||||||
|
if (state.md.validateLink(href)) {
|
||||||
|
pos = res.pos
|
||||||
|
} else {
|
||||||
|
href = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [link]( <href> "title" )
|
||||||
|
// ^^ skipping these spaces
|
||||||
|
start = pos
|
||||||
|
for (; pos < max; pos++) {
|
||||||
|
code = state.src.charCodeAt(pos)
|
||||||
|
if (!md.utils.isSpace(code) && code !== 0x0a) break
|
||||||
|
}
|
||||||
|
|
||||||
|
// [link]( <href> "title" )
|
||||||
|
// ^^^^^^^ parsing link title
|
||||||
|
res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax)
|
||||||
|
if (pos < max && start !== pos && res.ok) {
|
||||||
|
title = res.str
|
||||||
|
pos = res.pos
|
||||||
|
|
||||||
|
// [link]( <href> "title" )
|
||||||
|
// ^^ skipping these spaces
|
||||||
|
for (; pos < max; pos++) {
|
||||||
|
code = state.src.charCodeAt(pos)
|
||||||
|
if (!md.utils.isSpace(code) && code !== 0x0a) break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos >= max || state.src.charCodeAt(pos) !== 0x29) {
|
||||||
|
// Parenthesis: )
|
||||||
|
state.pos = oldPos
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
} else {
|
||||||
|
//
|
||||||
|
// Link reference
|
||||||
|
//
|
||||||
|
if (typeof state.env.references === 'undefined') return false
|
||||||
|
|
||||||
|
if (pos < max && state.src.charCodeAt(pos) === 0x5b) {
|
||||||
|
// Bracket: [
|
||||||
|
start = pos + 1
|
||||||
|
pos = state.md.helpers.parseLinkLabel(state, pos)
|
||||||
|
if (pos >= 0) {
|
||||||
|
label = state.src.slice(start, pos++)
|
||||||
|
} else {
|
||||||
|
pos = labelEnd + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pos = labelEnd + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// covers label === '' and label === undefined
|
||||||
|
// (collapsed reference link and shortcut reference link respectively)
|
||||||
|
if (!label) label = state.src.slice(labelStart, labelEnd)
|
||||||
|
|
||||||
|
ref = state.env.references[md.utils.normalizeReference(label)]
|
||||||
|
if (!ref) {
|
||||||
|
state.pos = oldPos
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
href = ref.href
|
||||||
|
title = ref.title
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pos = pos
|
||||||
|
state.posMax = max
|
||||||
|
|
||||||
|
if (silent) return true
|
||||||
|
|
||||||
|
// We found the end of the link, and know for a fact it's a valid link;
|
||||||
|
// so all that's left to do is to call tokenizer.
|
||||||
|
const content = state.src.slice(labelStart, labelEnd)
|
||||||
|
|
||||||
|
state.md.inline.parse(content, state.md, state.env, (tokens = []))
|
||||||
|
|
||||||
|
const mediaType = guessMediaType(href)
|
||||||
|
const tag = mediaType == 'image' ? 'img' : mediaType
|
||||||
|
|
||||||
|
const token = state.push(mediaType, tag, 0)
|
||||||
|
token.attrs = attrs = [['src', href]]
|
||||||
|
if (mediaType == 'image') attrs.push(['alt', ''])
|
||||||
|
token.children = tokens
|
||||||
|
token.content = content
|
||||||
|
|
||||||
|
if (title) attrs.push(['title', title])
|
||||||
|
|
||||||
|
state.pos = pos
|
||||||
|
state.posMax = max
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guess the media type represented by a URL based on the file extension,
|
||||||
|
* if any
|
||||||
|
*
|
||||||
|
* @param {String} url
|
||||||
|
* any valid URL
|
||||||
|
* @returns {String}
|
||||||
|
* a type identifier: 'image' (default for all unrecognized URLs), 'audio'
|
||||||
|
* or 'video'
|
||||||
|
* @memberof HTML5Media
|
||||||
|
*/
|
||||||
|
function guessMediaType(url: string): string {
|
||||||
|
const extensionMatch = url.match(/\.([^/.]+)$/)
|
||||||
|
if (extensionMatch === null) return 'image'
|
||||||
|
const extension = extensionMatch[1]
|
||||||
|
if (validAudioExtensions.indexOf(extension.toLowerCase()) != -1)
|
||||||
|
return 'audio'
|
||||||
|
else if (validVideoExtensions.indexOf(extension.toLowerCase()) != -1)
|
||||||
|
return 'video'
|
||||||
|
else return 'image'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tokens of the video/audio type to HTML5 tags
|
||||||
|
*
|
||||||
|
* @param {Object} tokens
|
||||||
|
* token stream
|
||||||
|
* @param {Number} idx
|
||||||
|
* which token are we rendering
|
||||||
|
* @param {Object} options
|
||||||
|
* Markdown-It options, including this plugin's settings
|
||||||
|
* @param {Object} env
|
||||||
|
* Markdown-It environment, potentially including language setting
|
||||||
|
* @param {MarkdownIt} md
|
||||||
|
* instance used for utilities access
|
||||||
|
* @returns {String}
|
||||||
|
* rendered token
|
||||||
|
* @memberof HTML5Media
|
||||||
|
*/
|
||||||
|
function renderMedia(
|
||||||
|
tokens: { [x: string]: any },
|
||||||
|
idx: string | number,
|
||||||
|
options: { html5Media: { [x: string]: string } },
|
||||||
|
env: { language: any },
|
||||||
|
md: { utils: { escapeHtml: (arg0: any) => any } }
|
||||||
|
): string {
|
||||||
|
const token = tokens[idx]
|
||||||
|
const type = token.type
|
||||||
|
|
||||||
|
if (!token.attrs || (type !== 'video' && type !== 'audio')) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = options.html5Media[`${type}Attrs`].trim()
|
||||||
|
if (attrs) {
|
||||||
|
attrs = ' ' + attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll always have a URL for non-image media: they are detected by URL
|
||||||
|
const url = token.attrs[token.attrIndex('src')][1]
|
||||||
|
|
||||||
|
// Title is set like this: 
|
||||||
|
const title =
|
||||||
|
token.attrIndex('title') != -1
|
||||||
|
? ` title="${md.utils.escapeHtml(
|
||||||
|
token.attrs[token.attrIndex('title')][1]
|
||||||
|
)}"`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const fallbackText =
|
||||||
|
translate(env.language, `html5 ${type} not supported`) +
|
||||||
|
'\n' +
|
||||||
|
translate(env.language, 'html5 media fallback link', [url])
|
||||||
|
|
||||||
|
const description = token.content
|
||||||
|
? '\n' +
|
||||||
|
translate(env.language, 'html5 media description', [
|
||||||
|
md.utils.escapeHtml(token.content)
|
||||||
|
])
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<${type} src="${url}"${title}${attrs}>\n` +
|
||||||
|
`${fallbackText}${description}\n` +
|
||||||
|
`</${type}>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main plugin function, exported as module.exports
|
||||||
|
*
|
||||||
|
* @param {MarkdownIt} md
|
||||||
|
* instance, automatically passed by md.use
|
||||||
|
* @param {Object} [options]
|
||||||
|
* configuration
|
||||||
|
* @param {String} [options.videoAttrs='controls class="html5-video-player"']
|
||||||
|
* attributes to include inside `<video>` tags
|
||||||
|
* @param {String} [options.audioAttrs='controls class="html5-audio-player"']
|
||||||
|
* attributes to include inside `<audio>` tags
|
||||||
|
* @param {MessagesObj} [options.messages=built-in messages]
|
||||||
|
* human-readable text that is part of the output
|
||||||
|
* @memberof HTML5Media
|
||||||
|
*/
|
||||||
|
export const html5Media = (
|
||||||
|
md: MarkdownIt & {
|
||||||
|
renderer: {
|
||||||
|
rules: {
|
||||||
|
video?: (tokens: any, idx: any, opt: any, env: any) => string
|
||||||
|
audio?: (tokens: any, idx: any, opt: any, env: any) => string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
messages?: any
|
||||||
|
translateFn?: () => string
|
||||||
|
videoAttrs?: unknown
|
||||||
|
audioAttrs?: unknown
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
if (options.messages) {
|
||||||
|
messages = options.messages
|
||||||
|
}
|
||||||
|
if (options.translateFn) {
|
||||||
|
translate = options.translateFn
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoAttrs =
|
||||||
|
options.videoAttrs !== undefined
|
||||||
|
? options.videoAttrs
|
||||||
|
: 'controls class="html5-video-player"'
|
||||||
|
const audioAttrs =
|
||||||
|
options.audioAttrs !== undefined
|
||||||
|
? options.audioAttrs
|
||||||
|
: 'controls class="html5-audio-player"'
|
||||||
|
|
||||||
|
md.inline.ruler.at('image', (tokens: any, silent: any) =>
|
||||||
|
tokenizeImagesAndMedia(tokens, silent, md)
|
||||||
|
)
|
||||||
|
|
||||||
|
md.renderer.rules.video = md.renderer.rules.audio = (
|
||||||
|
tokens: any,
|
||||||
|
idx: any,
|
||||||
|
opt: { html5Media: { videoAttrs: any; audioAttrs: any } },
|
||||||
|
env: any
|
||||||
|
) => {
|
||||||
|
opt.html5Media = {
|
||||||
|
videoAttrs,
|
||||||
|
audioAttrs
|
||||||
|
}
|
||||||
|
return renderMedia(tokens, idx, opt, env, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user