'use strict';
/* eslint complexity: "off" */
/**
* A minimalist `markdown-it` plugin for parsing video/audio references inside
* markdown image syntax as `<video>` / `<audio>` tags.
*
* @namespace HTML5Media
*/
// 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
// 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 = {
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'
}
};
/**
* You can override this function using options.translateFn.
*
* @param {String} language
* a language code, typically an ISO 639-[1-3] code.
* @param {String} messageKey
* an identifier for the message, typically a short descriptive text
* @param {String[]} messageParams
* Strings to be substituted into the message using some pattern, e.g., %s or
* %1$s, %2$s. By default we only use a simple %s pattern.
* @returns {String}
* the translation to use
* @memberof HTML5Media
*/
let translate = function(language, messageKey, messageParams) {
// 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 (let 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, silent, md) {
let attrs, code, content, label, labelEnd, labelStart, pos, ref, res, title,
token, tokens, start;
let href = '',
oldPos = state.pos,
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;
labelStart = state.pos + 2;
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.
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;
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) {
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, idx, options, env, md) {
const token = tokens[idx];
const type = token.type;
if (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
*/
function html5Media(md, options = {}) {
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, silent) => tokenizeImagesAndMedia(tokens, silent, md));
md.renderer.rules.video = md.renderer.rules.audio =
(tokens, idx, opt, env) => {
opt.html5Media = {
videoAttrs,
audioAttrs
};
return renderMedia(tokens, idx, opt, env, md);
};
}
module.exports = {
html5Media,
messages, // For partial customization of messages
guessMediaType
};