/* 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: ![descriptive text](video.mp4 "title") 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` + `` ) } /** * 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 `