From c0f1ba28db660cb4021b654e275cc20055147c49 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 2 Jan 2022 14:08:41 +0100 Subject: [PATCH] :bug: (markdown) redo the lib for audio media --- src/hooks/useMarkdown.hook.ts | 4 +- src/utils/markdown/markdown-html5-media.ts | 388 +++++++++++++++++++++ 2 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 src/utils/markdown/markdown-html5-media.ts diff --git a/src/hooks/useMarkdown.hook.ts b/src/hooks/useMarkdown.hook.ts index 45ad824..56bec3c 100644 --- a/src/hooks/useMarkdown.hook.ts +++ b/src/hooks/useMarkdown.hook.ts @@ -4,7 +4,7 @@ import blockEmbedPlugin from 'markdown-it-block-embed' import markdownItCheckbox from 'markdown-it-checkbox' import markdownItSvgCodeCopy from 'markdown-it-svg-code-copy' 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({ typographer: true, @@ -19,6 +19,7 @@ const md = new MarkdownIt({ h6: ['title', 'is-6'], table: ['table', 'is-fullwidth'] }) + .use(html5Media) .use(blockEmbedPlugin, { youtube: { width: '100%' @@ -34,7 +35,6 @@ const md = new MarkdownIt({ buttonClass: 'button is-light' }) .use(markdownItFootnote) - .use(htmlMedia.html5Media) export const useMarkdown = (defaultPrefix?: string) => { return { diff --git a/src/utils/markdown/markdown-html5-media.ts b/src/utils/markdown/markdown-html5-media.ts new file mode 100644 index 0000000..b979ecd --- /dev/null +++ b/src/utils/markdown/markdown-html5-media.ts @@ -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 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 `