Merge pull request #1664 from UziTech/fix-highlight-async

This commit is contained in:
Tony Brix 2020-05-14 10:52:53 -05:00 committed by GitHub
commit 5ef872b427
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 321 additions and 39 deletions

View File

@ -58,6 +58,7 @@ console.log(marked(markdownString));
|smartLists |`boolean` |`false` |v0.2.8 |If true, use smarter list behavior than those found in `markdown.pl`.| |smartLists |`boolean` |`false` |v0.2.8 |If true, use smarter list behavior than those found in `markdown.pl`.|
|smartypants |`boolean` |`false` |v0.2.9 |If true, use "smart" typographic punctuation for things like quotes and dashes.| |smartypants |`boolean` |`false` |v0.2.9 |If true, use "smart" typographic punctuation for things like quotes and dashes.|
|tokenizer |`object` |`new Tokenizer()`|v1.0.0|An object containing functions to create tokens from markdown. See [extensibility](/#/USING_PRO.md) for more details.| |tokenizer |`object` |`new Tokenizer()`|v1.0.0|An object containing functions to create tokens from markdown. See [extensibility](/#/USING_PRO.md) for more details.|
|walkTokens |`function` |`null`|v1.1.0|A function which is called for every token. See [extensibility](/#/USING_PRO.md) for more details.|
|xhtml |`boolean` |`false` |v0.3.2 |If true, emit self-closing HTML tags for void elements (<br/>, <img/>, etc.) with a "/" as required by XHTML.| |xhtml |`boolean` |`false` |v0.3.2 |If true, emit self-closing HTML tags for void elements (<br/>, <img/>, etc.) with a "/" as required by XHTML.|
<h2 id="highlight">Asynchronous highlighting</h2> <h2 id="highlight">Asynchronous highlighting</h2>

View File

@ -10,6 +10,8 @@ The `renderer` and `tokenizer` options can be an object with functions that will
The `renderer` and `tokenizer` functions can return false to fallback to the previous function. The `renderer` and `tokenizer` functions can return false to fallback to the previous function.
The `walkTokens` option can be a function that will be called with every token before rendering. When calling `use` multiple times with different `walkTokens` functions each function will be called in the **reverse** order in which they were assigned.
All other options will overwrite previously set options. All other options will overwrite previously set options.
<h2 id="renderer">The renderer</h2> <h2 id="renderer">The renderer</h2>
@ -188,6 +190,35 @@ smartypants('"this ... string"')
// "“this … string”" // "“this … string”"
``` ```
<h2 id="walk-tokens">Walk Tokens</h2>
The walkTokens function gets called with every token. Child tokens are called before moving on to sibling tokens. Each token is passed by reference so updates are persisted when passed to the parser. The return value of the function is ignored.
**Example:** Overriding heading tokens to start at h2.
```js
const marked = require('marked');
// Override function
const walkTokens = (token) => {
if (token.type === 'heading') {
token.depth += 1;
}
};
marked.use({ walkTokens });
// Run marked
console.log(marked('# heading 2\n\n## heading 3'));
```
**Output:**
```html
<h2 id="heading-2">heading 2</h2>
<h3 id="heading-3">heading 3</h3>
```
<h2 id="lexer">The lexer</h2> <h2 id="lexer">The lexer</h2>
The lexer takes a markdown string and calls the tokenizer functions. The lexer takes a markdown string and calls the tokenizer functions.

View File

@ -157,6 +157,7 @@
<li><a href="#/USING_PRO.md#use">marked.use()</a></li> <li><a href="#/USING_PRO.md#use">marked.use()</a></li>
<li><a href="#/USING_PRO.md#renderer">Renderer</a></li> <li><a href="#/USING_PRO.md#renderer">Renderer</a></li>
<li><a href="#/USING_PRO.md#tokenizer">Tokenizer</a></li> <li><a href="#/USING_PRO.md#tokenizer">Tokenizer</a></li>
<li><a href="#/USING_PRO.md#walk-tokens">Walk Tokens</a></li>
<li><a href="#/USING_PRO.md#lexer">Lexer</a></li> <li><a href="#/USING_PRO.md#lexer">Lexer</a></li>
<li><a href="#/USING_PRO.md#parser">Parser</a></li> <li><a href="#/USING_PRO.md#parser">Parser</a></li>
</ul> </ul>

View File

@ -269,6 +269,7 @@ module.exports = class Tokenizer {
} }
list.items.push({ list.items.push({
type: 'list_item',
raw, raw,
task: istask, task: istask,
checked: ischecked, checked: ischecked,

View File

@ -16,6 +16,7 @@ function getDefaults() {
smartLists: false, smartLists: false,
smartypants: false, smartypants: false,
tokenizer: null, tokenizer: null,
walkTokens: null,
xhtml: false xhtml: false
}; };
} }

View File

@ -28,18 +28,17 @@ function marked(src, opt, callback) {
+ Object.prototype.toString.call(src) + ', string expected'); + Object.prototype.toString.call(src) + ', string expected');
} }
if (callback || typeof opt === 'function') { if (typeof opt === 'function') {
if (!callback) { callback = opt;
callback = opt; opt = null;
opt = null; }
}
opt = merge({}, marked.defaults, opt || {}); opt = merge({}, marked.defaults, opt || {});
checkSanitizeDeprecation(opt); checkSanitizeDeprecation(opt);
if (callback) {
const highlight = opt.highlight; const highlight = opt.highlight;
let tokens, let tokens;
pending,
i = 0;
try { try {
tokens = Lexer.lex(src, opt); tokens = Lexer.lex(src, opt);
@ -47,20 +46,15 @@ function marked(src, opt, callback) {
return callback(e); return callback(e);
} }
pending = tokens.length;
const done = function(err) { const done = function(err) {
if (err) {
opt.highlight = highlight;
return callback(err);
}
let out; let out;
try { if (!err) {
out = Parser.parse(tokens, opt); try {
} catch (e) { out = Parser.parse(tokens, opt);
err = e; } catch (e) {
err = e;
}
} }
opt.highlight = highlight; opt.highlight = highlight;
@ -76,34 +70,45 @@ function marked(src, opt, callback) {
delete opt.highlight; delete opt.highlight;
if (!pending) return done(); if (!tokens.length) return done();
for (; i < tokens.length; i++) { let pending = 0;
(function(token) { marked.walkTokens(tokens, function(token) {
if (token.type !== 'code') { if (token.type === 'code') {
return --pending || done(); pending++;
} highlight(token.text, token.lang, function(err, code) {
return highlight(token.text, token.lang, function(err, code) { if (err) {
if (err) return done(err); return done(err);
if (code == null || code === token.text) { }
return --pending || done(); if (code != null && code !== token.text) {
token.text = code;
token.escaped = true;
}
pending--;
if (pending === 0) {
done();
} }
token.text = code;
token.escaped = true;
--pending || done();
}); });
})(tokens[i]); }
});
if (pending === 0) {
done();
} }
return; return;
} }
try { try {
opt = merge({}, marked.defaults, opt || {}); const tokens = Lexer.lex(src, opt);
checkSanitizeDeprecation(opt); if (opt.walkTokens) {
return Parser.parse(Lexer.lex(src, opt), opt); marked.walkTokens(tokens, opt.walkTokens);
}
return Parser.parse(tokens, opt);
} catch (e) { } catch (e) {
e.message += '\nPlease report this to https://github.com/markedjs/marked.'; e.message += '\nPlease report this to https://github.com/markedjs/marked.';
if ((opt || marked.defaults).silent) { if (opt.silent) {
return '<p>An error occurred:</p><pre>' return '<p>An error occurred:</p><pre>'
+ escape(e.message + '', true) + escape(e.message + '', true)
+ '</pre>'; + '</pre>';
@ -161,9 +166,50 @@ marked.use = function(extension) {
} }
opts.tokenizer = tokenizer; opts.tokenizer = tokenizer;
} }
if (extension.walkTokens) {
const walkTokens = marked.defaults.walkTokens;
opts.walkTokens = (token) => {
extension.walkTokens(token);
if (walkTokens) {
walkTokens(token);
}
};
}
marked.setOptions(opts); marked.setOptions(opts);
}; };
/**
* Run callback for every token
*/
marked.walkTokens = function(tokens, callback) {
for (const token of tokens) {
callback(token);
switch (token.type) {
case 'table': {
for (const cell of token.tokens.header) {
marked.walkTokens(cell, callback);
}
for (const row of token.tokens.cells) {
for (const cell of row) {
marked.walkTokens(cell, callback);
}
}
break;
}
case 'list': {
marked.walkTokens(token.items, callback);
break;
}
default: {
if (token.tokens) {
marked.walkTokens(token.tokens, callback);
}
}
}
}
};
/** /**
* Expose * Expose
*/ */

View File

@ -307,6 +307,7 @@ a | b
loose: false, loose: false,
items: [ items: [
{ {
type: 'list_item',
raw: '- item 1', raw: '- item 1',
task: false, task: false,
checked: undefined, checked: undefined,
@ -320,6 +321,7 @@ a | b
}] }]
}, },
{ {
type: 'list_item',
raw: '- item 2\n', raw: '- item 2\n',
task: false, task: false,
checked: undefined, checked: undefined,

View File

@ -132,6 +132,18 @@ describe('use extension', () => {
expect(html).toBe('<p>extension</p>\n'); expect(html).toBe('<p>extension</p>\n');
}); });
it('should use walkTokens', () => {
let walked = 0;
const extension = {
walkTokens(token) {
walked++;
}
};
marked.use(extension);
marked('text');
expect(walked).toBe(2);
});
it('should use options from extension', () => { it('should use options from extension', () => {
const extension = { const extension = {
headerIds: false headerIds: false
@ -141,6 +153,29 @@ describe('use extension', () => {
expect(html).toBe('<h1>heading</h1>\n'); expect(html).toBe('<h1>heading</h1>\n');
}); });
it('should call all walkTokens in reverse order', () => {
let walkedOnce = 0;
let walkedTwice = 0;
const extension1 = {
walkTokens(token) {
if (token.walkedOnce) {
walkedTwice++;
}
}
};
const extension2 = {
walkTokens(token) {
walkedOnce++;
token.walkedOnce = true;
}
};
marked.use(extension1);
marked.use(extension2);
marked('text');
expect(walkedOnce).toBe(2);
expect(walkedTwice).toBe(2);
});
it('should use last extension function and not override others', () => { it('should use last extension function and not override others', () => {
const extension1 = { const extension1 = {
renderer: { renderer: {
@ -229,3 +264,167 @@ paragraph
expect(html).toBe('arrow no options\nfunction options\nshorthand options\n'); expect(html).toBe('arrow no options\nfunction options\nshorthand options\n');
}); });
}); });
describe('async highlight', () => {
let highlight, markdown;
beforeEach(() => {
highlight = jasmine.createSpy('highlight', (text, lang, callback) => {
setImmediate(() => {
callback(null, `async ${text || ''}`);
});
});
markdown = `
\`\`\`lang1
text 1
\`\`\`
> \`\`\`lang2
> text 2
> \`\`\`
- \`\`\`lang3
text 3
\`\`\`
`;
});
it('should highlight codeblocks async', (done) => {
highlight.and.callThrough();
marked(markdown, { highlight }, (err, html) => {
if (err) {
fail(err);
}
expect(html).toBe(`<pre><code class="language-lang1">async text 1</code></pre>
<blockquote>
<pre><code class="language-lang2">async text 2</code></pre>
</blockquote>
<ul>
<li><pre><code class="language-lang3">async text 3</code></pre>
</li>
</ul>
`);
done();
});
});
it('should call callback for each error in highlight', (done) => {
highlight.and.callFake((lang, text, callback) => {
callback(new Error('highlight error'));
});
let numErrors = 0;
marked(markdown, { highlight }, (err, html) => {
expect(err).toBeTruthy();
expect(html).toBeUndefined();
if (err) {
numErrors++;
}
if (numErrors === 3) {
done();
}
});
});
});
describe('walkTokens', () => {
it('should walk over every token', () => {
const markdown = `
paragraph
---
# heading
\`\`\`
code
\`\`\`
| a | b |
|---|---|
| 1 | 2 |
| 3 | 4 |
> blockquote
- list
<div>html</div>
[link](https://example.com)
![image](https://example.com/image.jpg)
**strong**
*em*
\`codespan\`
~~del~~
br
br
`;
const tokens = marked.lexer(markdown, { ...marked.getDefaults(), breaks: true });
const tokensSeen = [];
marked.walkTokens(tokens, (token) => {
tokensSeen.push([token.type, (token.raw || '').replace(/\n/g, '')]);
});
expect(tokensSeen).toEqual([
['paragraph', 'paragraph'],
['text', 'paragraph'],
['space', ''],
['hr', '---'],
['heading', '# heading'],
['text', 'heading'],
['code', '```code```'],
['table', '| a | b ||---|---|| 1 | 2 || 3 | 4 |'],
['text', 'a'],
['text', 'b'],
['text', '1'],
['text', '2'],
['text', '3'],
['text', '4'],
['blockquote', '> blockquote'],
['paragraph', 'blockquote'],
['text', 'blockquote'],
['list', '- list'],
['list_item', '- list'],
['text', 'list'],
['text', 'list'],
['space', ''],
['html', '<div>html</div>'],
['paragraph', '[link](https://example.com)'],
['link', '[link](https://example.com)'],
['text', 'link'],
['space', ''],
['paragraph', '![image](https://example.com/image.jpg)'],
['image', '![image](https://example.com/image.jpg)'],
['space', ''],
['paragraph', '**strong**'],
['strong', '**strong**'],
['text', 'strong'],
['space', ''],
['paragraph', '*em*'],
['em', '*em*'],
['text', 'em'],
['space', ''],
['paragraph', '`codespan`'],
['codespan', '`codespan`'],
['space', ''],
['paragraph', '~~del~~'],
['del', '~~del~~'],
['text', 'del'],
['space', ''],
['paragraph', 'brbr'],
['text', 'br'],
['br', ''],
['text', 'br']
]);
});
});