",
19 | "license": "MPLv2",
20 | "bugs": {
21 | "url": "https://github.com/cmrd-senya/markdown-it-html5-embed/issues"
22 | },
23 | "dependencies": {
24 | "markdown-it": "^12.3.2",
25 | "mimoza": "~1.0.0"
26 | },
27 | "devDependencies": {
28 | "handlebars": "~4.7.7",
29 | "markdown-it-testgen": "^0.1.4",
30 | "mocha": "^10.0.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/test/fixtures/link-syntax.txt:
--------------------------------------------------------------------------------
1 | Video with link syntax:
2 | .
3 | [test link](https://example.com/file.webm)
4 | .
5 |
10 | .
11 |
12 | Video with link syntax (no text label):
13 | .
14 | [](https://example.com/file.webm)
15 | .
16 |
20 | .
21 |
22 | Check usual link is not broken:
23 | .
24 | [test link](https://example.com/file.php)
25 | .
26 | test link
27 | .
28 |
--------------------------------------------------------------------------------
/test/fixtures/link-syntax-http-enabled.txt:
--------------------------------------------------------------------------------
1 | Video with link syntax:
2 | .
3 | [test link](http://example.com/file.webm)
4 | .
5 |
10 | .
11 |
12 | Video with link syntax (no text label):
13 | .
14 | [](http://example.com/file.webm)
15 | .
16 |
20 | .
21 |
22 | Check usual link is not broken:
23 | .
24 | [test link](http://example.com/file.php)
25 | .
26 | test link
27 | .
28 |
--------------------------------------------------------------------------------
/test/fixtures/mime-filter.txt:
--------------------------------------------------------------------------------
1 | Webm is not allowed:
2 | .
3 | [test link](https://example.com/file.webm)
4 | .
5 | test link
6 | .
7 |
8 | MP3 is allowed
9 | .
10 | [](https://example.com/file.mp3)
11 | .
12 |
16 | .
17 |
18 | OGV is allowed
19 | .
20 | [test link](https://example.com/file.ogv)
21 | .
22 |
27 | .
28 |
29 | Non-media link
30 | .
31 | [test link](https://example.com/file.php)
32 | .
33 | test link
34 | .
35 |
--------------------------------------------------------------------------------
/test/fixtures/image-syntax-custom-messages.txt:
--------------------------------------------------------------------------------
1 | Video with image syntax:
2 | .
3 | 
4 | .
5 |
9 | .
10 |
11 | Audio with image syntax:
12 | .
13 | 
14 | .
15 |
19 | .
20 |
21 | Check usual image is not broken:
22 | .
23 | 
24 | .
25 | 
26 | .
27 |
28 | Video with image syntax and title:
29 | .
30 | 
31 | .
32 |
37 | .
38 |
--------------------------------------------------------------------------------
/test/fixtures/image-syntax-with-translation.txt:
--------------------------------------------------------------------------------
1 | Video with image syntax:
2 | .
3 | 
4 | .
5 |
9 | .
10 |
11 | Audio with image syntax:
12 | .
13 | 
14 | .
15 |
19 | .
20 |
21 | Check usual image is not broken:
22 | .
23 | 
24 | .
25 | 
26 | .
27 |
28 | Video with image syntax and title:
29 | .
30 | 
31 | .
32 |
37 | .
38 |
--------------------------------------------------------------------------------
/test/fixtures/image-syntax.txt:
--------------------------------------------------------------------------------
1 | Video with image syntax:
2 | .
3 | 
4 | .
5 |
9 | .
10 |
11 | Audio with image syntax:
12 | .
13 | 
14 | .
15 |
19 | .
20 |
21 | Check usual image is not broken:
22 | .
23 | 
24 | .
25 | 
26 | .
27 |
28 | Video with image syntax and title:
29 | .
30 | 
31 | .
32 |
37 | .
38 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var path = require('path');
4 | var generate = require('markdown-it-testgen');
5 |
6 | function clearBindings() {
7 | // Don't re-use cached function with old bindings
8 | delete require.cache[require.resolve('../lib')];
9 | }
10 |
11 | // For testing custom messages and translations
12 | var customMessages = {
13 | en: {
14 | 'video not supported': 'You cannot play this but you can download it: %s',
15 | 'audio not supported': 'You cannot play this either but you can download it: %s',
16 | 'content description': 'It may contain this: %s'
17 | },
18 | de: {
19 | 'video not supported': 'Du kannst dies nicht abspielen aber herunterladen: %s',
20 | 'audio not supported': 'Du kannst dies auch nicht abspielen aber herunterladen: %s',
21 | 'content description': 'Dies könnte enthalten sein: %s'
22 | }
23 | };
24 |
25 | describe('markdown-it-html5-embed with image syntax', function() {
26 | var option = {
27 | html5embed: {
28 | useImageSyntax: true,
29 | attributes: {
30 | 'audio': 'width="320" controls class="audioplayer"',
31 | 'video': 'width="320" height="240" class="audioplayer" controls'
32 | }
33 | }
34 | };
35 |
36 | var md = require('markdown-it')().use(require('../lib'), option);
37 | generate(path.join(__dirname, 'fixtures/image-syntax.txt'), md);
38 | });
39 |
40 | describe('markdown-it-html5-embed with link syntax', function() {
41 | var option = {
42 | html5embed: {
43 | useLinkSyntax: true
44 | }
45 | };
46 |
47 | var md = require('markdown-it')().use(require('../lib'), option);
48 | generate(path.join(__dirname, 'fixtures/link-syntax.txt'), md);
49 | });
50 |
51 | describe('markdown-it-html5-embed mime type filtering', function() {
52 | var option = {
53 | html5embed: {
54 | useLinkSyntax: true,
55 | isAllowedMimeType: function(mimetype) {
56 | return (mimetype[0] == 'audio/mpeg') || (mimetype[0] == 'video/ogg');
57 | }
58 | }
59 | };
60 |
61 | var md = require('markdown-it')().use(require('../lib'), option);
62 | generate(path.join(__dirname, 'fixtures/mime-filter.txt'), md);
63 | });
64 |
65 | describe('markdown-it-html5-embed with handlebars', function() {
66 | clearBindings();
67 |
68 | var Handlebars = require("handlebars");
69 | global.HandlebarsTemplates = { "template": Handlebars.compile("{{title}}
<{{media_type}} {{attributes}}>{{media_type}}>
") };
70 |
71 | function handleBarsRenderFn(parsed, mediaAttributes) {
72 | var attributes = mediaAttributes[parsed.mediaType];
73 | return HandlebarsTemplates[this]({
74 | media_type: parsed.mediaType,
75 | attributes: attributes,
76 | mimetype: parsed.mimeType,
77 | source_url: parsed.url,
78 | title: parsed.title,
79 | fallback: parsed.fallback,
80 | needs_cover: parsed.mediaType === "video"
81 | });
82 | }
83 |
84 | var option = {
85 | html5embed: {
86 | useLinkSyntax: true,
87 | renderFn: handleBarsRenderFn.bind("template"),
88 | attributes: {
89 | "video": "",
90 | "audio": ""
91 | }
92 | }
93 | };
94 |
95 | var md = require('markdown-it')().use(require('../lib'), option);
96 |
97 | generate(path.join(__dirname, 'fixtures/with-handlebars.txt'), md);
98 |
99 | clearBindings();
100 | });
101 |
102 | describe("embedding with [[html5embed]] clause", function() {
103 | var options = {
104 | html5embed: {
105 | inline: false
106 | }
107 | };
108 |
109 | var md = require('markdown-it')().use(require('../lib'), options);
110 |
111 | generate(path.join(__dirname, 'fixtures/with-placeholder-syntax.txt'), md);
112 | });
113 |
114 | describe("embedding with auto-append", function() {
115 | var options = {
116 | html5embed: {
117 | inline: false,
118 | autoAppend: true
119 | }
120 | };
121 |
122 | var md = require('markdown-it')().use(require('../lib'), options);
123 |
124 | generate(path.join(__dirname, 'fixtures/with-auto-append.txt'), md);
125 | });
126 |
127 | describe('markdown-it-html5-embed with image syntax + custom messages', function() {
128 | var option = {
129 | html5embed: {
130 | useImageSyntax: true,
131 | attributes: {
132 | 'audio': 'width="320" controls class="audioplayer"',
133 | 'video': 'width="320" height="240" class="audioplayer" controls'
134 | },
135 | messages: customMessages
136 | }
137 | };
138 |
139 | clearBindings();
140 |
141 | var md = require('markdown-it')().use(require('../lib'), option);
142 | generate(path.join(__dirname, 'fixtures/image-syntax-custom-messages.txt'), md);
143 | });
144 |
145 | describe('markdown-it-html5-embed with image syntax + custom translation fn', function() {
146 |
147 | // Simply get the upper case version of the translation, if any
148 | var translateFn = function(messageObj) {
149 | return this[messageObj.language][messageObj.messageKey] ?
150 | this[messageObj.language][messageObj.messageKey]
151 | .toUpperCase()
152 | .replace('%S', messageObj.messageParam) :
153 | '';
154 | };
155 |
156 | var option = {
157 | html5embed: {
158 | useImageSyntax: true,
159 | attributes: {
160 | 'audio': 'width="320" controls class="audioplayer"',
161 | 'video': 'width="320" height="240" class="audioplayer" controls'
162 | },
163 | messages: customMessages,
164 | translateFn: translateFn
165 | }
166 | };
167 |
168 | clearBindings();
169 |
170 | var md = require('markdown-it')().use(require('../lib'), option);
171 | var env = { language: 'de' };
172 |
173 | // Pass along env to generated tests
174 | md.origRender = md.render;
175 | md.render = function(input) {
176 | return this.origRender(input, env);
177 | };
178 |
179 | generate(path.join(__dirname, 'fixtures/image-syntax-with-translation.txt'), md);
180 | });
181 |
182 | describe('markdown-it-html5-embed with link syntax http link when http disabled', function() {
183 | clearBindings();
184 |
185 | var options = {
186 | html5embed: {
187 | useLinkSyntax: true
188 | }
189 | };
190 |
191 | var md = require('markdown-it')().use(require('../lib'), options);
192 | generate(path.join(__dirname, 'fixtures/link-syntax-http-disabled.txt'), md);
193 | });
194 |
195 | describe('markdown-it-html5-embed with link syntax http link when http disabled', function() {
196 | var options = {
197 | html5embed: {
198 | useLinkSyntax: true,
199 | isAllowedHttp: true
200 | }
201 | };
202 |
203 | var md = require('markdown-it')().use(require('../lib'), options);
204 | generate(path.join(__dirname, 'fixtures/link-syntax-http-enabled.txt'), md);
205 | });
206 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | /*! markdown-it-html5-embed https://github.com/cmrd-senya/markdown-it-html5-embed @license MPLv2 */
2 | // This is a plugin for markdown-it which adds support for embedding audio/video in the HTML5 way.
3 |
4 | 'use strict';
5 |
6 | var Mimoza = require('mimoza');
7 |
8 | // Default UI messages. You can customize and add simple translations via
9 | // options.messages. The language has to be provided via the markdown-it
10 | // environment, e.g.:
11 | //
12 | // md.render('some text', { language: 'some code' })
13 | //
14 | // It will default to English if not provided. To use your own i18n framework,
15 | // you have to provide a translation function via options.translateFn.
16 | //
17 | // The "untitled video" / "untitled audio" messages are only relevant to usage
18 | // inside alternative render functions, where you can access the title between [] as
19 | // {{title}}, and this text is used if no title is provided.
20 | var messages = {
21 | en: {
22 | 'video not supported': 'Your browser does not support playing HTML5 video. ' +
23 | 'You can download a copy of the video file instead.',
24 | 'audio not supported': 'Your browser does not support playing HTML5 audio. ' +
25 | 'You can download a copy of the audio file instead.',
26 | 'content description': 'Here is a description of the content: %s',
27 | 'untitled video': 'Untitled video',
28 | 'untitled audio': 'Untitled audio'
29 | }
30 | };
31 |
32 | function clearTokens(tokens, idx) {
33 | for (var i = idx; i < tokens.length; i++) {
34 | switch (tokens[i].type) {
35 | case 'link_close':
36 | tokens[i].hidden = true;
37 | break;
38 | case 'text':
39 | tokens[i].content = '';
40 | break;
41 | default:
42 | throw "Unexpected token: " + tokens[i].type;
43 | }
44 | }
45 | }
46 |
47 | function parseToken(tokens, idx, env) {
48 | var parsed = {};
49 | var token = tokens[idx];
50 | var description = '';
51 |
52 | var aIndex = token.attrIndex('src');
53 | parsed.isLink = aIndex < 0;
54 | if (parsed.isLink) {
55 | aIndex = token.attrIndex('href');
56 | description = tokens[idx + 1].content;
57 | } else {
58 | description = token.content;
59 | }
60 |
61 | parsed.url = token.attrs[aIndex][1];
62 | parsed.mimeType = Mimoza.getMimeType(parsed.url);
63 | var RE = /^(audio|video)\/.*/gi;
64 | var mimetype_matches = RE.exec(parsed.mimeType);
65 | if (mimetype_matches === null) {
66 | parsed.mediaType = null;
67 | } else {
68 | parsed.mediaType = mimetype_matches[1];
69 | }
70 |
71 | if (parsed.mediaType !== null) {
72 | // For use as titles in alternative render functions, we store the description
73 | // in parsed.title. For use as fallback text, we store it in parsed.fallback
74 | // alongside the standard fallback text.
75 | parsed.fallback = translate({
76 | messageKey: parsed.mediaType + ' not supported',
77 | messageParam: parsed.url,
78 | language: env.language
79 | });
80 | if (description.trim().length) {
81 | parsed.fallback += '\n' + translate({
82 | messageKey: 'content description',
83 | messageParam: description,
84 | language: env.language
85 | });
86 | parsed.title = description;
87 | } else {
88 | parsed.title = translate({
89 | messageKey: 'untitled ' + parsed.mediaType,
90 | language: env.language
91 | });
92 | }
93 | }
94 | return parsed;
95 | }
96 |
97 | function isAllowedMimeType(parsed, options) {
98 | return parsed.mediaType !== null &&
99 | (!options.isAllowedMimeType || options.isAllowedMimeType([parsed.mimeType, parsed.mediaType]));
100 | }
101 |
102 | function isAllowedSchema(parsed, options) {
103 | if (!options.isAllowedHttp && parsed.url.match('^http://')) {
104 | return false;
105 | }
106 | return true;
107 | }
108 |
109 | function isAllowedToEmbed(parsed, options) {
110 | return isAllowedMimeType(parsed, options) && isAllowedSchema(parsed, options);
111 | }
112 |
113 | function renderMediaEmbed(parsed, mediaAttributes) {
114 | var attributes = mediaAttributes[parsed.mediaType];
115 |
116 | return ['<' + parsed.mediaType + ' ' + attributes + '>',
117 | '',
118 | parsed.fallback,
119 | '' + parsed.mediaType + '>'
120 | ].join('\n');
121 | }
122 |
123 | function html5EmbedRenderer(tokens, idx, options, env, renderer, defaultRender) {
124 | var parsed = parseToken(tokens, idx, env);
125 |
126 | if (!isAllowedToEmbed(parsed, options.html5embed)) {
127 | return defaultRender(tokens, idx, options, env, renderer);
128 | }
129 |
130 | if (parsed.isLink) {
131 | clearTokens(tokens, idx + 1);
132 | }
133 |
134 | return renderMediaEmbed(parsed, options.html5embed.attributes);
135 | }
136 |
137 | function forEachLinkOpen(state, action) {
138 | state.tokens.forEach(function(token, _idx, _tokens) {
139 | if (token.type === "inline") {
140 | token.children.forEach(function(token, idx, tokens) {
141 | if (token.type === "link_open") {
142 | action(tokens, idx);
143 | }
144 | });
145 | }
146 | });
147 | }
148 |
149 | function findDirective(state, startLine, _endLine, silent, regexp, build_token) {
150 | var pos = state.bMarks[startLine] + state.tShift[startLine];
151 | var max = state.eMarks[startLine];
152 |
153 | // Detect directive markdown
154 | var currentLine = state.src.substring(pos, max);
155 | var match = regexp.exec(currentLine);
156 | if (match === null || match.length < 1) {
157 | return false;
158 | }
159 |
160 | if (silent) {
161 | return true;
162 | }
163 |
164 | state.line = startLine + 1;
165 |
166 | // Build content
167 | var token = build_token();
168 | token.map = [startLine, state.line];
169 | token.markup = currentLine;
170 |
171 | return true;
172 | }
173 |
174 | /**
175 | * Very basic translation function. To translate or customize the UI messages,
176 | * set options.messages. To also customize the translation function itself, set
177 | * option.translateFn to a function that handles the same message object format.
178 | *
179 | * @param {Object} messageObj
180 | * the message object
181 | * @param {String} messageObj.messageKey
182 | * an identifier used for looking up the message in i18n files
183 | * @param {String} messageObj.messageParam
184 | * for substitution of %s for filename and description in the respective
185 | * messages
186 | * @param {String} [messageObj.language='en']
187 | * a language code, ignored in the default implementation
188 | * @this {Object}
189 | * the built-in default messages, or options.messages if set
190 | */
191 | function translate(messageObj) {
192 | // Default to English if we don't have this message, or don't support this
193 | // language at all
194 | var language = messageObj.language && this[messageObj.language] &&
195 | this[messageObj.language][messageObj.messageKey] ?
196 | messageObj.language :
197 | 'en';
198 | var rv = this[language][messageObj.messageKey];
199 |
200 | if (messageObj.messageParam) {
201 | rv = rv.replace('%s', messageObj.messageParam);
202 | }
203 | return rv;
204 | }
205 |
206 | module.exports = function html5_embed_plugin(md, options) {
207 | var gstate;
208 | var defaults = {
209 | attributes: {
210 | audio: 'controls preload="metadata"',
211 | video: 'controls preload="metadata"'
212 | },
213 | useImageSyntax: true,
214 | inline: true,
215 | autoAppend: false,
216 | embedPlaceDirectiveRegexp: /^\[\[html5media\]\]/im,
217 | messages: messages
218 | };
219 | var options = md.utils.assign({}, defaults, options.html5embed);
220 |
221 | if (!options.inline) {
222 | md.block.ruler.before("paragraph", "html5embed", function(state, startLine, endLine, silent) {
223 | return findDirective(state, startLine, endLine, silent, options.embedPlaceDirectiveRegexp, function() {
224 | return state.push("html5media", "html5media", 0);
225 | });
226 | });
227 |
228 | md.renderer.rules.html5media = function(tokens, index, _, env) {
229 | var result = "";
230 | forEachLinkOpen(gstate, function(tokens, idx) {
231 | var parsed = parseToken(tokens, idx, env);
232 |
233 | if (!isAllowedToEmbed(parsed, options)) {
234 | return;
235 | }
236 |
237 | result += renderMediaEmbed(parsed, options.attributes);
238 | });
239 | if (result.length) {
240 | result += "\n";
241 | }
242 | return result;
243 | };
244 |
245 | // Catch all the tokens for iteration later
246 | md.core.ruler.push("grab_state", function(state) {
247 | gstate = state;
248 |
249 | if (options.autoAppend) {
250 | var token = new state.Token("html5media", "", 0);
251 | state.tokens.push(token);
252 | }
253 | });
254 | }
255 |
256 | if (typeof options.isAllowedMimeType === "undefined") {
257 | options.isAllowedMimeType = options.is_allowed_mime_type;
258 | }
259 |
260 | if (options.inline && options.useImageSyntax) {
261 | var defaultRender = md.renderer.rules.image;
262 | md.renderer.rules.image = function(tokens, idx, opt, env, self) {
263 | opt.html5embed = options;
264 | return html5EmbedRenderer(tokens, idx, opt, env, self, defaultRender);
265 | }
266 | }
267 |
268 | if (options.inline && options.useLinkSyntax) {
269 | var defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
270 | return self.renderToken(tokens, idx, options);
271 | };
272 | md.renderer.rules.link_open = function(tokens, idx, opt, env, self) {
273 | opt.html5embed = options;
274 | return html5EmbedRenderer(tokens, idx, opt, env, self, defaultRender);
275 | };
276 | }
277 |
278 | // options.messages will be set to built-in messages at the beginning of this
279 | // file if not configured
280 | translate = typeof options.translateFn == 'function' ?
281 | options.translateFn.bind(options.messages) :
282 | translate.bind(options.messages);
283 |
284 | if (typeof options.renderFn == 'function') {
285 | renderMediaEmbed = options.renderFn;
286 | }
287 | };
288 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # markdown-it-html5-embed
2 | This is a plugin for markdown-it which adds support for embedding audio/video in the HTML5 way, by using `