From 2c8ef7d4585296f34880b0f68ec3436eecefcd26 Mon Sep 17 00:00:00 2001 From: arve0 Date: Sat, 5 Aug 2017 08:59:40 +0200 Subject: [PATCH 1/4] added test for issue #32 --- test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test.js b/test.js index d09c99c..e2907f9 100644 --- a/test.js +++ b/test.js @@ -207,5 +207,20 @@ describe('markdown-it-attrs', () => { expected += '\n'; assert.equal(md.render(src), expected); }); + + it('should support nested lists', () => { + src = '- item\n'; + src += ' - nested'; + src += ' {.red}'; + src += '{.blue}'; + expected = '\n'; + assert.equal(md.render(src), expected); + }); }); From 9ef61ef713cb76597974eeecfd00984074593c27 Mon Sep 17 00:00:00 2001 From: arve0 Date: Mon, 7 Aug 2017 23:24:50 +0200 Subject: [PATCH 2/4] rewrite logic as patterns and transforms The logic works like this: 1. Try matching pattern to token stream. 2. If pattern matches, run `pattern.transform`. 3. `pattern.transform` finds attributes and alters token stream accordingly. A patterns consist of several tests. Each test must contain either `shift` or `position`. For each token, tests of all patterns are rerun, such that you can match which tokens follow each other in the token stream. Example: ```js { /** * | h1 | * | -- | * | c1 | * {.c} */ name: 'tables', tests: [ { shift: 0, type: 'table_close' }, { shift: 1, type: 'paragraph_open' }, { shift: 2, type: 'inline', content: utils.hasCurly('only') } ],... ``` The pattern above will match the token stream if 1. current token is of type table_close 2. the next token is a paragraph_open 3. the token after paragraph_open is of type inline 4. and it has content with curly One may also specify patterns of children: ```js /** * - item * {.a} */ name: 'list softbreak', tests: [ { shift: -2, type: 'list_item_open' }, { shift: 0, type: 'inline', children: [ { position: -2, type: 'softbreak' }, { position: -1, content: utils.hasCurly('only') } ] } ],... ``` The children pattern is similar as previous example, but uses `position` here instead of `shift`. This makes it easy to specify last token (position: -1). A transform is usually something like this: 1. get attributes 2. find correct token where attributes should be set 3. set attributes 4. remove curly from output Example: ```js transform: (tokens, i) => { let token = tokens[i]; let start = token.info.lastIndexOf('{'); let attrs = utils.getAttrs(token.info, start); utils.addAttrs(attrs, token); token.info = utils.removeCurly(token.info); } ``` Note that keys in tests may be: 1. boolean | number | string: strict compare with token 2. function: function is called with token[key], should return true or false 3. array of functions: same as above, but all functions are called 4. array of tests: only for the key `children`, same as a token test, but will loop childrens of token, note that token should then be of type 'inline' --- index.js | 261 ++++++++++++++++++------------------------- patterns.js | 315 ++++++++++++++++++++++++++++++++++++---------------- test.js | 15 +-- utils.js | 145 ++++++++++++++---------- 4 files changed, 418 insertions(+), 318 deletions(-) diff --git a/index.js b/index.js index 02ef918..ac8386d 100644 --- a/index.js +++ b/index.js @@ -1,183 +1,138 @@ 'use strict'; -const utils = require('./utils.js'); const patterns = require('./patterns.js'); module.exports = function attributes(md) { - function curlyAttrs(state){ - var tokens = state.tokens; - - for (var i = 0; i < tokens.length; ++i) { - let token = tokens[i]; - let pattern; - if (token.block) { - pattern = getMatchingPattern(tokens, i, 'block'); - if (pattern) { - pattern.transform(tokens, i); - continue; - } - if (token.type === 'inline') { - let children = tokens[i].children; - for (let j = 0; j < children.length; ++j) { - pattern = getMatchingPattern(children, j, 'inline'); - if (pattern) { - pattern.transform(children, j); - continue; - } + function curlyAttrs(state) { + let tokens = state.tokens; + + for (let i = 0; i < tokens.length; i++) { + for (let p = 0; p < patterns.length; p++) { + let pattern = patterns[p]; + let j = null; // position of child with offset 0 + let match = pattern.tests.every(t => { + let res = test(tokens, i, t); + if (res.j !== null) { j = res.j; } + return res.match; + }); + if (match) { + pattern.transform(tokens, i, j); + if (pattern.name === 'inline attributes') { + // retry, may be several inline attributes + p--; } } } - - // block tokens contain markup - // inline tokens contain the text - if (tokens[i].type !== 'inline') { - continue; - } - - var inlineTokens = tokens[i].children; - if (!inlineTokens || inlineTokens.length <= 0) { - continue; - } - - // attributes for blocks - var lastInlineToken; - if (utils.hasCurlyInEnd(tokens[i].content)) { - lastInlineToken = last(inlineTokens); - var content = lastInlineToken.content; - var curlyStart = content.lastIndexOf('{'); - var attrs = utils.getAttrs(content, curlyStart + 1, content.length - 1); - // if list and `\n{#c}` -> apply to bullet list open: - // - // - iii - // {#c} - // - // should give - // - // - var nextLastInline = nextLast(inlineTokens); - // some blocks are hidden, example li > paragraph_open - var correspondingBlock = firstTokenNotHidden(tokens, i - 1); - if (nextLastInline && nextLastInline.type === 'softbreak' && - correspondingBlock && correspondingBlock.type === 'list_item_open') { - utils.addAttrs(attrs, bulletListOpen(tokens, i - 1)); - // remove softbreak and {} inline tokens - tokens[i].children = inlineTokens.slice(0, -2); - tokens[i].content = utils.removeCurly(tokens[i].content); - if (utils.hasCurlyInEnd(tokens[i].content)) { - // do once more: - // - // - item {.a} - // {.b} <-- applied this - i -= 1; - } - } else { - utils.addAttrs(attrs, correspondingBlock); - lastInlineToken.content = utils.removeCurly(content); - if (lastInlineToken.content === '') { - // remove empty inline token - inlineTokens.pop(); - } - tokens[i].content = utils.removeCurly(tokens[i].content); - } - } - } } + md.core.ruler.before('linkify', 'curly_attributes', curlyAttrs); }; /** - * some blocks are hidden (not rendered) - */ -function firstTokenNotHidden(tokens, i) { - if (tokens[i] && tokens[i].hidden) { - return firstTokenNotHidden(tokens, i - 1); - } - return tokens[i]; -} - -/** - * Find corresponding bullet/ordered list open. - */ -function bulletListOpen(tokens, i) { - var level = 0; - var token; - for (; i >= 0; i -= 1) { - token = tokens[i]; - // jump past nested lists, level == 0 and open -> correct opening token - if (token.type === 'bullet_list_close' || - token.type === 'ordered_list_close') { - level += 1; - } - if (token.type === 'bullet_list_open' || - token.type === 'ordered_list_open') { - if (level === 0) { - return token; - } else { - level -= 1; - } - } - } -} - -/** - * Returns first pattern that matches `token`-stream - * at current `i`. + * Test if t matches token stream. * * @param {array} tokens * @param {number} i - * @param {string} type - pattern type + * @param {object} t Test to match. + * @return {object} { match: true|false, j: null|number } */ -function getMatchingPattern (tokens, i, type) { - type = type || 'block'; - for (let pattern of patterns.filter(p => p.type === type)) { - let match = pattern.tests.every((test) => { - let j = i + test.shift; - let token = tokens[j]; - - if (token === undefined) { return false; } - - for (let key in test) { - if (key === 'shift') { continue; } - - - if (token[key] === undefined) { return false; } - switch (typeof test[key]) { - case 'boolean': - case 'number': - case 'string': - if (token[key] !== test[key]) { return false; } - break; - case 'function': - if (!test[key](token[key])) { return false; } - break; - case 'object': - if (Array.isArray(test[key])) { - let res = test[key].every(t => t(token[key])); - if (res === false) { return false; } +function test(tokens, i, t) { + let res = { + match: false, + j: null // position of child + }; + + let ii = t.shift !== undefined + ? i + t.shift + : t.position; + let token = get(tokens, ii); // supports negative ii + + + if (token === undefined) { return res; } + + for (let key in t) { + if (key === 'shift' || key === 'position') { continue; } + + if (token[key] === undefined) { return res; } + + if (key === 'children' && isArrayOfObjects(t.children)) { + if (token.children.length === 0) { + return res; + } + let match; + let childTests = t.children; + let children = token.children; + if (childTests.every(tt => tt.position !== undefined)) { + // positions instead of shifts, do not loop all children + match = childTests.every(tt => test(children, tt.position, tt).match); + if (match) { + // we may need position of child in transform + let j = last(childTests).position; + res.j = j >= 0 ? j : children.length + j; + } + } else { + for (let j = 0; j < children.length; j++) { + match = childTests.every(tt => test(children, j, tt).match); + if (match) { + res.j = j; + // all tests true, continue with next key of pattern t break; } - // fall through for objects that are not arrays - default: - throw new Error('Unknown type of pattern test. Test should be of type boolean, number, string, function or array of functions.'); } } - return true; - }); - if (match) { - return pattern; + + if (match === false) { return res; } + + continue; + } + + switch (typeof t[key]) { + case 'boolean': + case 'number': + case 'string': + if (token[key] !== t[key]) { return res; } + break; + case 'function': + if (!t[key](token[key])) { return res; } + break; + case 'object': + if (isArrayOfFunctions(t[key])) { + let r = t[key].every(tt => tt(token[key])); + if (r === false) { return res; } + break; + } + // fall through for objects !== arrays of functions + default: + throw new Error(`Unknown type of pattern test (key: ${key}). Test should be of type boolean, number, string, function or array of functions.`); } } - return false; + + // no tests returned false -> all tests returns true + res.match = true; + return res; } -function last(arr) { - return arr.slice(-1)[0]; +function isArrayOfObjects(arr) { + return Array.isArray(arr) && arr.length && arr.every(i => typeof i === 'object'); +} + +function isArrayOfFunctions(arr) { + return Array.isArray(arr) && arr.length && arr.every(i => typeof i === 'function'); +} + +/** + * Get n item of array. Supports negative n, where -1 is last + * element in array. + * @param {array} arr + * @param {number} n + */ +function get(arr, n) { + return n >= 0 ? arr[n] : arr[arr.length + n]; } -function nextLast(arr) { - return arr.slice(-2, -1)[0]; +// get last element of array, safe - returns {} if not found +function last(arr) { + return arr.slice(-1)[0] || {}; } diff --git a/patterns.js b/patterns.js index 5ca9670..90d4777 100644 --- a/patterns.js +++ b/patterns.js @@ -9,163 +9,284 @@ const utils = require('./utils.js'); module.exports = [ { /** - * > quote - * {.cls} + * ```python {.cls} + * for i in range(10): + * print(i) + * ``` */ - name: 'blockquote', - type: 'block', + name: 'fenced code blocks', + tests: [ + { + shift: 0, + block: true, + info: utils.hasCurly('end') + } + ], + transform: (tokens, i) => { + let token = tokens[i]; + let start = token.info.lastIndexOf('{'); + let attrs = utils.getAttrs(token.info, start); + utils.addAttrs(attrs, token); + token.info = utils.removeCurly(token.info); + } + }, { + /** + * bla `click()`{.c} + */ + name: 'code inline', tests: [ { - shift: 2, - type: 'blockquote_close' - }, { shift: 0, type: 'inline', children: [ - isNonEmptyArray, - (arr) => { - let j = arr.length - 2; - return arr[j] && arr[j].type === 'softbreak'; - }, - (arr) => utils.hasCurlyInStart(arr[arr.length - 1].content) + { + shift: -1, + type: 'code_inline' // does not have nesting: -1 + }, { + shift: 0, + type: 'text', + content: utils.hasCurly('start') + } ] } ], - transform: (tokens, i) => { - let token = tokens[i]; - let j = token.children.length - 1; - let attrsText = token.children[j].content; - let start = attrsText.lastIndexOf('{') + 1; - let end = attrsText.length - 1; - let attrs = utils.getAttrs(attrsText, start, end); - let blockquoteOpen = utils.matchingOpeningToken(tokens, i + 2); - utils.addAttrs(attrs, blockquoteOpen); - token.children = token.children.slice(0, -2); + transform: (tokens, i, j) => { + let token = tokens[i].children[j]; + let endChar = token.content.indexOf('}'); + var attrToken = tokens[i].children[j - 1]; + var attrs = utils.getAttrs(token.content, 0); + utils.addAttrs(attrs, attrToken); + token.content = token.content.slice(endChar + 1); } }, { /** - * ```python {.cls} - * for i in range(10): - * print(i) - * ``` + * | h1 | + * | -- | + * | c1 | + * {.c} */ - name: 'fenced code blocks', - type: 'block', + name: 'tables', tests: [ { + // let this token be i, such that for-loop continues at + // next token after tokens.splice shift: 0, - block: true, - info: utils.hasCurlyInEnd + type: 'table_close' + }, { + shift: 1, + type: 'paragraph_open' + }, { + shift: 2, + type: 'inline', + content: utils.hasCurly('only') } ], transform: (tokens, i) => { - let token = tokens[i]; - let start = token.info.lastIndexOf('{') + 1; - let attrs = utils.getAttrs(token.info, start, token.info.length - 1); - utils.addAttrs(attrs, token); - token.info = utils.removeCurly(token.info); + let token = tokens[i + 2]; + var tableOpen = utils.getMatchingOpeningToken(tokens, i); + var attrs = utils.getAttrs(token.content, 0); + // add attributes + utils.addAttrs(attrs, tableOpen); + // remove

