feat: add Marked instance (#2831)

Co-authored-by: Steven <steven@ceriously.com>
This commit is contained in:
Tony Brix 2023-06-09 22:10:12 -05:00 committed by GitHub
parent f19fe76db9
commit 353e13b479
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 690 additions and 545 deletions

View File

@ -1,3 +1,18 @@
## Marked instance
By default, Marked stores options and extensions in the global scope. That means changing the options in one script will also change the options in another script since they share the same instance.
If you don't want to mutate global scope, you can create a new instance of Marked to ensure options and extensions are locally scoped.
```js
import { Marked } from 'marked';
const marked = new Marked([options, extension, ...]);
```
|Argument |Type |Notes |
|:--------|:-------|:----------------------------------------------------------------------|
| options |`object`|The same arguments that can be passed to [`marked.use`](/using_pro#use)|
## The `parse` function ## The `parse` function
```js ```js
@ -162,6 +177,7 @@ markedWorker.onmessage = (e) => {
markedWorker.postMessage(markdownString); markedWorker.postMessage(markdownString);
``` ```
<h2 id="cli-extensions">CLI Extensions</h2> <h2 id="cli-extensions">CLI Extensions</h2>
You can use extensions in the CLI by creating a new CLI that imports marked and the marked binary. You can use extensions in the CLI by creating a new CLI that imports marked and the marked binary.

View File

@ -22,7 +22,7 @@ marked.use({
You can also supply multiple `extension` objects at once. You can also supply multiple `extension` objects at once.
``` ```js
marked.use(myExtension, extension2, extension3); marked.use(myExtension, extension2, extension3);
\\ EQUIVALENT TO: \\ EQUIVALENT TO:
@ -30,12 +30,11 @@ marked.use(myExtension, extension2, extension3);
marked.use(myExtension); marked.use(myExtension);
marked.use(extension2); marked.use(extension2);
marked.use(extension3); marked.use(extension3);
``` ```
All options will overwrite those previously set, except for the following options which will be merged with the existing framework and can be used to change or extend the functionality of Marked: `renderer`, `tokenizer`, `walkTokens`, and `extensions`. All options will overwrite those previously set, except for the following options which will be merged with the existing framework and can be used to change or extend the functionality of Marked: `renderer`, `tokenizer`, `hooks`, `walkTokens`, and `extensions`.
* The `renderer` and `tokenizer` options are objects with functions that will be merged into the built-in `renderer` and `tokenizer` respectively. * The `renderer`, `tokenizer`, and `hooks` options are objects with functions that will be merged into the built-in `renderer` and `tokenizer` respectively.
* The `walkTokens` option is a function that will be called to post-process every token before rendering. * The `walkTokens` option is a function that will be called to post-process every token before rendering.

374
src/Instance.js Normal file
View File

@ -0,0 +1,374 @@
import { getDefaults } from './defaults.js';
import { Lexer } from './Lexer.js';
import { Parser } from './Parser.js';
import { Hooks } from './Hooks.js';
import { Renderer } from './Renderer.js';
import { Tokenizer } from './Tokenizer.js';
import { TextRenderer } from './TextRenderer.js';
import { Slugger } from './Slugger.js';
import {
checkDeprecations,
escape
} from './helpers.js';
export class Marked {
defaults = getDefaults();
options = this.setOptions;
parse = this.#parseMarkdown(Lexer.lex, Parser.parse);
parseInline = this.#parseMarkdown(Lexer.lexInline, Parser.parseInline);
Parser = Parser;
parser = Parser.parse;
Renderer = Renderer;
TextRenderer = TextRenderer;
Lexer = Lexer;
lexer = Lexer.lex;
Tokenizer = Tokenizer;
Slugger = Slugger;
Hooks = Hooks;
constructor(...args) {
this.use(...args);
}
walkTokens(tokens, callback) {
let values = [];
for (const token of tokens) {
values = values.concat(callback.call(this, token));
switch (token.type) {
case 'table': {
for (const cell of token.header) {
values = values.concat(this.walkTokens(cell.tokens, callback));
}
for (const row of token.rows) {
for (const cell of row) {
values = values.concat(this.walkTokens(cell.tokens, callback));
}
}
break;
}
case 'list': {
values = values.concat(this.walkTokens(token.items, callback));
break;
}
default: {
if (this.defaults.extensions && this.defaults.extensions.childTokens && this.defaults.extensions.childTokens[token.type]) { // Walk any extensions
this.defaults.extensions.childTokens[token.type].forEach((childTokens) => {
values = values.concat(this.walkTokens(token[childTokens], callback));
});
} else if (token.tokens) {
values = values.concat(this.walkTokens(token.tokens, callback));
}
}
}
}
return values;
}
use(...args) {
const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} };
args.forEach((pack) => {
// copy options to new object
const opts = { ...pack };
// set async to true if it was set to true before
opts.async = this.defaults.async || opts.async || false;
// ==-- Parse "addon" extensions --== //
if (pack.extensions) {
pack.extensions.forEach((ext) => {
if (!ext.name) {
throw new Error('extension name required');
}
if (ext.renderer) { // Renderer extensions
const prevRenderer = extensions.renderers[ext.name];
if (prevRenderer) {
// Replace extension with func to run new extension but fall back if false
extensions.renderers[ext.name] = function(...args) {
let ret = ext.renderer.apply(this, args);
if (ret === false) {
ret = prevRenderer.apply(this, args);
}
return ret;
};
} else {
extensions.renderers[ext.name] = ext.renderer;
}
}
if (ext.tokenizer) { // Tokenizer Extensions
if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) {
throw new Error("extension level must be 'block' or 'inline'");
}
if (extensions[ext.level]) {
extensions[ext.level].unshift(ext.tokenizer);
} else {
extensions[ext.level] = [ext.tokenizer];
}
if (ext.start) { // Function to check for start of token
if (ext.level === 'block') {
if (extensions.startBlock) {
extensions.startBlock.push(ext.start);
} else {
extensions.startBlock = [ext.start];
}
} else if (ext.level === 'inline') {
if (extensions.startInline) {
extensions.startInline.push(ext.start);
} else {
extensions.startInline = [ext.start];
}
}
}
}
if (ext.childTokens) { // Child tokens to be visited by walkTokens
extensions.childTokens[ext.name] = ext.childTokens;
}
});
opts.extensions = extensions;
}
// ==-- Parse "overwrite" extensions --== //
if (pack.renderer) {
const renderer = this.defaults.renderer || new Renderer(this.defaults);
for (const prop in pack.renderer) {
const prevRenderer = renderer[prop];
// Replace renderer with func to run extension, but fall back if false
renderer[prop] = (...args) => {
let ret = pack.renderer[prop].apply(renderer, args);
if (ret === false) {
ret = prevRenderer.apply(renderer, args);
}
return ret;
};
}
opts.renderer = renderer;
}
if (pack.tokenizer) {
const tokenizer = this.defaults.tokenizer || new Tokenizer(this.defaults);
for (const prop in pack.tokenizer) {
const prevTokenizer = tokenizer[prop];
// Replace tokenizer with func to run extension, but fall back if false
tokenizer[prop] = (...args) => {
let ret = pack.tokenizer[prop].apply(tokenizer, args);
if (ret === false) {
ret = prevTokenizer.apply(tokenizer, args);
}
return ret;
};
}
opts.tokenizer = tokenizer;
}
// ==-- Parse Hooks extensions --== //
if (pack.hooks) {
const hooks = this.defaults.hooks || new Hooks();
for (const prop in pack.hooks) {
const prevHook = hooks[prop];
if (Hooks.passThroughHooks.has(prop)) {
hooks[prop] = (arg) => {
if (this.defaults.async) {
return Promise.resolve(pack.hooks[prop].call(hooks, arg)).then(ret => {
return prevHook.call(hooks, ret);
});
}
const ret = pack.hooks[prop].call(hooks, arg);
return prevHook.call(hooks, ret);
};
} else {
hooks[prop] = (...args) => {
let ret = pack.hooks[prop].apply(hooks, args);
if (ret === false) {
ret = prevHook.apply(hooks, args);
}
return ret;
};
}
}
opts.hooks = hooks;
}
// ==-- Parse WalkTokens extensions --== //
if (pack.walkTokens) {
const walkTokens = this.defaults.walkTokens;
opts.walkTokens = function(token) {
let values = [];
values.push(pack.walkTokens.call(this, token));
if (walkTokens) {
values = values.concat(walkTokens.call(this, token));
}
return values;
};
}
this.defaults = { ...this.defaults, ...opts };
});
return this;
}
setOptions(opt) {
this.defaults = { ...this.defaults, ...opt };
return this;
}
#parseMarkdown(lexer, parser) {
return (src, opt, callback) => {
if (typeof opt === 'function') {
callback = opt;
opt = null;
}
const origOpt = { ...opt };
opt = { ...this.defaults, ...origOpt };
const throwError = this.#onError(opt.silent, opt.async, callback);
// throw error in case of non string input
if (typeof src === 'undefined' || src === null) {
return throwError(new Error('marked(): input parameter is undefined or null'));
}
if (typeof src !== 'string') {
return throwError(new Error('marked(): input parameter is of type '
+ Object.prototype.toString.call(src) + ', string expected'));
}
checkDeprecations(opt, callback);
if (opt.hooks) {
opt.hooks.options = opt;
}
if (callback) {
const highlight = opt.highlight;
let tokens;
try {
if (opt.hooks) {
src = opt.hooks.preprocess(src);
}
tokens = lexer(src, opt);
} catch (e) {
return throwError(e);
}
const done = (err) => {
let out;
if (!err) {
try {
if (opt.walkTokens) {
this.walkTokens(tokens, opt.walkTokens);
}
out = parser(tokens, opt);
if (opt.hooks) {
out = opt.hooks.postprocess(out);
}
} catch (e) {
err = e;
}
}
opt.highlight = highlight;
return err
? throwError(err)
: callback(null, out);
};
if (!highlight || highlight.length < 3) {
return done();
}
delete opt.highlight;
if (!tokens.length) return done();
let pending = 0;
this.walkTokens(tokens, (token) => {
if (token.type === 'code') {
pending++;
setTimeout(() => {
highlight(token.text, token.lang, (err, code) => {
if (err) {
return done(err);
}
if (code != null && code !== token.text) {
token.text = code;
token.escaped = true;
}
pending--;
if (pending === 0) {
done();
}
});
}, 0);
}
});
if (pending === 0) {
done();
}
return;
}
if (opt.async) {
return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src)
.then(src => lexer(src, opt))
.then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens)
.then(tokens => parser(tokens, opt))
.then(html => opt.hooks ? opt.hooks.postprocess(html) : html)
.catch(throwError);
}
try {
if (opt.hooks) {
src = opt.hooks.preprocess(src);
}
const tokens = lexer(src, opt);
if (opt.walkTokens) {
this.walkTokens(tokens, opt.walkTokens);
}
let html = parser(tokens, opt);
if (opt.hooks) {
html = opt.hooks.postprocess(html);
}
return html;
} catch (e) {
return throwError(e);
}
};
}
#onError(silent, async, callback) {
return (e) => {
e.message += '\nPlease report this to https://github.com/markedjs/this.';
if (silent) {
const msg = '<p>An error occurred:</p><pre>'
+ escape(e.message + '', true)
+ '</pre>';
if (async) {
return Promise.resolve(msg);
}
if (callback) {
callback(null, msg);
return;
}
return msg;
}
if (async) {
return Promise.reject(e);
}
if (callback) {
callback(e);
return;
}
throw e;
};
}
}

