diff --git a/index.js b/index.js index 4b1c1af..51e069e 100644 --- a/index.js +++ b/index.js @@ -1,110 +1,230 @@ -let MARKER_CHAR_CODE = ":".charCodeAt(0) +let todo = require("./todo") +let list = require("./list") -module.exports = md => { - function tokenize(state, silent) { - var i, scanned, token, len, ch, - start = state.pos, - marker = state.src.charCodeAt(start); +// wrapping rule is based on markdown-it-mark +let createWrappingRule = ({tag, name = tag, character, repeats = 1, before = "emphasis", classname = null}) => md => { + let targetCharacterCode = character.charCodeAt(0) - if (silent) { return false; } + function tokenize (state, silent) { + let startCharacter = state.src.charAt(state.pos) + let marker = startCharacter.charCodeAt(0) - if (marker !== MARKER_CHAR_CODE) { return false; } + if (silent) { + return false + } - scanned = state.scanDelims(state.pos, true); - len = scanned.length; - ch = String.fromCharCode(marker); + if (marker != targetCharacterCode) { + return false + } - if (len < 2) { return false; } + let scanned = state.scanDelims(state.pos, true) + let scanLength = scanned.length - if (len % 2) { - token = state.push('text', '', 0); - token.content = ch; - len--; + if (scanLength < repeats) { + return false } - for (i = 0; i < len; i += 2) { - token = state.push('text', '', 0); - token.content = ch + ch; + let token + + if (scanLength % repeats) { + token = state.push("text", "", 0) + token.content = startCharacter + scanLength-- + } + + for (let index = 0; index < scanLength; index += repeats) { + token = state.push("text", "", 0) + token.content = startCharacter.repeat(repeats) state.delimiters.push({ - marker: marker, - jump: i, - token: state.tokens.length - 1, - level: state.level, - end: -1, - open: scanned.can_open, - close: scanned.can_close - }); + marker, + jump: index, + token: state.tokens.length - 1, + level: state.level, + end: -1, + open: scanned.can_open, + close: scanned.can_close + }) } - state.pos += scanned.length; + state.pos += scanned.length - return true; + return true } - // Walk through delimiter list and replace text tokens with tags function postProcess(state) { - var i, j, - startDelim, - endDelim, - token, - loneMarkers = [], - delimiters = state.delimiters, - max = state.delimiters.length; - - for (i = 0; i < max; i++) { - startDelim = delimiters[i]; - - if (startDelim.marker !== MARKER_CHAR_CODE) { - continue; + let startDelim + let endDelim + let {delimiters} = state + let loneMarkers = [] + let token + for (let i = 0; i < state.delimiters.length; i++) { + startDelim = delimiters[i] + + if (startDelim.marker !== targetCharacterCode) { + continue } if (startDelim.end === -1) { - continue; + continue } - endDelim = delimiters[startDelim.end]; - - token = state.tokens[startDelim.token]; - token.type = 'mark_open'; - token.tag = 'mark'; - token.nesting = 1; - token.markup = '::'; - token.content = ''; + endDelim = delimiters[startDelim.end] - token = state.tokens[endDelim.token]; - token.type = 'mark_close'; - token.tag = 'mark'; - token.nesting = -1; - token.markup = '::'; - token.content = ''; - - if (state.tokens[endDelim.token - 1].type === 'text' && - state.tokens[endDelim.token - 1].content === ':') { - - loneMarkers.push(endDelim.token - 1); + token = state.tokens[startDelim.token] + token.type = `${name}_open` + if (classname) { + token.attrs = token.attrs || [] + classname && token.attrs.push(["class", classname]) + } + token.tag = tag + token.nesting = 1 + token.markup = character.repeat(repeats) + token.content = "" + + token = state.tokens[endDelim.token] + token.type = `${name}_close` + token.tag = tag + token.nesting = -1 + token.markup = character.repeat(repeats) + token.content = "" + + if (state.tokens[endDelim.token - 1].type == "text" && + state.tokens[endDelim.token - 1].content == character) { + + loneMarkers.push(endDelim.token - 1) } } while (loneMarkers.length) { - i = loneMarkers.pop(); - j = i + 1; + let i = loneMarkers.pop() + let j = i + 1 - while (j < state.tokens.length && state.tokens[j].type === 'mark_close') { - j++; + while (j < state.tokens.length && state.tokens[j].type === `${name}_close`) { + j++ } - j--; + j-- if (i !== j) { - token = state.tokens[j]; - state.tokens[j] = state.tokens[i]; - state.tokens[i] = token; + token = state.tokens[j] + state.tokens[j] = state.tokens[i] + state.tokens[i] = token } } } - md.inline.ruler.before('emphasis', 'mark', tokenize); - md.inline.ruler2.before('emphasis', 'mark', postProcess); + md.inline.ruler.before(before, name, tokenize) + md.inline.ruler2.before(before, name, postProcess) +} + +// this is here because `/` was not a terminator char +let createTextRuler = () => { + function isTerminatorChar(ch) { + switch (ch) { + case 0x0A/* \n */: + case 0x21/* ! */: + case 0x23/* # */: + case 0x24/* $ */: + case 0x25/* % */: + case 0x26/* & */: + case 0x2A/* * */: + case 0x2B/* + */: + case 0x2D/* - */: + case 0x2F/* / */: + case 0x3A/* : */: + case 0x3C/* < */: + case 0x3D/* = */: + case 0x3E/* > */: + case 0x40/* @ */: + case 0x5B/* [ */: + case 0x5C/* \ */: + case 0x5D/* ] */: + case 0x5E/* ^ */: + case 0x5F/* _ */: + case 0x60/* ` */: + case 0x7B/* { */: + case 0x7D/* } */: + case 0x7E/* ~ */: + return true; + default: + return false; + } + } + + return function text(state, silent) { + var pos = state.pos + + while (pos < state.posMax && !isTerminatorChar(state.src.charCodeAt(pos))) { + pos++ + } + + if (pos === state.pos) { + return false + } + + if (!silent) { + state.pending += state.src.slice(state.pos, pos) + } + + state.pos = pos + + return true + } +} + +module.exports = md => { + md.enable([ + "paragraph", + "reference", + "blockquote", + "linkify", + "heading", + "hr", + "link", + "code", + "fence", + "image", + "backticks", + "list" + ]) + + md.set({ + breaks: true, + typographer: true + }) + + md.inline.ruler.at( + "text", + createTextRuler() + ) + let addWrappingRule = rule => createWrappingRule(rule)(md) + + let wrappingRules = [ + { + character: ":", + repeats: 2, + tag: "mark" + }, + { + character: "*", + tag: "b" + }, + { + character: "/", + tag: "i", + before: "mark" + }, + { + character: "_", + tag: "u" + }, + { + character: "-", + tag: "s" + } + ] + wrappingRules.forEach(addWrappingRule) + md.core.ruler.after("inline", "todo", todo) } diff --git a/list.js b/list.js new file mode 100644 index 0000000..adac95f --- /dev/null +++ b/list.js @@ -0,0 +1,350 @@ +// Lists + +'use strict'; + + +function isSpace(code) { + switch (code) { + case 0x09: + case 0x20: + return true; + } + return false; +} + +function isBullet (marker) { + if (marker !== 0x2A/* * */){ + // marker !== 0x2D/* - */ && + // marker !== 0x2B/* + */) { + return false; + } +} + + +// Search `[-+*][\n ]`, returns next pos after marker on success +// or -1 on fail. +function skipBulletListMarker(state, startLine) { + var marker, pos, max, ch; + + pos = state.bMarks[startLine] + state.tShift[startLine]; + max = state.eMarks[startLine]; + + marker = state.src.charCodeAt(pos++); + // Check bullet + if (!isBullet(marker)) { + return -1 + } + + if (pos < max) { + ch = state.src.charCodeAt(pos); + + if (!isSpace(ch)) { + // " -test " - is not a list item + return -1; + } + } + + return pos; +} + +// Search `\d+[.)][\n ]`, returns next pos after marker on success +// or -1 on fail. +function skipOrderedListMarker(state, startLine) { + var ch, + start = state.bMarks[startLine] + state.tShift[startLine], + pos = start, + max = state.eMarks[startLine]; + + // List marker should have at least 2 chars (digit + dot) + if (pos + 1 >= max) { return -1; } + + ch = state.src.charCodeAt(pos++); + + if (ch < 0x30/* 0 */ || ch > 0x39/* 9 */) { return -1; } + + for (;;) { + // EOL -> fail + if (pos >= max) { return -1; } + + ch = state.src.charCodeAt(pos++); + + if (ch >= 0x30/* 0 */ && ch <= 0x39/* 9 */) { + + // List marker should have no more than 9 digits + // (prevents integer overflow in browsers) + if (pos - start >= 10) { return -1; } + + continue; + } + + // found valid marker + if (ch === 0x29/* ) */ || ch === 0x2e/* . */) { + break; + } + + return -1; + } + + + if (pos < max) { + ch = state.src.charCodeAt(pos); + + if (!isSpace(ch)) { + // " 1.test " - is not a list item + return -1; + } + } + return pos; +} + +function markTightParagraphs(state, idx) { + var i, l, + level = state.level + 2; + + for (i = idx + 2, l = state.tokens.length - 2; i < l; i++) { + if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') { + state.tokens[i + 2].hidden = true; + state.tokens[i].hidden = true; + i += 2; + } + } +} + + +module.exports = function list(state, startLine, endLine, silent) { + var ch, + contentStart, + i, + indent, + indentAfterMarker, + initial, + isOrdered, + itemLines, + l, + listLines, + listTokIdx, + markerCharCode, + markerValue, + max, + nextLine, + offset, + oldIndent, + oldLIndent, + oldParentType, + oldTShift, + oldTight, + pos, + posAfterMarker, + prevEmptyEnd, + start, + terminate, + terminatorRules, + token, + isTerminatingParagraph = false, + tight = true; + + // if it's indented more than 3 spaces, it should be a code block + if (state.sCount[startLine] - state.blkIndent >= 4) { return false; } + + // limit conditions when list can interrupt + // a paragraph (validation mode only) + if (silent && state.parentType === 'paragraph') { + // Next list item should still terminate previous list item; + // + // This code can fail if plugins use blkIndent as well as lists, + // but I hope the spec gets fixed long before that happens. + // + if (state.tShift[startLine] >= state.blkIndent) { + isTerminatingParagraph = true; + } + } + + // Detect list type and position after marker + if ((posAfterMarker = skipOrderedListMarker(state, startLine)) >= 0) { + isOrdered = true; + start = state.bMarks[startLine] + state.tShift[startLine]; + markerValue = Number(state.src.substr(start, posAfterMarker - start - 1)); + + // If we're starting a new ordered list right after + // a paragraph, it should start with 1. + if (isTerminatingParagraph && markerValue !== 1) return false; + + } else if ((posAfterMarker = skipBulletListMarker(state, startLine)) >= 0) { + isOrdered = false; + + } else { + return false; + } + + // If we're starting a new unordered list right after + // a paragraph, first line should not be empty. + if (isTerminatingParagraph) { + if (state.skipSpaces(posAfterMarker) >= state.eMarks[startLine]) return false; + } + + // We should terminate list on style change. Remember first one to compare. + markerCharCode = state.src.charCodeAt(posAfterMarker - 1); + + // For validation mode we can terminate immediately + if (silent) { return true; } + + // Start list + listTokIdx = state.tokens.length; + + if (isOrdered) { + token = state.push('ordered_list_open', 'ol', 1); + if (markerValue !== 1) { + token.attrs = [ [ 'start', markerValue ] ]; + } + + } else { + token = state.push('bullet_list_open', 'ul', 1); + } + + token.map = listLines = [ startLine, 0 ]; + token.markup = String.fromCharCode(markerCharCode); + + // + // Iterate list items + // + + nextLine = startLine; + prevEmptyEnd = false; + terminatorRules = state.md.block.ruler.getRules('list'); + + oldParentType = state.parentType; + state.parentType = 'list'; + + while (nextLine < endLine) { + pos = posAfterMarker; + max = state.eMarks[nextLine]; + + initial = offset = state.sCount[nextLine] + posAfterMarker - (state.bMarks[startLine] + state.tShift[startLine]); + + while (pos < max) { + ch = state.src.charCodeAt(pos); + + if (ch === 0x09) { + offset += 4 - (offset + state.bsCount[nextLine]) % 4; + } else if (ch === 0x20) { + offset++; + } else { + break; + } + + pos++; + } + + contentStart = pos; + + if (contentStart >= max) { + // trimming space in "- \n 3" case, indent is 1 here + indentAfterMarker = 1; + } else { + indentAfterMarker = offset - initial; + } + + // If we have more than 4 spaces, the indent is 1 + // (the rest is just indented code block) + if (indentAfterMarker > 4) { indentAfterMarker = 1; } + + // " - test" + // ^^^^^ - calculating total length of this thing + indent = initial + indentAfterMarker; + + // Run subparser & write tokens + token = state.push('list_item_open', 'li', 1); + token.markup = String.fromCharCode(markerCharCode); + token.map = itemLines = [ startLine, 0 ]; + + oldIndent = state.blkIndent; + oldTight = state.tight; + oldTShift = state.tShift[startLine]; + oldLIndent = state.sCount[startLine]; + state.blkIndent = indent; + state.tight = true; + state.tShift[startLine] = contentStart - state.bMarks[startLine]; + state.sCount[startLine] = offset; + + if (contentStart >= max && state.isEmpty(startLine + 1)) { + // workaround for this case + // (list item is empty, list terminates before "foo"): + // ~~~~~~~~ + // - + // + // foo + // ~~~~~~~~ + state.line = Math.min(state.line + 2, endLine); + } else { + state.md.block.tokenize(state, startLine, endLine, true); + } + + // If any of list item is tight, mark list as tight + if (!state.tight || prevEmptyEnd) { + tight = false; + } + // Item become loose if finish with empty line, + // but we should filter last element, because it means list finish + prevEmptyEnd = (state.line - startLine) > 1 && state.isEmpty(state.line - 1); + + state.blkIndent = oldIndent; + state.tShift[startLine] = oldTShift; + state.sCount[startLine] = oldLIndent; + state.tight = oldTight; + + token = state.push('list_item_close', 'li', -1); + token.markup = String.fromCharCode(markerCharCode); + + nextLine = startLine = state.line; + itemLines[1] = nextLine; + contentStart = state.bMarks[startLine]; + + if (nextLine >= endLine) { break; } + + // + // Try to check if list is terminated or continued. + // + if (state.sCount[nextLine] < state.blkIndent) { break; } + + // fail if terminating block found + terminate = false; + for (i = 0, l = terminatorRules.length; i < l; i++) { + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + } + if (terminate) { break; } + + // fail if list has another type + if (isOrdered) { + posAfterMarker = skipOrderedListMarker(state, nextLine); + if (posAfterMarker < 0) { break; } + if (markerCharCode !== state.src.charCodeAt(posAfterMarker - 1)) { break; } + } else { + posAfterMarker = skipBulletListMarker(state, nextLine); + if (posAfterMarker < 0) { break; } + if (!isBullet(state.src.charCodeAt(posAfterMarker - 1))) { break; } + } + } + + // Finalize list + if (isOrdered) { + token = state.push('ordered_list_close', 'ol', -1); + } else { + token = state.push('bullet_list_close', 'ul', -1); + } + token.markup = String.fromCharCode(markerCharCode); + + listLines[1] = nextLine; + state.line = nextLine; + + state.parentType = oldParentType; + + // mark paragraphs tight if needed + if (tight) { + markTightParagraphs(state, listTokIdx); + } + + return true; +}; diff --git a/package-lock.json b/package-lock.json index b97f984..8816738 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "markdown-it-mark", + "name": "markdown-it-bear", "version": "2.0.0", "lockfileVersion": 1, "requires": true, diff --git a/package.json b/package.json index 405f49d..6b56323 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "markdown-it-bear-mark", + "name": "markdown-it-bear", "version": "2.0.0", - "description": "polar bear tag for markdown-it markdown parser.", + "description": "bear.app's polar bear plugin for markdown-it markdown parser.", "keywords": [ "markdown-it-plugin", "markdown-it", diff --git a/test/fixtures/bear.txt b/test/fixtures/bear.txt new file mode 100644 index 0000000..97f4232 --- /dev/null +++ b/test/fixtures/bear.txt @@ -0,0 +1,246 @@ +. +- happened +* what +. + + +. + + +. +- todo item 1 +- todo item 2 +. + +. + +. ++ did it +- gotta do it again +. + +. + +. +# heading 1 +## heading 2 +### heading 3 +#### heading 4 +##### heading 5 +###### heading 6 +. +

heading 1

+

heading 2

+

heading 3

+

heading 4

+
heading 5
+
heading 6
+. + +. +1. one +2. two +3. three +. +
    +
  1. one
  2. +
  3. two
  4. +
  5. three
  6. +
+. + +. +* one +* two +* three +. + +. + +. +hello [chee](https://chee.snoot.club/content) +. +

hello chee

+. + +. +--- +. +
+. + +. +i am *bold* boi +. +

i am bold boi

+. + +. +i am /italic/ girl +. +

i am italic girl

+. + +. +i am well _understood_ +. +

i am well understood

+. + +. +i -struck out- +. +

i struck out

+. + +. +#recipes #recipes/abe #recipes/i like cheese# + +don't forget about #recipes #recipes/abe #recipes/i like cheese# and stuff +. +

#recipes #recipes/abe #recipes/i like cheese#

+

don't forget about #recipes #recipes/abe #recipes/i like cheese# and stuff

+. + +. +::Mark:: +. +

Mark

+. + +. +x ::::foo:: bar:: +. +

x foo bar

+. + +. +x ::foo ::bar:::: +. +

x foo bar

+. + +. +x ::::foo:::: +. +

x foo

+. + +. +x :::foo::: +. +

x :foo:

+. + +Marks have the same priority as emphases: + +. +*::test*:: + +::*test::* +. +

::test::

+

*test*

+. + + +Marks have the same priority as emphases with respect to links +. +[::link]():: + +::[link::]() +. +

::link::

+

::link::

+. + + +Marks have the same priority as emphases with respect to backticks +. +::`code::` + +`::code`:: +. +

::code::

+

::code::

+. + + +Nested marks +. +::foo ::bar:: baz:: +. +

foo bar baz

+. + + +Nested marks with bold +. +::f *o ::o b:: a* r:: +. +

f o o b a r

+. + + +Should not have a whitespace between text and "::": +. +foo :: bar :: baz +. +

foo :: bar :: baz

+. + + +Newline should be considered a whitespace: +. +::test +:: a + +:: +test:: + +:: +test +== +. +

::test +:: a

+

:: +test::

+

:: +test +==

+. + + +. +x ::a ::foo:::::::::::bar:: b:: + +x ::a ::foo::::::::::::bar:: b:: +. +

x a foo:::bar b

+

x a foo::::bar b

+. + + +From CommonMark test suite, replacing `**` with our marker: + +. +a::"foo":: +. +

a::"foo"::

+. diff --git a/test/fixtures/mark.txt b/test/fixtures/mark.txt deleted file mode 100644 index bf17dc5..0000000 --- a/test/fixtures/mark.txt +++ /dev/null @@ -1,126 +0,0 @@ -. -::Mark:: -. -

Mark

-. - -. -x ::::foo:: bar:: -. -

x foo bar

-. - -. -x ::foo ::bar:::: -. -

x foo bar

-. - -. -x ::::foo:::: -. -

x foo

-. - -. -x :::foo::: -. -

x :foo:

-. - -Marks have the same priority as emphases: - -. -**::test**:: - -::**test::** -. -

::test::

-

**test**

-. - - -Marks have the same priority as emphases with respect to links -. -[::link]():: - -::[link::]() -. -

::link::

-

::link::

-. - - -Marks have the same priority as emphases with respect to backticks -. -::`code::` - -`::code`:: -. -

::code::

-

::code::

-. - - -Nested marks -. -::foo ::bar:: baz:: -. -

foo bar baz

-. - - -Nested marks with emphasis -. -::f **o ::o b:: a** r:: -. -

f o o b a r

-. - - -Should not have a whitespace between text and "::": -. -foo :: bar :: baz -. -

foo :: bar :: baz

-. - - -Newline should be considered a whitespace: -. -::test -:: a - -:: -test:: - -:: -test -== -. -

::test -:: a

-

:: -test::

-

:: -test

-. - - -. -x ::a ::foo:::::::::::bar:: b:: - -x ::a ::foo::::::::::::bar:: b:: -. -

x a foo:::bar b

-

x a foo::::bar b

-. - - -From CommonMark test suite, replacing `**` with our marker: - -. -a::"foo":: -. -

a::"foo"::

-. diff --git a/test/test.js b/test/test.js index c5c9d3f..2f1d1cb 100644 --- a/test/test.js +++ b/test/test.js @@ -1,8 +1,8 @@ var path = require("path") var generate = require("markdown-it-testgen") -describe("markdown-it-mark", function () { - var md = require("markdown-it")() - .use(require("../")) - generate(path.join(__dirname, "fixtures/mark.txt"), md) +describe("markdown-it-polar-bear", function () { + var md = require("markdown-it")("zero") + .use(require("../")) + generate(path.join(__dirname, "fixtures/bear.txt"), md) }); diff --git a/todo.js b/todo.js new file mode 100644 index 0000000..326bf12 --- /dev/null +++ b/todo.js @@ -0,0 +1,92 @@ +// task list is based on this https://github.com/revin/markdown-it-task-lists +let getTrio = (tokens, index) => [ + tokens[index - 1], + tokens[index], + tokens[index + 1] +] + +module.exports = (state, startLine, endLine, silent) => { + let {tokens} = state + if (silent) return + for (let i = 2; i < tokens.length; i++) { + let [prev, current, next] = getTrio(tokens, i) + if (isBulletListClose(current)) { + if (next && isBulletList(next)) { + let listies = new Set(["-", "+"]) + if (listies.has(next.markup) && listies.has(current.markup)) { + next.hidden = true + current.hidden = true + } + } + } + let todoType = getTodoType(tokens, i) + if (todoType) { + todoify(tokens, i, state.Token, todoType) + let hotdog = tokens[i - 3] + if (isBulletList(hotdog)) { + attrSet(hotdog, "class", "todo-list") + } + } + } +} + +function attrSet(token, name, value) { + var index = token.attrIndex(name) + var attr = [name, value] + + if (index < 0) { + token.attrPush(attr) + } else { + token.attrs[index] = attr + } +} + +function getTodoType(tokens, index) { + if ( + tokens[index].content && + isInline(tokens[index]) && + isParagraph(tokens[index - 1]) && + isListItem(tokens[index - 2]) + ) { + let {markup} = tokens[index - 2] + switch (markup) { + case "-": + return "todo" + case "+": + return "done" + default: + return null + } + } + return null +} + +function todoify(tokens, index, TokenConstructor, todoType) { + var checkbox = new TokenConstructor("html_inline", "", 0); + let classname = `todo-checkbox ${todoType == "done" ? "todo-checked" : ""}` + + checkbox.content = ` ${tokens[index].content}` + tokens[index] = checkbox + // token.children.unshift(checkbox) + return checkbox +} + +function isInline (token) { + return token.type == "inline" +} + +function isParagraph (token) { + return token.type == "paragraph_open" +} + +function isListItem (token) { + return token.type == "list_item_open" +} + +function isBulletList (token) { + return token.type == "bullet_list_open" +} + +function isBulletListClose (token) { + return token.type == "bullet_list_close" +}