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(
+ '"![target=_self alt text](http://imageUrl)
"',
+ );
+ });
+
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(
+ '""',
+ );
+ });
+
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:
- '
Test',
- },
- ]);
-
- 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:
+ '
Test',
+ },
+ ]);
+
+ 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');
+ });
});
});