View File

@ -5,179 +5,16 @@ import { Renderer } from './Renderer.js';
import { TextRenderer } from './TextRenderer.js'; import { TextRenderer } from './TextRenderer.js';
import { Slugger } from './Slugger.js'; import { Slugger } from './Slugger.js';
import { Hooks } from './Hooks.js'; import { Hooks } from './Hooks.js';
import { import { Marked } from './Instance.js';
checkDeprecations, import { changeDefaults, getDefaults, defaults } from './defaults.js';
escape
} from './helpers.js';
import {
getDefaults,
changeDefaults,
defaults
} from './defaults.js';
function onError(silent, async, callback) { const markedInstance = new Marked(defaults);
return (e) => {
e.message += '\nPlease report this to https://github.com/markedjs/marked.';
if (silent) {
const msg = '<p>An error occurred:</p><pre>'
+ escape(e.message + '', true)
+ '</pre>';
if (async) {
return Promise.resolve(msg);
}
if (callback) {
callback(null, msg);
return;
}
return msg;
}
if (async) {
return Promise.reject(e);
}
if (callback) {
callback(e);
return;
}
throw e;
};
}
function parseMarkdown(lexer, parser) {
return (src, opt, callback) => {
if (typeof opt === 'function') {
callback = opt;
opt = null;
}
const origOpt = { ...opt };
opt = { ...marked.defaults, ...origOpt };
const throwError = onError(opt.silent, opt.async, callback);
// throw error in case of non string input
if (typeof src === 'undefined' || src === null) {
return throwError(new Error('marked(): input parameter is undefined or null'));
}
if (typeof src !== 'string') {
return throwError(new Error('marked(): input parameter is of type '
+ Object.prototype.toString.call(src) + ', string expected'));
}
checkDeprecations(opt, callback);
if (opt.hooks) {
opt.hooks.options = opt;
}
if (callback) {
const highlight = opt.highlight;
let tokens;
try {
if (opt.hooks) {
src = opt.hooks.preprocess(src);
}
tokens = lexer(src, opt);
} catch (e) {
return throwError(e);
}
const done = function(err) {
let out;
if (!err) {
try {
if (opt.walkTokens) {
marked.walkTokens(tokens, opt.walkTokens);
}
out = parser(tokens, opt);
if (opt.hooks) {
out = opt.hooks.postprocess(out);
}
} catch (e) {
err = e;
}
}
opt.highlight = highlight;
return err
? throwError(err)
: callback(null, out);
};
if (!highlight || highlight.length < 3) {
return done();
}
delete opt.highlight;
if (!tokens.length) return done();
let pending = 0;
marked.walkTokens(tokens, function(token) {
if (token.type === 'code') {
pending++;
setTimeout(() => {
highlight(token.text, token.lang, function(err, code) {
if (err) {
return done(err);
}
if (code != null && code !== token.text) {
token.text = code;
token.escaped = true;
}
pending--;
if (pending === 0) {
done();
}
});
}, 0);
}
});
if (pending === 0) {
done();
}
return;
}
if (opt.async) {
return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src)
.then(src => lexer(src, opt))
.then(tokens => opt.walkTokens ? Promise.all(marked.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens)
.then(tokens => parser(tokens, opt))
.then(html => opt.hooks ? opt.hooks.postprocess(html) : html)
.catch(throwError);
}
try {
if (opt.hooks) {
src = opt.hooks.preprocess(src);
}
const tokens = lexer(src, opt);
if (opt.walkTokens) {
marked.walkTokens(tokens, opt.walkTokens);
}
let html = parser(tokens, opt);
if (opt.hooks) {
html = opt.hooks.postprocess(html);
}
return html;
} catch (e) {
return throwError(e);
}
};
}
/** /**
* Marked * Marked
*/ */
export function marked(src, opt, callback) { export function marked(src, opt, callback) {
return parseMarkdown(Lexer.lex, Parser.parse)(src, opt, callback); return markedInstance.parse(src, opt, callback);
} }
/** /**
@ -186,7 +23,8 @@ export function marked(src, opt, callback) {
marked.options = marked.options =
marked.setOptions = function(opt) { marked.setOptions = function(opt) {
marked.defaults = { ...marked.defaults, ...opt }; markedInstance.setOptions(opt);
marked.defaults = markedInstance.defaults;
changeDefaults(marked.defaults); changeDefaults(marked.defaults);
return marked; return marked;
}; };
@ -200,144 +38,10 @@ marked.defaults = defaults;
*/ */
marked.use = function(...args) { marked.use = function(...args) {
const extensions = marked.defaults.extensions || { renderers: {}, childTokens: {} }; markedInstance.use(...args);
marked.defaults = markedInstance.defaults;
args.forEach((pack) => { changeDefaults(marked.defaults);
// copy options to new object return marked;
const opts = { ...pack };
// set async to true if it was set to true before
opts.async = marked.defaults.async || opts.async || false;
// ==-- Parse "addon" extensions --== //
if (pack.extensions) {
pack.extensions.forEach((ext) => {
if (!ext.name) {
throw new Error('extension name required');
}
if (ext.renderer) { // Renderer extensions
const prevRenderer = extensions.renderers[ext.name];
if (prevRenderer) {
// Replace extension with func to run new extension but fall back if false
extensions.renderers[ext.name] = function(...args) {
let ret = ext.renderer.apply(this, args);
if (ret === false) {
ret = prevRenderer.apply(this, args);
}
return ret;
};
} else {
extensions.renderers[ext.name] = ext.renderer;
}
}
if (ext.tokenizer) { // Tokenizer Extensions
if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) {
throw new Error("extension level must be 'block' or 'inline'");
}
if (extensions[ext.level]) {
extensions[ext.level].unshift(ext.tokenizer);
} else {
extensions[ext.level] = [ext.tokenizer];
}
if (ext.start) { // Function to check for start of token
if (ext.level === 'block') {
if (extensions.startBlock) {
extensions.startBlock.push(ext.start);
} else {
extensions.startBlock = [ext.start];
}
} else if (ext.level === 'inline') {
if (extensions.startInline) {
extensions.startInline.push(ext.start);
} else {
extensions.startInline = [ext.start];
}
}
}
}
if (ext.childTokens) { // Child tokens to be visited by walkTokens
extensions.childTokens[ext.name] = ext.childTokens;
}
});
opts.extensions = extensions;
}
// ==-- Parse "overwrite" extensions --== //
if (pack.renderer) {
const renderer = marked.defaults.renderer || new Renderer();
for (const prop in pack.renderer) {
const prevRenderer = renderer[prop];
// Replace renderer with func to run extension, but fall back if false
renderer[prop] = (...args) => {
let ret = pack.renderer[prop].apply(renderer, args);
if (ret === false) {
ret = prevRenderer.apply(renderer, args);
}
return ret;
};
}
opts.renderer = renderer;
}
if (pack.tokenizer) {
const tokenizer = marked.defaults.tokenizer || new Tokenizer();
for (const prop in pack.tokenizer) {
const prevTokenizer = tokenizer[prop];
// Replace tokenizer with func to run extension, but fall back if false
tokenizer[prop] = (...args) => {
let ret = pack.tokenizer[prop].apply(tokenizer, args);
if (ret === false) {
ret = prevTokenizer.apply(tokenizer, args);
}
return ret;
};
}
opts.tokenizer = tokenizer;
}
// ==-- Parse Hooks extensions --== //
if (pack.hooks) {
const hooks = marked.defaults.hooks || new Hooks();
for (const prop in pack.hooks) {
const prevHook = hooks[prop];
if (Hooks.passThroughHooks.has(prop)) {
hooks[prop] = (arg) => {
if (marked.defaults.async) {
return Promise.resolve(pack.hooks[prop].call(hooks, arg)).then(ret => {
return prevHook.call(hooks, ret);
});
}
const ret = pack.hooks[prop].call(hooks, arg);
return prevHook.call(hooks, ret);
};
} else {
hooks[prop] = (...args) => {
let ret = pack.hooks[prop].apply(hooks, args);
if (ret === false) {
ret = prevHook.apply(hooks, args);
}
return ret;
};
}
}
opts.hooks = hooks;
}
// ==-- Parse WalkTokens extensions --== //
if (pack.walkTokens) {
const walkTokens = marked.defaults.walkTokens;
opts.walkTokens = function(token) {
let values = [];
values.push(pack.walkTokens.call(this, token));
if (walkTokens) {
values = values.concat(walkTokens.call(this, token));
}
return values;
};
}
marked.setOptions(opts);
});
}; };
/** /**
@ -345,44 +49,14 @@ marked.use = function(...args) {
*/ */
marked.walkTokens = function(tokens, callback) { marked.walkTokens = function(tokens, callback) {
let values = []; return markedInstance.walkTokens(tokens, callback);
for (const token of tokens) {
values = values.concat(callback.call(marked, token));
switch (token.type) {
case 'table': {
for (const cell of token.header) {
values = values.concat(marked.walkTokens(cell.tokens, callback));
}
for (const row of token.rows) {
for (const cell of row) {
values = values.concat(marked.walkTokens(cell.tokens, callback));
}
}
break;
}
case 'list': {
values = values.concat(marked.walkTokens(token.items, callback));
break;
}
default: {
if (marked.defaults.extensions && marked.defaults.extensions.childTokens && marked.defaults.extensions.childTokens[token.type]) { // Walk any extensions
marked.defaults.extensions.childTokens[token.type].forEach(function(childTokens) {
values = values.concat(marked.walkTokens(token[childTokens], callback));
});
} else if (token.tokens) {
values = values.concat(marked.walkTokens(token.tokens, callback));
}
}
}
}
return values;
}; };
/** /**
* Parse Inline * Parse Inline
* @param {string} src * @param {string} src
*/ */
marked.parseInline = parseMarkdown(Lexer.lexInline, Parser.parseInline); marked.parseInline = markedInstance.parseInline;
/** /**
* Expose * Expose
@ -414,3 +88,4 @@ export { Renderer } from './Renderer.js';
export { TextRenderer } from './TextRenderer.js'; export { TextRenderer } from './TextRenderer.js';
export { Slugger } from './Slugger.js'; export { Slugger } from './Slugger.js';
export { Hooks } from './Hooks.js'; export { Hooks } from './Hooks.js';
export { Marked } from './Instance.js';

View File

@ -1,16 +1,18 @@
import { marked, setOptions, getDefaults } from '../../src/marked.js'; import { Marked, setOptions, getDefaults } from '../../src/marked.js';
import { isEqual, firstDiff } from './html-differ.js'; import { isEqual, firstDiff } from './html-differ.js';
import { strictEqual } from 'assert'; import { strictEqual } from 'assert';
beforeEach(() => { beforeEach(() => {
setOptions(getDefaults()); setOptions(getDefaults());
setOptions({ silent: true });
jasmine.addAsyncMatchers({ jasmine.addAsyncMatchers({
toRender: () => { toRender: () => {
return { return {
compare: async(spec, expected) => { compare: async(spec, expected) => {
const marked = new Marked();
const result = {}; const result = {};
const actual = marked(spec.markdown, spec.options); const actual = marked.parse(spec.markdown, spec.options);
result.pass = await isEqual(expected, actual); result.pass = await isEqual(expected, actual);
if (result.pass) { if (result.pass) {
@ -41,8 +43,9 @@ beforeEach(() => {
}, },
toRenderExact: () => ({ toRenderExact: () => ({
compare: async(spec, expected) => { compare: async(spec, expected) => {
const marked = new Marked();
const result = {}; const result = {};
const actual = marked(spec.markdown, spec.options); const actual = marked.parse(spec.markdown, spec.options);
result.pass = strictEqual(expected, actual) === undefined; result.pass = strictEqual(expected, actual) === undefined;

View File

@ -55,4 +55,4 @@ runSpecs('CommonMark', './commonmark', true, { gfm: false, pedantic: false, head
runSpecs('Original', './original', false, { gfm: false, pedantic: true }); runSpecs('Original', './original', false, { gfm: false, pedantic: true });
runSpecs('New', './new'); runSpecs('New', './new');
runSpecs('ReDOS', './redos'); runSpecs('ReDOS', './redos');
runSpecs('Security', './security', false, { silent: true }); // silent - do not show deprecation warning runSpecs('Security', './security');

117
test/unit/Hooks-spec.js Normal file
View File

@ -0,0 +1,117 @@
import { marked } from '../../src/marked.js';
import { timeout } from './utils.js';
describe('Hooks', () => {
it('should preprocess markdown', () => {
marked.use({
hooks: {
preprocess(markdown) {
return `# preprocess\n\n${markdown}`;
}
}
});
const html = marked('*text*');
expect(html.trim()).toBe('<h1 id="preprocess">preprocess</h1>\n<p><em>text</em></p>');
});
it('should preprocess async', async() => {
marked.use({
async: true,
hooks: {
async preprocess(markdown) {
await timeout();
return `# preprocess async\n\n${markdown}`;
}
}
});
const promise = marked('*text*');
expect(promise).toBeInstanceOf(Promise);
const html = await promise;
expect(html.trim()).toBe('<h1 id="preprocess-async">preprocess async</h1>\n<p><em>text</em></p>');
});
it('should preprocess options', () => {
marked.use({
hooks: {
preprocess(markdown) {
this.options.headerIds = false;
return markdown;
}
}
});
const html = marked('# test');
expect(html.trim()).toBe('<h1>test</h1>');
});
it('should preprocess options async', async() => {
marked.use({
async: true,
hooks: {
async preprocess(markdown) {
await timeout();
this.options.headerIds = false;
return markdown;
}
}
});
const html = await marked('# test');
expect(html.trim()).toBe('<h1>test</h1>');
});
it('should postprocess html', () => {
marked.use({
hooks: {
postprocess(html) {
return html + '<h1>postprocess</h1>';
}
}
});
const html = marked('*text*');
expect(html.trim()).toBe('<p><em>text</em></p>\n<h1>postprocess</h1>');
});
it('should postprocess async', async() => {
marked.use({
async: true,
hooks: {
async postprocess(html) {
await timeout();
return html + '<h1>postprocess async</h1>\n';
}
}
});
const promise = marked('*text*');
expect(promise).toBeInstanceOf(Promise);
const html = await promise;
expect(html.trim()).toBe('<p><em>text</em></p>\n<h1>postprocess async</h1>');
});
it('should process all hooks in reverse', async() => {
marked.use({
hooks: {
preprocess(markdown) {
return `# preprocess1\n\n${markdown}`;
},
postprocess(html) {
return html + '<h1>postprocess1</h1>\n';
}
}
});
marked.use({
async: true,
hooks: {
preprocess(markdown) {
return `# preprocess2\n\n${markdown}`;
},
async postprocess(html) {
await timeout();
return html + '<h1>postprocess2 async</h1>\n';
}
}
});
const promise = marked('*text*');
expect(promise).toBeInstanceOf(Promise);
const html = await promise;
expect(html.trim()).toBe('<h1 id="preprocess1">preprocess1</h1>\n<h1 id="preprocess2">preprocess2</h1>\n<p><em>text</em></p>\n<h1>postprocess2 async</h1>\n<h1>postprocess1</h1>');
});
});

74
test/unit/Slugger-spec.js Normal file
View File

@ -0,0 +1,74 @@
import { Slugger } from '../../src/Slugger.js';
describe('Test slugger functionality', () => {
it('should use lowercase slug', () => {
const slugger = new Slugger();
expect(slugger.slug('Test')).toBe('test');
});
it('should be unique to avoid collisions 1280', () => {
const slugger = new Slugger();
expect(slugger.slug('test')).toBe('test');
expect(slugger.slug('test')).toBe('test-1');
expect(slugger.slug('test')).toBe('test-2');
});
it('should be unique when slug ends with number', () => {
const slugger = new Slugger();
expect(slugger.slug('test 1')).toBe('test-1');
expect(slugger.slug('test')).toBe('test');
expect(slugger.slug('test')).toBe('test-2');
});
it('should be unique when slug ends with hyphen number', () => {
const slugger = new Slugger();
expect(slugger.slug('foo')).toBe('foo');
expect(slugger.slug('foo')).toBe('foo-1');
expect(slugger.slug('foo 1')).toBe('foo-1-1');
expect(slugger.slug('foo-1')).toBe('foo-1-2');
expect(slugger.slug('foo')).toBe('foo-2');
});
it('should allow non-latin chars', () => {
const slugger = new Slugger();
expect(slugger.slug('привет')).toBe('привет');
});
it('should remove ampersands 857', () => {
const slugger = new Slugger();
expect(slugger.slug('This & That Section')).toBe('this--that-section');
});
it('should remove periods', () => {
const slugger = new Slugger();
expect(slugger.slug('file.txt')).toBe('filetxt');
});
it('should remove html tags', () => {
const slugger = new Slugger();
expect(slugger.slug('<em>html</em>')).toBe('html');
});
it('should not increment seen when using dryrun option', () => {
const slugger = new Slugger();
expect(slugger.slug('<h1>This Section</h1>', { dryrun: true })).toBe('this-section');
expect(slugger.slug('<h1>This Section</h1>')).toBe('this-section');
});
it('should still return the next unique id when using dryrun', () => {
const slugger = new Slugger();
expect(slugger.slug('<h1>This Section</h1>')).toBe('this-section');
expect(slugger.slug('<h1>This Section</h1>', { dryrun: true })).toBe('this-section-1');
});
it('should be repeatable in a sequence', () => {
const slugger = new Slugger();
expect(slugger.slug('foo')).toBe('foo');
expect(slugger.slug('foo')).toBe('foo-1');
expect(slugger.slug('foo')).toBe('foo-2');
expect(slugger.slug('foo', { dryrun: true })).toBe('foo-3');
expect(slugger.slug('foo', { dryrun: true })).toBe('foo-3');
expect(slugger.slug('foo')).toBe('foo-3');
expect(slugger.slug('foo')).toBe('foo-4');
});
});

View File

@ -0,0 +1,75 @@
import { marked, Marked, Renderer } from '../../src/marked.js';
describe('Marked', () => {
it('should allow multiple instances', () => {
const marked1 = new Marked({
silent: true,
renderer: {
heading() {
return 'im marked1';
}
}
});
const marked2 = new Marked({
silent: true,
renderer: {
heading() {
return 'im marked2';
}
}
});
expect(marked1.parse('# header')).toBe('im marked1');
expect(marked2.parse('# header')).toBe('im marked2');
expect(marked.parse('# header')).toBe('<h1 id="header">header</h1>\n');
});
it('should work with use', () => {
const marked1 = new Marked();
marked1.use({
silent: true,
renderer: {
heading() {
return 'im marked1';
}
}
});
const marked2 = new Marked();
marked2.use({
silent: true,
renderer: {
heading() {
return 'im marked2';
}
}
});
expect(marked1.parse('# header')).toBe('im marked1');
expect(marked2.parse('# header')).toBe('im marked2');
expect(marked.parse('# header')).toBe('<h1 id="header">header</h1>\n');
});
it('should work with setOptions', () => {
const marked1 = new Marked();
const marked1Renderer = new Renderer();
marked1Renderer.heading = () => 'im marked1';
marked1.setOptions({
silent: true,
renderer: marked1Renderer
});
const marked2 = new Marked();
const marked2Renderer = new Renderer();
marked2Renderer.heading = () => 'im marked2';
marked2.setOptions({
silent: true,
renderer: marked2Renderer
});
expect(marked1.parse('# header')).toBe('im marked1');
expect(marked2.parse('# header')).toBe('im marked2');
expect(marked.parse('# header')).toBe('<h1 id="header">header</h1>\n');
});
});

View File

@ -1,14 +1,9 @@
import { marked, Renderer, Slugger, lexer, parseInline, use, getDefaults, walkTokens as _walkTokens, defaults, setOptions } from '../../src/marked.js'; import { marked, Renderer, Slugger, lexer, parseInline, use, getDefaults, walkTokens as _walkTokens, defaults, setOptions } from '../../src/marked.js';
import { timeout } from './utils.js';
async function timeout(ms = 1) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
describe('Test heading ID functionality', () => { describe('Test heading ID functionality', () => {
it('should add id attribute by default', () => { it('should add id attribute', () => {
const renderer = new Renderer(); const renderer = new Renderer({ ...defaults, headerIds: true });
const slugger = new Slugger(); const slugger = new Slugger();
const header = renderer.heading('test', 1, 'test', slugger); const header = renderer.heading('test', 1, 'test', slugger);
expect(header).toBe('<h1 id="test">test</h1>\n'); expect(header).toBe('<h1 id="test">test</h1>\n');
@ -21,79 +16,6 @@ describe('Test heading ID functionality', () => {
}); });
}); });
describe('Test slugger functionality', () => {
it('should use lowercase slug', () => {
const slugger = new Slugger();
expect(slugger.slug('Test')).toBe('test');
});
it('should be unique to avoid collisions 1280', () => {
const slugger = new Slugger();
expect(slugger.slug('test')).toBe('test');
expect(slugger.slug('test')).toBe('test-1');
expect(slugger.slug('test')).toBe('test-2');
});
it('should be unique when slug ends with number', () => {
const slugger = new Slugger();
expect(slugger.slug('test 1')).toBe('test-1');
expect(slugger.slug('test')).toBe('test');
expect(slugger.slug('test')).toBe('test-2');
});
it('should be unique when slug ends with hyphen number', () => {
const slugger = new Slugger();
expect(slugger.slug('foo')).toBe('foo');
expect(slugger.slug('foo')).toBe('foo-1');
expect(slugger.slug('foo 1')).toBe('foo-1-1');
expect(slugger.slug('foo-1')).toBe('foo-1-2');
expect(slugger.slug('foo')).toBe('foo-2');
});
it('should allow non-latin chars', () => {
const slugger = new Slugger();
expect(slugger.slug('привет')).toBe('привет');
});
it('should remove ampersands 857', () => {
const slugger = new Slugger();
expect(slugger.slug('This & That Section')).toBe('this--that-section');
});
it('should remove periods', () => {
const slugger = new Slugger();
expect(slugger.slug('file.txt')).toBe('filetxt');
});
it('should remove html tags', () => {
const slugger = new Slugger();
expect(slugger.slug('<em>html</em>')).toBe('html');
});
it('should not increment seen when using dryrun option', () => {
const slugger = new Slugger();
expect(slugger.slug('<h1>This Section</h1>', { dryrun: true })).toBe('this-section');
expect(slugger.slug('<h1>This Section</h1>')).toBe('this-section');
});
it('should still return the next unique id when using dryrun', () => {
const slugger = new Slugger();
expect(slugger.slug('<h1>This Section</h1>')).toBe('this-section');
expect(slugger.slug('<h1>This Section</h1>', { dryrun: true })).toBe('this-section-1');
});
it('should be repeatable in a sequence', () => {
const slugger = new Slugger();
expect(slugger.slug('foo')).toBe('foo');
expect(slugger.slug('foo')).toBe('foo-1');
expect(slugger.slug('foo')).toBe('foo-2');
expect(slugger.slug('foo', { dryrun: true })).toBe('foo-3');
expect(slugger.slug('foo', { dryrun: true })).toBe('foo-3');
expect(slugger.slug('foo')).toBe('foo-3');
expect(slugger.slug('foo')).toBe('foo-4');
});
});
describe('Test paragraph token type', () => { describe('Test paragraph token type', () => {
it('should use the "paragraph" type on top level', () => { it('should use the "paragraph" type on top level', () => {
const md = 'A Paragraph.\n\n> A blockquote\n\n- list item\n'; const md = 'A Paragraph.\n\n> A blockquote\n\n- list item\n';
@ -707,7 +629,7 @@ used extension2 walked</p>
const html = marked('This is a *paragraph* with blue text. {blue}\n' const html = marked('This is a *paragraph* with blue text. {blue}\n'
+ '# This is a *header* with red text {red}'); + '# This is a *header* with red text {red}');
expect(html).toBe('<p style="color:blue;">This is a <em>paragraph</em> with blue text.</p>\n' expect(html).toBe('<p style="color:blue;">This is a <em>paragraph</em> with blue text.</p>\n'
+ '<h1 style="color:red;">This is a <em>header</em> with red text</h1>\n'); + '<h1 style="color:red;">This is a <em>header</em> with red text</h1>\n');
}); });
it('should use renderer', () => { it('should use renderer', () => {
@ -1128,118 +1050,3 @@ br
expect(html.trim()).toBe('<p><em>text</em></p>'); expect(html.trim()).toBe('<p><em>text</em></p>');
}); });
}); });
describe('Hooks', () => {
it('should preprocess markdown', () => {
marked.use({
hooks: {
preprocess(markdown) {
return `# preprocess\n\n${markdown}`;
}
}
});
const html = marked('*text*');
expect(html.trim()).toBe('<h1 id="preprocess">preprocess</h1>\n<p><em>text</em></p>');
});
it('should preprocess async', async() => {
marked.use({
async: true,
hooks: {
async preprocess(markdown) {
await timeout();
return `# preprocess async\n\n${markdown}`;
}
}
});
const promise = marked('*text*');
expect(promise).toBeInstanceOf(Promise);
const html = await promise;
expect(html.trim()).toBe('<h1 id="preprocess-async">preprocess async</h1>\n<p><em>text</em></p>');
});
it('should preprocess options', () => {
marked.use({
hooks: {
preprocess(markdown) {
this.options.headerIds = false;
return markdown;
}
}
});
const html = marked('# test');
expect(html.trim()).toBe('<h1>test</h1>');
});
it('should preprocess options async', async() => {
marked.use({
async: true,
hooks: {
async preprocess(markdown) {
await timeout();
this.options.headerIds = false;
return markdown;
}
}
});
const html = await marked('# test');
expect(html.trim()).toBe('<h1>test</h1>');
});
it('should postprocess html', () => {
marked.use({
hooks: {
postprocess(html) {
return html + '<h1>postprocess</h1>';
}
}
});
const html = marked('*text*');
expect(html.trim()).toBe('<p><em>text</em></p>\n<h1>postprocess</h1>');
});
it('should postprocess async', async() => {
marked.use({
async: true,
hooks: {
async postprocess(html) {
await timeout();
return html + '<h1>postprocess async</h1>\n';
}
}
});
const promise = marked('*text*');
expect(promise).toBeInstanceOf(Promise);
const html = await promise;
expect(html.trim()).toBe('<p><em>text</em></p>\n<h1>postprocess async</h1>');
});
it('should process all hooks in reverse', async() => {
marked.use({
hooks: {
preprocess(markdown) {
return `# preprocess1\n\n${markdown}`;
},
postprocess(html) {
return html + '<h1>postprocess1</h1>\n';
}
}
});
marked.use({
async: true,
hooks: {
preprocess(markdown) {
return `# preprocess2\n\n${markdown}`;
},
async postprocess(html) {
await timeout();
return html + '<h1>postprocess2 async</h1>\n';
}
}
});
const promise = marked('*text*');
expect(promise).toBeInstanceOf(Promise);
const html = await promise;
expect(html.trim()).toBe('<h1 id="preprocess1">preprocess1</h1>\n<h1 id="preprocess2">preprocess2</h1>\n<p><em>text</em></p>\n<h1>postprocess2 async</h1>\n<h1>postprocess1</h1>');
});
});

5
test/unit/utils.js Normal file
View File

@ -0,0 +1,5 @@
export async function timeout(ms = 1) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}