From 674e88b089510e4214f86384553f9dc88778040e Mon Sep 17 00:00:00 2001 From: koy Date: Mon, 22 Jul 2024 22:44:13 +0800 Subject: [PATCH 1/7] update: rewrite getAndRemove config Reactor getAndRemoveConfig function to a generic lexer instead of a complex regex. Correctly the behavior and only resolve valid configs. Warning invalid configs also. --- src/core/render/compiler.js | 24 ++-- src/core/render/compiler/image.js | 6 +- src/core/render/compiler/link.js | 13 ++- src/core/render/utils.js | 184 ++++++++++++++++++++++++++++-- test/unit/render-util.test.js | 136 ++++++++++++++-------- 5 files changed, 288 insertions(+), 75 deletions(-) diff --git a/src/core/render/compiler.js b/src/core/render/compiler.js index 3d4388e61..7717810af 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'; } @@ -143,9 +143,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)) { @@ -158,7 +158,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'; @@ -174,7 +174,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 89003537f..d09ba7401 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.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 4c1c26e20..5181fcfd9 100644 --- a/src/core/render/compiler/link.js +++ b/src/core/render/compiler/link.js @@ -10,13 +10,12 @@ export const linkCompiler = ({ }) => (renderer.link = (href, title = '', text) => { const attrs = []; - 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) && @@ -53,15 +52,19 @@ export const linkCompiler = ({ } if (config.class) { - attrs.push(`class="${config.class}"`); + let classes = config.class; + if (config.class_appened_props) { + classes = `${config.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..de21f8dd5 100644 --- a/src/core/render/utils.js +++ b/src/core/render/utils.js @@ -6,17 +6,28 @@ * An example of this is ':include :type=code :fragment=demo' is taken and * then converted to: * + * Specially the exitra 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 = {}; @@ -25,20 +36,173 @@ export function getAndRemoveConfig(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); } return { str, config }; } +const lexer = function (str) { + const FLAG = ':'; + const EQUIL = '='; + const tokens = str.split(''); + let cur = 0; + const configs = {}; + const invalidConfigKeys = []; + + const scanner = function (token) { + if (isAtEnd()) {return;} + + if (isBlank(token)) { + return; + } + if (token !== FLAG) { + return; + } + + let curToken = ''; + let start = cur - 1; + + // eat start '/" if it exists + if (tokens[start - 1] && tokens[start - 1].match(/['"]/)) { + start--; + } + + while (!isBlank(peek()) && !(peek() === EQUIL) && !peek().match(/['"]/)) { + curToken += advance(); + } + + let match = true; + // Check for the currentToken and process it with our keywords + switch (curToken) { + // customise ID for headings or #id + case 'id': + configs.id = findValuePair(); + break; + // customise class for headings + 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 + if (curToken.endsWith(FLAG)) { + // okay, suppose it should be a emoji, skip it to make all happy + } else { + invalidConfigKeys.push(FLAG + curToken); + } + match = false; + } + + if (match) { + for (let i = start; i < cur; i++) { + tokens[i] = ''; + } + + // eat the end '/" if it exists + if (peek().match(/['"]/)) { + tokens[cur] = ''; + advance(); + } + match = false; + } + }; + + 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(); + } + + 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 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()); + } + + if (invalidConfigKeys.length > 0) { + const msg = invalidConfigKeys.join(', '); + console.warn( + `May find docsify doesn't support config keys: [${msg}], please recheck it.`, + ); + } + + 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/unit/render-util.test.js b/test/unit/render-util.test.js index 33ae14ad2..9ea2e3edb 100644 --- a/test/unit/render-util.test.js +++ b/test/unit/render-util.test.js @@ -62,80 +62,122 @@ describe('core/render/utils', () => { // getAndRemoveConfig() // --------------------------------------------------------------------------- describe('getAndRemoveConfig()', () => { - test('parse simple config', () => { + test('parse simple class config', () => { const result = getAndRemoveConfig( - "[filename](_media/example.md ':include')", + "[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 no config', () => { + const result = getAndRemoveConfig( + '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 arguments', () => { 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')", + // It looks weird since it quotes the invalid configs, and we don't want to swallow them. + str: "[filename](_media/example.md :invalid=bar :invalid2 test')", }); }); test('parse config with double quotes', () => { 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 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::', }); }); }); -}); -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'); + }); }); }); From cd38063177fdb8bfb51b6a24785ce100fb09c7f8 Mon Sep 17 00:00:00 2001 From: koy Date: Mon, 22 Jul 2024 23:02:57 +0800 Subject: [PATCH 2/7] fix: lint --- src/core/render/utils.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/render/utils.js b/src/core/render/utils.js index de21f8dd5..84061d89f 100644 --- a/src/core/render/utils.js +++ b/src/core/render/utils.js @@ -52,7 +52,9 @@ const lexer = function (str) { const invalidConfigKeys = []; const scanner = function (token) { - if (isAtEnd()) {return;} + if (isAtEnd()) { + return; + } if (isBlank(token)) { return; @@ -177,7 +179,9 @@ const lexer = function (str) { }; const peek = function () { - if (isAtEnd()) {return '';} + if (isAtEnd()) { + return ''; + } return tokens[cur]; }; From 1aa88b24ad23dfd24e1e3488eef28ac9b3d8e5a1 Mon Sep 17 00:00:00 2001 From: koy Date: Mon, 22 Jul 2024 23:09:00 +0800 Subject: [PATCH 3/7] fix: lint --- src/core/render/compiler/image.js | 2 +- src/core/render/compiler/link.js | 2 +- test/integration/render.test.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/render/compiler/image.js b/src/core/render/compiler/image.js index b9ee2d954..463a9e3d0 100644 --- a/src/core/render/compiler/image.js +++ b/src/core/render/compiler/image.js @@ -29,7 +29,7 @@ export const imageCompiler = ({ renderer, contentBase, router }) => if (config.class) { let classes = config.class; if (config.class_appened_props) { - classes = `${config.config.class} ${config.class_appened_props}`; + classes = `${config.class} ${config.class_appened_props}`; } attrs.push(`class="${classes}"`); } diff --git a/src/core/render/compiler/link.js b/src/core/render/compiler/link.js index b8a00aeb2..10af8a2c3 100644 --- a/src/core/render/compiler/link.js +++ b/src/core/render/compiler/link.js @@ -55,7 +55,7 @@ export const linkCompiler = ({ if (config.class) { let classes = config.class; if (config.class_appened_props) { - classes = `${config.config.class} ${config.class_appened_props}`; + classes = `${config.class} ${config.class_appened_props}`; } attrs.push(`class="${classes}"`); } diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 4f624618b..0ca96b447 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -271,11 +271,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

"', ); }); From 73dc9f71eb79613f4f528e5c67c7ee7d23bd21e6 Mon Sep 17 00:00:00 2001 From: koy Date: Mon, 22 Jul 2024 23:50:55 +0800 Subject: [PATCH 4/7] chore: typo --- src/core/render/utils.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/core/render/utils.js b/src/core/render/utils.js index 84061d89f..2514dc2db 100644 --- a/src/core/render/utils.js +++ b/src/core/render/utils.js @@ -6,7 +6,7 @@ * An example of this is ':include :type=code :fragment=demo' is taken and * then converted to: * - * Specially the exitra values following a key, such as `:propKey=propVal additinalProp1 additinalProp2` + * 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' }` @@ -66,7 +66,7 @@ const lexer = function (str) { let curToken = ''; let start = cur - 1; - // eat start '/" if it exists + // Eat start '/" if it exists if (tokens[start - 1] && tokens[start - 1].match(/['"]/)) { start--; } @@ -76,27 +76,26 @@ const lexer = function (str) { } let match = true; - // Check for the currentToken and process it with our keywords + // Check for the currentToken and process it with our keywords. switch (curToken) { - // customise ID for headings or #id + // Customise ID for headings or #id. case 'id': configs.id = findValuePair(); break; - // customise class for headings case 'type': configs.type = findValuePair(); findAdditionalPropsIfExist('type'); break; - // ignore to compile link, e.g. ':ignore' , ':ignore title' + // Ignore to compile link, e.g. ':ignore' , ':ignore title'. case 'ignore': configs.ignore = true; findAdditionalPropsIfExist('ignore'); break; - // include + // Include case 'include': configs.include = true; break; - // Embedded code fragments e.g. :fragment=demo' + // Embedded code fragments e.g. :fragment=demo'. case 'fragment': configs.fragment = findValuePair(); break; @@ -104,11 +103,11 @@ const lexer = function (str) { case 'disabled': configs.disabled = true; break; - // Link target config, e.g. target=_blank + // Link target config, e.g. target=_blank. case 'target': configs.target = findValuePair(); break; - // Image size config, e.g. size=100, size=WIDTHxHEIGHT + // Image size config, e.g. size=100, size=WIDTHxHEIGHT. case 'size': configs.size = findValuePair(); break; @@ -120,9 +119,9 @@ const lexer = function (str) { configs['no-zoom'] = true; break; default: - // Although it start with FLAG (:), it is an invalid config token for docsify + // Although it start with FLAG (:), it is an invalid config token for docsify. if (curToken.endsWith(FLAG)) { - // okay, suppose it should be a emoji, skip it to make all happy + // Okay, suppose it should be an emoji, skip to warn it to make all happy. } else { invalidConfigKeys.push(FLAG + curToken); } From b0218dfbd1db51ba8c57897c4bfa7a2bb0c5e9d5 Mon Sep 17 00:00:00 2001 From: koy Date: Tue, 23 Jul 2024 22:22:34 +0800 Subject: [PATCH 5/7] update: review --- src/core/render/utils.js | 95 ++++++++++++++++++++++----------- test/integration/render.test.js | 26 +++++++++ test/unit/render-util.test.js | 70 +++++++++++++++++++++--- 3 files changed, 153 insertions(+), 38 deletions(-) diff --git a/src/core/render/utils.js b/src/core/render/utils.js index 2514dc2db..4db0f3588 100644 --- a/src/core/render/utils.js +++ b/src/core/render/utils.js @@ -33,11 +33,7 @@ export function getAndRemoveConfig(str = '') { const config = {}; if (str) { - str = str - .replace(/^('|")/, '') - .replace(/('|")$/, '') - .trim(); - return lexer(str); + return lexer(str.trim()); } return { str, config }; @@ -47,9 +43,11 @@ const lexer = function (str) { const FLAG = ':'; const EQUIL = '='; const tokens = str.split(''); - let cur = 0; const configs = {}; - const invalidConfigKeys = []; + let cur = 0; + let startConfigsStringQuote = ''; + let startConfigsStringIndex = -1; + let endConfigsStringIndex = -1; const scanner = function (token) { if (isAtEnd()) { @@ -64,14 +62,41 @@ const lexer = function (str) { } let curToken = ''; - let start = cur - 1; + 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]; + // Can not find the start quote, it means it is not a valid config scope, e.g. `[example](_mediea/xx.md :include )`. + if (possibleStartQuoteIndex !== -1) { + // Find the close quote + const possibleEndQuoteIndex = findPossiableEndQuote( + start, + possibleStartQuote, + ); + // Can not find a close quote, e.g. `[example](_mediea/xx.md ":include )`. + if (possibleEndQuoteIndex === -1) { + return; + } + // Can not find a matched close quote but a wired string, e.g. `[example](_mediea/xx.md ":include' )`. + if (possibleStartQuote !== tokens[possibleEndQuoteIndex]) { + return; + } + endConfigsStringIndex = possibleEndQuoteIndex; + } - // Eat start '/" if it exists - if (tokens[start - 1] && tokens[start - 1].match(/['"]/)) { - start--; + startConfigsStringIndex = possibleStartQuoteIndex; + startConfigsStringQuote = possibleStartQuote; } - while (!isBlank(peek()) && !(peek() === EQUIL) && !peek().match(/['"]/)) { + while ( + !isBlank(peek()) && + !(peek() === startConfigsStringQuote) && + !(peek() === EQUIL) && + !(peek() === FLAG) + ) { curToken += advance(); } @@ -79,8 +104,9 @@ const lexer = function (str) { // Check for the currentToken and process it with our keywords. switch (curToken) { // Customise ID for headings or #id. + // Note: when user wrapper the id value like this :id="something" in heading, it will bring the excaped quotes, we should remove it. case 'id': - configs.id = findValuePair(); + configs.id = findValuePair().replace(/"/g, ''); break; case 'type': configs.type = findValuePair(); @@ -119,26 +145,14 @@ const lexer = function (str) { configs['no-zoom'] = true; break; default: - // Although it start with FLAG (:), it is an invalid config token for docsify. - if (curToken.endsWith(FLAG)) { - // Okay, suppose it should be an emoji, skip to warn it to make all happy. - } else { - invalidConfigKeys.push(FLAG + curToken); - } match = false; + // Although it start with FLAG (:), it is an invalid config token for docsify. } if (match) { for (let i = start; i < cur; i++) { tokens[i] = ''; } - - // eat the end '/" if it exists - if (peek().match(/['"]/)) { - tokens[cur] = ''; - advance(); - } - match = false; } }; @@ -155,6 +169,7 @@ const lexer = function (str) { while (!isBlank(peek()) && !peek().match(/['"]/)) { val += advance(); } + return val.trim(); } @@ -177,6 +192,27 @@ const lexer = function (str) { 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 ''; @@ -196,11 +232,8 @@ const lexer = function (str) { scanner(advance()); } - if (invalidConfigKeys.length > 0) { - const msg = invalidConfigKeys.join(', '); - console.warn( - `May find docsify doesn't support config keys: [${msg}], please recheck it.`, - ); + for (let i = startConfigsStringIndex; i <= endConfigsStringIndex; i++) { + tokens[i] = ''; } const content = tokens.join('').trim(); diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 0ca96b447..85c47b9a3 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -125,6 +125,16 @@ describe('render', function () { ); }); + test('multi class and losse 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,22 @@ describe('render', function () { ); }); + test('id in heading', async function () { + const output = window.marked('# MyHeader :id=myNewId'); + + expect(output).toMatchInlineSnapshot( + '"

MyHeader

"', + ); + }); + + test('compatibility case when id in heading with quoto', async function () { + const output = window.marked('# MyHeader :id="myQuotedId"'); + + expect(output).toMatchInlineSnapshot( + '"

MyHeader

"', + ); + }); + test('no-zoom', async function () { const output = window.marked("![alt text](http://imageUrl ':no-zoom')"); diff --git a/test/unit/render-util.test.js b/test/unit/render-util.test.js index 9ea2e3edb..019c937f7 100644 --- a/test/unit/render-util.test.js +++ b/test/unit/render-util.test.js @@ -62,7 +62,16 @@ describe('core/render/utils', () => { // getAndRemoveConfig() // --------------------------------------------------------------------------- describe('getAndRemoveConfig()', () => { - test('parse simple class 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')", ); @@ -82,7 +91,7 @@ describe('core/render/utils', () => { }); }); - test('parse simple config with emoji no config', () => { + test('parse simple config with emoji and no config', () => { const result = getAndRemoveConfig( 'I use the :emoji: but it should be fine with :code=js', ); @@ -93,7 +102,7 @@ describe('core/render/utils', () => { }); }); - test('parse config with invalid arguments', () => { + test('parse config with invalid data attributes but we can swallow them', () => { const result = getAndRemoveConfig( "[filename](_media/example.md ':include :class=myClz myClz2 myClz3 :invalid=bar :invalid2 test')", ); @@ -104,12 +113,11 @@ describe('core/render/utils', () => { class: 'myClz', class_appened_props: 'myClz2 myClz3', }, - // It looks weird since it quotes the invalid configs, and we don't want to swallow them. - str: "[filename](_media/example.md :invalid=bar :invalid2 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 ":type=code js :include")', ); @@ -124,9 +132,40 @@ describe('core/render/utils', () => { }); }); + 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'", + "It should work :dog: and the ::dog2:: and the ::dog3::dog4:: ' :id=myTitle :type=code js :include'", ); expect(result).toMatchObject({ @@ -139,6 +178,23 @@ describe('core/render/utils', () => { 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', + }, + // It looks weird since it quotes the invalid configs, and we don't want to swallow them. + str: '[filename](_media/example.md )', + }); + }); }); describe('core/render/tpl', () => { From 89bc77ee201d7714e0f30347688cb539e890bb10 Mon Sep 17 00:00:00 2001 From: koy Date: Tue, 23 Jul 2024 22:28:45 +0800 Subject: [PATCH 6/7] chore:typo --- test/integration/render.test.js | 2 +- test/unit/render-util.test.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 85c47b9a3..74ef975e2 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -125,7 +125,7 @@ describe('render', function () { ); }); - test('multi class and losse quotes', async function () { + test('multi class and loose quotes', async function () { const output = window.marked( "![alt text](http://imageUrl ' target=_self :class=someCssClass someCssClassB ')", ); diff --git a/test/unit/render-util.test.js b/test/unit/render-util.test.js index 019c937f7..3fae94c8e 100644 --- a/test/unit/render-util.test.js +++ b/test/unit/render-util.test.js @@ -191,7 +191,6 @@ describe('core/render/utils', () => { class_appened_props: 'myClz2 myClz3', target: '_blank', }, - // It looks weird since it quotes the invalid configs, and we don't want to swallow them. str: '[filename](_media/example.md )', }); }); From 56cdeab9b7ffd4592a554a20ddeef62a3dc8867e Mon Sep 17 00:00:00 2001 From: koy Date: Wed, 24 Jul 2024 20:32:38 +0800 Subject: [PATCH 7/7] chore: polish --- src/core/render/utils.js | 25 ++++++++++++------------- test/integration/render.test.js | 8 -------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/core/render/utils.js b/src/core/render/utils.js index 4db0f3588..31fc9809b 100644 --- a/src/core/render/utils.js +++ b/src/core/render/utils.js @@ -69,19 +69,19 @@ const lexer = function (str) { if (startConfigsStringIndex === -1) { const possibleStartQuoteIndex = findPossiableStartQuote(start); const possibleStartQuote = tokens[possibleStartQuoteIndex]; - // Can not find the start quote, it means it is not a valid config scope, e.g. `[example](_mediea/xx.md :include )`. + if (possibleStartQuoteIndex !== -1) { - // Find the close quote const possibleEndQuoteIndex = findPossiableEndQuote( start, possibleStartQuote, ); - // Can not find a close quote, e.g. `[example](_mediea/xx.md ":include )`. - if (possibleEndQuoteIndex === -1) { + + if (!possibleStartQuote) { return; } - // Can not find a matched close quote but a wired string, e.g. `[example](_mediea/xx.md ":include' )`. - if (possibleStartQuote !== tokens[possibleEndQuoteIndex]) { + + const possibleEndQuote = tokens[possibleEndQuoteIndex]; + if (possibleStartQuote !== possibleEndQuote) { return; } endConfigsStringIndex = possibleEndQuoteIndex; @@ -101,18 +101,17 @@ const lexer = function (str) { } let match = true; - // Check for the currentToken and process it with our keywords. + switch (curToken) { - // Customise ID for headings or #id. - // Note: when user wrapper the id value like this :id="something" in heading, it will bring the excaped quotes, we should remove it. + // Customise ID for headings #Docsify :id=heading . case 'id': - configs.id = findValuePair().replace(/"/g, ''); + configs.id = findValuePair(); break; case 'type': configs.type = findValuePair(); findAdditionalPropsIfExist('type'); break; - // Ignore to compile link, e.g. ':ignore' , ':ignore title'. + // Ignore to compile link, e.g. :ignore , :ignore title. case 'ignore': configs.ignore = true; findAdditionalPropsIfExist('ignore'); @@ -145,8 +144,8 @@ const lexer = function (str) { configs['no-zoom'] = true; break; default: + // Although it start with FLAG (:), it is an invalid config token for docsify. match = false; - // Although it start with FLAG (:), it is an invalid config token for docsify. } if (match) { @@ -170,7 +169,7 @@ const lexer = function (str) { val += advance(); } - return val.trim(); + return val.trim().replace(/"/g, ''); } return ''; diff --git a/test/integration/render.test.js b/test/integration/render.test.js index 74ef975e2..65d3eecaa 100644 --- a/test/integration/render.test.js +++ b/test/integration/render.test.js @@ -153,14 +153,6 @@ describe('render', function () { ); }); - test('compatibility case when id in heading with quoto', async function () { - const output = window.marked('# MyHeader :id="myQuotedId"'); - - expect(output).toMatchInlineSnapshot( - '"

MyHeader

"', - ); - }); - test('no-zoom', async function () { const output = window.marked("![alt text](http://imageUrl ':no-zoom')");