From 353e13b479d012bcda338dff276598b6cfff77f8 Mon Sep 17 00:00:00 2001 From: Tony Brix Date: Fri, 9 Jun 2023 22:10:12 -0500 Subject: [PATCH] feat: add Marked instance (#2831) Co-authored-by: Steven --- docs/INDEX.md | 4 +- docs/USING_ADVANCED.md | 16 ++ docs/USING_PRO.md | 7 +- src/Instance.js | 374 +++++++++++++++++++++++++++++++++++++ src/marked.js | 351 ++-------------------------------- test/helpers/helpers.js | 9 +- test/specs/run-spec.js | 2 +- test/unit/Hooks-spec.js | 117 ++++++++++++ test/unit/Slugger-spec.js | 74 ++++++++ test/unit/instance-spec.js | 75 ++++++++ test/unit/marked-spec.js | 201 +------------------- test/unit/utils.js | 5 + 12 files changed, 690 insertions(+), 545 deletions(-) create mode 100644 src/Instance.js create mode 100644 test/unit/Hooks-spec.js create mode 100644 test/unit/Slugger-spec.js create mode 100644 test/unit/instance-spec.js create mode 100644 test/unit/utils.js diff --git a/docs/INDEX.md b/docs/INDEX.md index 4ff003adb0..50ad12cb76 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -37,7 +37,7 @@ DOMPurify.sanitize(marked.parse(`` **⚠️ Input: special ZERO WIDTH unicode characters (for example `\uFEFF`) might interfere with parsing. Some text editors add them at the start of the file (see: [#2139](https://github.com/markedjs/marked/issues/2139)).** ```js -// remove the most common zerowidth characters from the start of the file +// remove the most common zerowidth characters from the start of the file marked.parse( contents.replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/,"") ) @@ -121,7 +121,7 @@ By supporting the above Markdown flavors, it's possible that Marked can help you

List of Tools Using Marked

-We actively support the usability of Marked in super-fast markdown transformation, some of Tools using `Marked` for single-page creations are +We actively support the usability of Marked in super-fast markdown transformation, some of Tools using `Marked` for single-page creations are | Tools | Description | | :----------------------------------------------------------------- | :------------------------------------------------------------------------ | diff --git a/docs/USING_ADVANCED.md b/docs/USING_ADVANCED.md index 969c0b972d..40a8149e2a 100644 --- a/docs/USING_ADVANCED.md +++ b/docs/USING_ADVANCED.md @@ -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 ```js @@ -162,6 +177,7 @@ markedWorker.onmessage = (e) => { markedWorker.postMessage(markdownString); ``` +

CLI Extensions

You can use extensions in the CLI by creating a new CLI that imports marked and the marked binary. diff --git a/docs/USING_PRO.md b/docs/USING_PRO.md index 63eca5048c..0666811027 100644 --- a/docs/USING_PRO.md +++ b/docs/USING_PRO.md @@ -22,7 +22,7 @@ marked.use({ You can also supply multiple `extension` objects at once. -``` +```js marked.use(myExtension, extension2, extension3); \\ EQUIVALENT TO: @@ -30,12 +30,11 @@ marked.use(myExtension, extension2, extension3); marked.use(myExtension); marked.use(extension2); 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. diff --git a/src/Instance.js b/src/Instance.js new file mode 100644 index 0000000000..2d5c25750e --- /dev/null +++ b/src/Instance.js @@ -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 = '

An error occurred:

'
+          + escape(e.message + '', true)
+          + '
'; + 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; + }; + } +} diff --git a/src/marked.js b/src/marked.js index b721d66547..4b990b3603 100644 --- a/src/marked.js +++ b/src/marked.js @@ -5,179 +5,16 @@ import { Renderer } from './Renderer.js'; import { TextRenderer } from './TextRenderer.js'; import { Slugger } from './Slugger.js'; import { Hooks } from './Hooks.js'; -import { - checkDeprecations, - escape -} from './helpers.js'; -import { - getDefaults, - changeDefaults, - defaults -} from './defaults.js'; +import { Marked } from './Instance.js'; +import { changeDefaults, getDefaults, defaults } from './defaults.js'; -function onError(silent, async, callback) { - return (e) => { - e.message += '\nPlease report this to https://github.com/markedjs/marked.'; - - if (silent) { - const msg = '

An error occurred:

'
-        + escape(e.message + '', true)
-        + '
'; - 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); - } - }; -} +const markedInstance = new Marked(defaults); /** * Marked */ 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.setOptions = function(opt) { - marked.defaults = { ...marked.defaults, ...opt }; + markedInstance.setOptions(opt); + marked.defaults = markedInstance.defaults; changeDefaults(marked.defaults); return marked; }; @@ -200,144 +38,10 @@ marked.defaults = defaults; */ marked.use = function(...args) { - const extensions = marked.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 = 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); - }); + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; }; /** @@ -345,44 +49,14 @@ marked.use = function(...args) { */ marked.walkTokens = function(tokens, callback) { - let values = []; - 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; + return markedInstance.walkTokens(tokens, callback); }; /** * Parse Inline * @param {string} src */ -marked.parseInline = parseMarkdown(Lexer.lexInline, Parser.parseInline); +marked.parseInline = markedInstance.parseInline; /** * Expose @@ -414,3 +88,4 @@ export { Renderer } from './Renderer.js'; export { TextRenderer } from './TextRenderer.js'; export { Slugger } from './Slugger.js'; export { Hooks } from './Hooks.js'; +export { Marked } from './Instance.js'; diff --git a/test/helpers/helpers.js b/test/helpers/helpers.js index c5d7a55512..4cd98bc578 100644 --- a/test/helpers/helpers.js +++ b/test/helpers/helpers.js @@ -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 { strictEqual } from 'assert'; beforeEach(() => { setOptions(getDefaults()); + setOptions({ silent: true }); jasmine.addAsyncMatchers({ toRender: () => { return { compare: async(spec, expected) => { + const marked = new Marked(); const result = {}; - const actual = marked(spec.markdown, spec.options); + const actual = marked.parse(spec.markdown, spec.options); result.pass = await isEqual(expected, actual); if (result.pass) { @@ -41,8 +43,9 @@ beforeEach(() => { }, toRenderExact: () => ({ compare: async(spec, expected) => { + const marked = new Marked(); const result = {}; - const actual = marked(spec.markdown, spec.options); + const actual = marked.parse(spec.markdown, spec.options); result.pass = strictEqual(expected, actual) === undefined; diff --git a/test/specs/run-spec.js b/test/specs/run-spec.js index a2696b2745..8560ed2ee7 100644 --- a/test/specs/run-spec.js +++ b/test/specs/run-spec.js @@ -55,4 +55,4 @@ runSpecs('CommonMark', './commonmark', true, { gfm: false, pedantic: false, head runSpecs('Original', './original', false, { gfm: false, pedantic: true }); runSpecs('New', './new'); runSpecs('ReDOS', './redos'); -runSpecs('Security', './security', false, { silent: true }); // silent - do not show deprecation warning +runSpecs('Security', './security'); diff --git a/test/unit/Hooks-spec.js b/test/unit/Hooks-spec.js new file mode 100644 index 0000000000..0fee5861c3 --- /dev/null +++ b/test/unit/Hooks-spec.js @@ -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('

preprocess

\n

text

'); + }); + + 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('

preprocess async

\n

text

'); + }); + + it('should preprocess options', () => { + marked.use({ + hooks: { + preprocess(markdown) { + this.options.headerIds = false; + return markdown; + } + } + }); + const html = marked('# test'); + expect(html.trim()).toBe('

test

'); + }); + + 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('

test

'); + }); + + it('should postprocess html', () => { + marked.use({ + hooks: { + postprocess(html) { + return html + '

postprocess

'; + } + } + }); + const html = marked('*text*'); + expect(html.trim()).toBe('

text

\n

postprocess

'); + }); + + it('should postprocess async', async() => { + marked.use({ + async: true, + hooks: { + async postprocess(html) { + await timeout(); + return html + '

postprocess async

\n'; + } + } + }); + const promise = marked('*text*'); + expect(promise).toBeInstanceOf(Promise); + const html = await promise; + expect(html.trim()).toBe('

text

\n

postprocess async

'); + }); + + it('should process all hooks in reverse', async() => { + marked.use({ + hooks: { + preprocess(markdown) { + return `# preprocess1\n\n${markdown}`; + }, + postprocess(html) { + return html + '

postprocess1

\n'; + } + } + }); + marked.use({ + async: true, + hooks: { + preprocess(markdown) { + return `# preprocess2\n\n${markdown}`; + }, + async postprocess(html) { + await timeout(); + return html + '

postprocess2 async

\n'; + } + } + }); + const promise = marked('*text*'); + expect(promise).toBeInstanceOf(Promise); + const html = await promise; + expect(html.trim()).toBe('

preprocess1

\n

preprocess2

\n

text

\n

postprocess2 async

\n

postprocess1

'); + }); +}); diff --git a/test/unit/Slugger-spec.js b/test/unit/Slugger-spec.js new file mode 100644 index 0000000000..1e68bf2cf0 --- /dev/null +++ b/test/unit/Slugger-spec.js @@ -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('html')).toBe('html'); + }); + + it('should not increment seen when using dryrun option', () => { + const slugger = new Slugger(); + expect(slugger.slug('

This Section

', { dryrun: true })).toBe('this-section'); + expect(slugger.slug('

This Section

')).toBe('this-section'); + }); + + it('should still return the next unique id when using dryrun', () => { + const slugger = new Slugger(); + expect(slugger.slug('

This Section

')).toBe('this-section'); + expect(slugger.slug('

This Section

', { 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'); + }); +}); diff --git a/test/unit/instance-spec.js b/test/unit/instance-spec.js new file mode 100644 index 0000000000..4aab129d49 --- /dev/null +++ b/test/unit/instance-spec.js @@ -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('

header

\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('

header

\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('

header

\n'); + }); +}); diff --git a/test/unit/marked-spec.js b/test/unit/marked-spec.js index fb02954f42..b68ae61c34 100644 --- a/test/unit/marked-spec.js +++ b/test/unit/marked-spec.js @@ -1,14 +1,9 @@ import { marked, Renderer, Slugger, lexer, parseInline, use, getDefaults, walkTokens as _walkTokens, defaults, setOptions } from '../../src/marked.js'; - -async function timeout(ms = 1) { - return new Promise(resolve => { - setTimeout(resolve, ms); - }); -} +import { timeout } from './utils.js'; describe('Test heading ID functionality', () => { - it('should add id attribute by default', () => { - const renderer = new Renderer(); + it('should add id attribute', () => { + const renderer = new Renderer({ ...defaults, headerIds: true }); const slugger = new Slugger(); const header = renderer.heading('test', 1, 'test', slugger); expect(header).toBe('

test

\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('html')).toBe('html'); - }); - - it('should not increment seen when using dryrun option', () => { - const slugger = new Slugger(); - expect(slugger.slug('

This Section

', { dryrun: true })).toBe('this-section'); - expect(slugger.slug('

This Section

')).toBe('this-section'); - }); - - it('should still return the next unique id when using dryrun', () => { - const slugger = new Slugger(); - expect(slugger.slug('

This Section

')).toBe('this-section'); - expect(slugger.slug('

This Section

', { 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', () => { it('should use the "paragraph" type on top level', () => { const md = 'A Paragraph.\n\n> A blockquote\n\n- list item\n'; @@ -707,7 +629,7 @@ used extension2 walked

const html = marked('This is a *paragraph* with blue text. {blue}\n' + '# This is a *header* with red text {red}'); expect(html).toBe('

This is a paragraph with blue text.

\n' - + '

This is a header with red text

\n'); + + '

This is a header with red text

\n'); }); it('should use renderer', () => { @@ -1128,118 +1050,3 @@ br expect(html.trim()).toBe('

text

'); }); }); - -describe('Hooks', () => { - it('should preprocess markdown', () => { - marked.use({ - hooks: { - preprocess(markdown) { - return `# preprocess\n\n${markdown}`; - } - } - }); - const html = marked('*text*'); - expect(html.trim()).toBe('

preprocess

\n

text

'); - }); - - 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('

preprocess async

\n

text

'); - }); - - it('should preprocess options', () => { - marked.use({ - hooks: { - preprocess(markdown) { - this.options.headerIds = false; - return markdown; - } - } - }); - const html = marked('# test'); - expect(html.trim()).toBe('

test

'); - }); - - 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('

test

'); - }); - - it('should postprocess html', () => { - marked.use({ - hooks: { - postprocess(html) { - return html + '

postprocess

'; - } - } - }); - const html = marked('*text*'); - expect(html.trim()).toBe('

text

\n

postprocess

'); - }); - - it('should postprocess async', async() => { - marked.use({ - async: true, - hooks: { - async postprocess(html) { - await timeout(); - return html + '

postprocess async

\n'; - } - } - }); - const promise = marked('*text*'); - expect(promise).toBeInstanceOf(Promise); - const html = await promise; - expect(html.trim()).toBe('

text

\n

postprocess async

'); - }); - - it('should process all hooks in reverse', async() => { - marked.use({ - hooks: { - preprocess(markdown) { - return `# preprocess1\n\n${markdown}`; - }, - postprocess(html) { - return html + '

postprocess1

\n'; - } - } - }); - marked.use({ - async: true, - hooks: { - preprocess(markdown) { - return `# preprocess2\n\n${markdown}`; - }, - async postprocess(html) { - await timeout(); - return html + '

postprocess2 async

\n'; - } - } - }); - const promise = marked('*text*'); - expect(promise).toBeInstanceOf(Promise); - const html = await promise; - expect(html.trim()).toBe('

preprocess1

\n

preprocess2

\n

text

\n

postprocess2 async

\n

postprocess1

'); - }); -}); diff --git a/test/unit/utils.js b/test/unit/utils.js new file mode 100644 index 0000000000..e4a5f46b77 --- /dev/null +++ b/test/unit/utils.js @@ -0,0 +1,5 @@ +export async function timeout(ms = 1) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +}