diff --git a/src/core/render/compiler.js b/src/core/render/compiler.js index d01e65aea..85dbf67de 100644 --- a/src/core/render/compiler.js +++ b/src/core/render/compiler.js @@ -30,27 +30,27 @@ const compileMedia = { url, }; }, - iframe(url, title) { + iframe(url, props) { return { html: ``, }; }, - video(url, title) { + video(url, props) { return { - html: ``, + html: ``, }; }, - audio(url, title) { + audio(url, props) { return { - html: ``, + html: ``, }; }, - code(url, title) { + code(url, props) { let lang = url.match(/\.(\w+)$/); - lang = title || (lang && lang[1]); + lang = props || (lang && lang[1]); if (lang === 'md') { lang = 'markdown'; } @@ -142,9 +142,9 @@ export class Compiler { * @return {type} Return value description. */ compileEmbed(href, title) { - const { str, config } = getAndRemoveConfig(title); + const { config } = getAndRemoveConfig(title); let embed; - title = str; + const appenedProps = config.type_appened_props; if (config.include) { if (!isAbsolutePath(href)) { @@ -157,7 +157,7 @@ export class Compiler { let media; if (config.type && (media = compileMedia[config.type])) { - embed = media.call(this, href, title); + embed = media.call(this, href, appenedProps); embed.type = config.type; } else { let type = 'code'; @@ -173,7 +173,7 @@ export class Compiler { type = 'audio'; } - embed = compileMedia[type].call(this, href, title); + embed = compileMedia[type].call(this, href, appenedProps); embed.type = type; } diff --git a/src/core/render/compiler/image.js b/src/core/render/compiler/image.js index 4c8ea9089..463a9e3d0 100644 --- a/src/core/render/compiler/image.js +++ b/src/core/render/compiler/image.js @@ -27,7 +27,11 @@ export const imageCompiler = ({ renderer, contentBase, router }) => } if (config.class) { - attrs.push(`class="${config.class}"`); + let classes = config.class; + if (config.class_appened_props) { + classes = `${config.class} ${config.class_appened_props}`; + } + attrs.push(`class="${classes}"`); } if (config.id) { diff --git a/src/core/render/compiler/link.js b/src/core/render/compiler/link.js index c7a3559eb..10af8a2c3 100644 --- a/src/core/render/compiler/link.js +++ b/src/core/render/compiler/link.js @@ -11,13 +11,12 @@ export const linkCompiler = ({ (renderer.link = function ({ href, title = '', tokens }) { const attrs = []; const text = this.parser.parseInline(tokens) || ''; - const { str, config } = getAndRemoveConfig(title); + const { config } = getAndRemoveConfig(title); linkTarget = config.target || linkTarget; linkRel = linkTarget === '_blank' ? compilerClass.config.externalLinkRel || 'noopener' : ''; - title = str; if ( !isAbsolutePath(href) && @@ -54,15 +53,19 @@ export const linkCompiler = ({ } if (config.class) { - attrs.push(`class="${config.class}"`); + let classes = config.class; + if (config.class_appened_props) { + classes = `${config.class} ${config.class_appened_props}`; + } + attrs.push(`class="${classes}"`); } if (config.id) { attrs.push(`id="${config.id}"`); } - if (title) { - attrs.push(`title="${title}"`); + if (config.ignore_appened_props) { + attrs.push(`title="${config.ignore_appened_props}"`); } return /* html */ `${text}`; diff --git a/src/core/render/utils.js b/src/core/render/utils.js index 20e4d07e4..31fc9809b 100644 --- a/src/core/render/utils.js +++ b/src/core/render/utils.js @@ -6,39 +6,238 @@ * An example of this is ':include :type=code :fragment=demo' is taken and * then converted to: * + * Specially the extra values following a key, such as `:propKey=propVal additinalProp1 additinalProp2` + * or `:propKey propVal additinalProp1 additinalProp2`, those additional values will be appended to the key with `_appened_props` suffix + * as the additional props for the key. + * the example above, its result will be `{ propKey: propVal, propKey_appened_props: 'additinalProp1 additinalProp2' }` + * * ``` + * [](_media/example.html ':include :type=code text :fragment=demo :class=foo bar bee') * { * include: '', * type: 'code', - * fragment: 'demo' + * type_appened_props: 'text', + * fragment: 'demo', + * class: 'foo', + * class_appened_props: 'bar bee' * } * ``` * + * Any invalid config keys will be logged warning to the console intead of swallow them silently. + * * @param {string} str The string to parse. * - * @return {{str: string, config: object}} The original string formatted, and parsed object, { str, config }. + * @return {{str: string, config: object}} The string after parsed the config, and the parsed configs. */ export function getAndRemoveConfig(str = '') { const config = {}; if (str) { - str = str - .replace(/^('|")/, '') - .replace(/('|")$/, '') - .replace(/(?:^|\s):([\w-]+:?)=?([\w-%]+)?/g, (m, key, value) => { - if (key.indexOf(':') === -1) { - config[key] = (value && value.replace(/"/g, '')) || true; - return ''; - } - - return m; - }) - .trim(); + return lexer(str.trim()); } return { str, config }; } +const lexer = function (str) { + const FLAG = ':'; + const EQUIL = '='; + const tokens = str.split(''); + const configs = {}; + let cur = 0; + let startConfigsStringQuote = ''; + let startConfigsStringIndex = -1; + let endConfigsStringIndex = -1; + + const scanner = function (token) { + if (isAtEnd()) { + return; + } + + if (isBlank(token)) { + return; + } + if (token !== FLAG) { + return; + } + + let curToken = ''; + const start = cur - 1; + + // Eat the most close start '/" if it exists. + // The special case is the :id config in heading, which is without quotes wrapped. + if (startConfigsStringIndex === -1) { + const possibleStartQuoteIndex = findPossiableStartQuote(start); + const possibleStartQuote = tokens[possibleStartQuoteIndex]; + + if (possibleStartQuoteIndex !== -1) { + const possibleEndQuoteIndex = findPossiableEndQuote( + start, + possibleStartQuote, + ); + + if (!possibleStartQuote) { + return; + } + + const possibleEndQuote = tokens[possibleEndQuoteIndex]; + if (possibleStartQuote !== possibleEndQuote) { + return; + } + endConfigsStringIndex = possibleEndQuoteIndex; + } + + startConfigsStringIndex = possibleStartQuoteIndex; + startConfigsStringQuote = possibleStartQuote; + } + + while ( + !isBlank(peek()) && + !(peek() === startConfigsStringQuote) && + !(peek() === EQUIL) && + !(peek() === FLAG) + ) { + curToken += advance(); + } + + let match = true; + + switch (curToken) { + // Customise ID for headings #Docsify :id=heading . + case 'id': + configs.id = findValuePair(); + break; + case 'type': + configs.type = findValuePair(); + findAdditionalPropsIfExist('type'); + break; + // Ignore to compile link, e.g. :ignore , :ignore title. + case 'ignore': + configs.ignore = true; + findAdditionalPropsIfExist('ignore'); + break; + // Include + case 'include': + configs.include = true; + break; + // Embedded code fragments e.g. :fragment=demo'. + case 'fragment': + configs.fragment = findValuePair(); + break; + // Disable link :disabled + case 'disabled': + configs.disabled = true; + break; + // Link target config, e.g. target=_blank. + case 'target': + configs.target = findValuePair(); + break; + // Image size config, e.g. size=100, size=WIDTHxHEIGHT. + case 'size': + configs.size = findValuePair(); + break; + case 'class': + configs.class = findValuePair(); + findAdditionalPropsIfExist('class'); + break; + case 'no-zoom': + configs['no-zoom'] = true; + break; + default: + // Although it start with FLAG (:), it is an invalid config token for docsify. + match = false; + } + + if (match) { + for (let i = start; i < cur; i++) { + tokens[i] = ''; + } + } + }; + + const isAtEnd = function () { + return cur >= tokens.length; + }; + + const findValuePair = function () { + if (peek() === EQUIL) { + // Skip the EQUIL + advance(); + let val = ''; + // Find the value until the end of the string or next FLAG + while (!isBlank(peek()) && !peek().match(/['"]/)) { + val += advance(); + } + + return val.trim().replace(/"/g, ''); + } + + return ''; + }; + + const findAdditionalPropsIfExist = function (configKey) { + while (isBlank(peek())) { + advance(); + if (isAtEnd()) { + break; + } + } + + let val = ''; + while (!peek().match(/['"]/) && peek() !== FLAG && !isAtEnd()) { + val += advance(); + } + + val && (configs[configKey + '_appened_props'] = val.trimEnd()); + }; + + const findPossiableStartQuote = function (current) { + for (let i = current - 1; i >= 0; i--) { + if (tokens[i].match(/['"]/)) { + return i; + } + if (!isBlank(tokens[i])) { + return -1; + } + } + return -1; + }; + + const findPossiableEndQuote = function (current, possibleStartQuote) { + for (let i = current + 1; i < tokens.length; i++) { + if (tokens[i] === possibleStartQuote) { + return i; + } + } + return -1; + }; + + const peek = function () { + if (isAtEnd()) { + return ''; + } + return tokens[cur]; + }; + + const advance = function () { + return tokens[cur++]; + }; + + const isBlank = str => { + return !str || /^\s*$/.test(str); + }; + + while (!isAtEnd()) { + scanner(advance()); + } + + for (let i = startConfigsStringIndex; i <= endConfigsStringIndex; i++) { + tokens[i] = ''; + } + + const content = tokens.join('').trim(); + return { str: content, config: configs }; +}; /** * Remove the tag from sidebar when the header with link, details see issue 1069 * @param {string} str The string to deal with. diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 4f624618b..65d3eecaa 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -125,6 +125,16 @@ describe('render', function () { ); }); + test('multi class and loose quotes', async function () { + const output = window.marked( + "![alt text](http://imageUrl ' target=_self :class=someCssClass someCssClassB ')", + ); + + expect(output).toMatchInlineSnapshot( + '"

alt text

"', + ); + }); + test('id', async function () { const output = window.marked( "![alt text](http://imageUrl ':id=someCssID')", @@ -135,6 +145,14 @@ describe('render', function () { ); }); + test('id in heading', async function () { + const output = window.marked('# MyHeader :id=myNewId'); + + expect(output).toMatchInlineSnapshot( + '"

MyHeader

"', + ); + }); + test('no-zoom', async function () { const output = window.marked("![alt text](http://imageUrl ':no-zoom')"); @@ -271,11 +289,11 @@ describe('render', function () { test('class', async function () { const output = window.marked( - "[alt text](http://url ':class=someCssClass')", + "[alt text](http://url ':class=someCssClass someCssClassB')", ); expect(output).toMatchInlineSnapshot( - '"

alt text

"', + '"

alt text

"', ); }); diff --git a/test/unit/render-util.test.js b/test/unit/render-util.test.js index 33ae14ad2..3fae94c8e 100644 --- a/test/unit/render-util.test.js +++ b/test/unit/render-util.test.js @@ -62,80 +62,177 @@ describe('core/render/utils', () => { // getAndRemoveConfig() // --------------------------------------------------------------------------- describe('getAndRemoveConfig()', () => { - test('parse simple config', () => { + test('parse a headling config which is no leading quoto', () => { + const result = getAndRemoveConfig('Test :id=myTitle'); + + expect(result).toMatchObject({ + config: { id: 'myTitle' }, + str: 'Test', + }); + }); + + test('parse simple classes config', () => { + const result = getAndRemoveConfig( + "[filename](_media/example.md ':class=foo bar')", + ); + + expect(result).toMatchObject({ + config: { class: 'foo', class_appened_props: 'bar' }, + str: '[filename](_media/example.md )', + }); + }); + + test('parse simple no config', () => { + const result = getAndRemoveConfig('[filename](_media/example.md )'); + + expect(result).toMatchObject({ + config: {}, + str: '[filename](_media/example.md )', + }); + }); + + test('parse simple config with emoji and no config', () => { const result = getAndRemoveConfig( - "[filename](_media/example.md ':include')", + 'I use the :emoji: but it should be fine with :code=js', ); expect(result).toMatchObject({ config: {}, - str: "[filename](_media/example.md ':include')", + str: 'I use the :emoji: but it should be fine with :code=js', }); }); - test('parse config with arguments', () => { + test('parse config with invalid data attributes but we can swallow them', () => { const result = getAndRemoveConfig( - "[filename](_media/example.md ':include :foo=bar :baz test')", + "[filename](_media/example.md ':include :class=myClz myClz2 myClz3 :invalid=bar :invalid2 test')", ); expect(result).toMatchObject({ config: { - foo: 'bar', - baz: true, + include: true, + class: 'myClz', + class_appened_props: 'myClz2 myClz3', }, - str: "[filename](_media/example.md ':include test')", + str: '[filename](_media/example.md )', }); }); - test('parse config with double quotes', () => { + test('parse config with double quotes configs string', () => { const result = getAndRemoveConfig( - '[filename](_media/example.md ":include")', + '[filename](_media/example.md ":type=code js :include")', ); expect(result).toMatchObject({ - config: {}, - str: '[filename](_media/example.md ":include")', + config: { + include: true, + type: 'code', + type_appened_props: 'js', + }, + str: '[filename](_media/example.md )', + }); + }); + + test('parse config with double quotes and loose leading/ending quotos', () => { + const result = getAndRemoveConfig( + '[filename](_media/example.md " :target=_self :type=code js :include ")', + ); + + expect(result).toMatchObject({ + config: { + include: true, + type: 'code', + type_appened_props: 'js', + target: '_self', + }, + str: '[filename](_media/example.md )', + }); + }); + + test('parse config with some custom appened configs which we could use in further', () => { + const result = getAndRemoveConfig( + '[filename](_media/example.md " :type=code lang=js highlight=false :include ")', + ); + + expect(result).toMatchObject({ + config: { + include: true, + type: 'code', + type_appened_props: 'lang=js highlight=false', + }, + str: '[filename](_media/example.md )', + }); + }); + + test('parse config with naughty complex string', () => { + const result = getAndRemoveConfig( + "It should work :dog: and the ::dog2:: and the ::dog3::dog4:: ' :id=myTitle :type=code js :include'", + ); + + expect(result).toMatchObject({ + config: { + id: 'myTitle', + type: 'code', + type_appened_props: 'js', + include: true, + }, + str: 'It should work :dog: and the ::dog2:: and the ::dog3::dog4::', + }); + }); + + test('parse config with multi config arguments', () => { + const result = getAndRemoveConfig( + "[filename](_media/example.md ':include :class=myClz myClz2 myClz3 :target=_blank')", + ); + + expect(result).toMatchObject({ + config: { + include: true, + class: 'myClz', + class_appened_props: 'myClz2 myClz3', + target: '_blank', + }, + str: '[filename](_media/example.md )', }); }); }); -}); -describe('core/render/tpl', () => { - test('remove html tag in tree', () => { - const result = tree([ - { - level: 2, - slug: '#/cover?id=basic-usage', - title: 'Basic usage', - }, - { - level: 2, - slug: '#/cover?id=custom-background', - title: 'Custom background', - }, - { - level: 2, - slug: '#/cover?id=test', - title: - 'icoTest', - }, - ]); - - expect(result).toBe( - /* html */ '', - ); + describe('core/render/tpl', () => { + test('remove html tag in tree', () => { + const result = tree([ + { + level: 2, + slug: '#/cover?id=basic-usage', + title: 'Basic usage', + }, + { + level: 2, + slug: '#/cover?id=custom-background', + title: 'Custom background', + }, + { + level: 2, + slug: '#/cover?id=test', + title: + 'icoTest', + }, + ]); + + expect(result).toBe( + /* html */ '', + ); + }); }); -}); -describe('core/render/slugify', () => { - test('slugify()', () => { - const result = slugify( - 'Bla bla bla ', - ); - const result2 = slugify( - 'Another broken example', - ); - expect(result).toBe('bla-bla-bla-'); - expect(result2).toBe('another-broken-example'); + describe('core/render/slugify', () => { + test('slugify()', () => { + const result = slugify( + 'Bla bla bla ', + ); + const result2 = slugify( + 'Another broken example', + ); + expect(result).toBe('bla-bla-bla-'); + expect(result2).toBe('another-broken-example'); + }); }); });