/* oxlint-disable typescript/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 download the file 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]( "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]( "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]( "title" )
// ^^ skipping these spaces
start = pos
for (; pos < max; pos++) {
code = state.src.charCodeAt(pos)
if (!md.utils.isSpace(code) && code !== 0x0a) break
}
// [link]( "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]( "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 `