{.c}

+ tokens.splice(i + 1, 3); } }, { /** * *emphasis*{.with attrs=1} */ name: 'inline attributes', - type: 'inline', tests: [ { - shift: -1, - nesting: -1 // closing inline tag before text with {.class} - }, { shift: 0, - type: 'text', - content: utils.hasCurlyInStart + type: 'inline', + children: [ + { + shift: -1, + nesting: -1 // closing inline tag, {.a} + }, { + shift: 0, + type: 'text', + content: utils.hasCurly('start') + } + ] } ], - transform: (tokens, i) => { - let token = tokens[i]; - let endChar = token.content.indexOf('}'); - // which token to add attributes to - var attrToken = utils.matchingOpeningToken(tokens, i - 1); - if (!attrToken) { return; } - var inlineAttrs = utils.getAttrs(token.content, 1, endChar); - if (inlineAttrs.length !== 0) { - // remove {} - token.content = token.content.slice(endChar + 1); - // add attributes - utils.addAttrs(inlineAttrs, attrToken); - } + transform: (tokens, i, j) => { + let token = tokens[i].children[j]; + let content = token.content; + let attrs = utils.getAttrs(content, 0); + var openingToken = utils.getMatchingOpeningToken(tokens[i].children, j - 1); + utils.addAttrs(attrs, openingToken); + token.content = content.slice(content.indexOf('}') + 1); } }, { /** - * bla `click()`{.c} + * - item + * {.a} */ - name: 'code inline', - type: 'inline', + name: 'list softbreak', tests: [ { - shift: -1, - type: 'code_inline' + shift: -2, + type: 'list_item_open' }, { shift: 0, - type: 'text', - content: utils.hasCurlyInStart + type: 'inline', + children: [ + { + position: -2, + type: 'softbreak' + }, { + position: -1, + content: utils.hasCurly('only') + } + ] } ], - transform: (tokens, i) => { - let token = tokens[i]; - let endChar = token.content.indexOf('}'); - var attrToken = tokens[i - 1]; - var inlineAttrs = utils.getAttrs(token.content, 1, endChar); - if (inlineAttrs.length !== 0) { - // remove {} - token.content = token.content.slice(endChar + 1); - // add attributes - utils.addAttrs(inlineAttrs, attrToken); - } + transform: (tokens, i, j) => { + let token = tokens[i].children[j]; + let content = token.content; + let attrs = utils.getAttrs(content, 0); + let ii = i - 2; + while (tokens[ii - 1] && + tokens[ii - 1].type !== 'ordered_list_open' && + tokens[ii - 1].type !== 'bullet_list_open') { ii--; } + utils.addAttrs(attrs, tokens[ii - 1]); + tokens[i].children = tokens[i].children.slice(0, -2); } }, { /** - * | h1 | - * | -- | - * | c1 | - * {.c} + * - nested list + * - with double \n + * {.a} <-- apply to nested ul + * + * {.b} <-- apply to root