├── .git-blame-ignore-revs ├── .gitattributes ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── LICENSE ├── package.json ├── .github └── workflows │ └── ci.yml ├── README.md ├── src └── index.ts └── test └── index.ts /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /node_modules/ 3 | 4 | # Output directories 5 | /lib/ 6 | /esm/ 7 | /package-lock.json 8 | /yarn.lock 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "rootDir": "src", 7 | "declaration": true, 8 | "strictNullChecks": true, 9 | "skipLibCheck": true, 10 | "outDir": "lib", 11 | "lib": [ 12 | "es2018", 13 | "dom", 14 | ] 15 | }, 16 | "include": [ 17 | "src/**/*.ts", 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Moxio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-it-fancy-lists", 3 | "version": "1.1.0", 4 | "description": "Extension for markdown-it to support additional numbering types for ordered lists ", 5 | "keywords": [ "markdown-it-plugin", "markdown-it", "markdown", "commonmark", "fancy-lists", "ordered-list" ], 6 | "author": { 7 | "name": "Moxio", 8 | "email": "info@moxio.com", 9 | "url": "https://www.moxio.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/Moxio/markdown-it-fancy-lists.git" 14 | }, 15 | "license": "MIT", 16 | "scripts": { 17 | "test": "./node_modules/.bin/mocha --require ts-node/register ./test/**/*.ts", 18 | "prepare": "npm run build", 19 | "build": "npm run build:commonjs && npm run build:esm", 20 | "build:commonjs": "tsc", 21 | "build:esm": "tsc -m esNext --outDir esm" 22 | }, 23 | "main": "lib/index.js", 24 | "module": "esm/index.js", 25 | "sideEffects": false, 26 | "files": [ 27 | "lib/", 28 | "esm/" 29 | ], 30 | "types": "lib/index.d.ts", 31 | "typings": "lib/index.d.ts", 32 | "dependencies": { 33 | "roman-numerals": "^0.3.2" 34 | }, 35 | "devDependencies": { 36 | "@markedjs/html-differ": "^3.0.4", 37 | "@types/chai": "^4.2.14", 38 | "@types/markdown-it": "^13.0.2", 39 | "@types/mocha": "^8.2.0", 40 | "@types/roman-numerals": "^0.3.0", 41 | "chai": "^4.2.0", 42 | "markdown-it": "^14.0.0", 43 | "mocha": "^8.2.1", 44 | "ts-node": "^9.1.1", 45 | "typescript": "^4.1.3" 46 | }, 47 | "peerDependencies": { 48 | "markdown-it": "^12.0.3 || ^13.0.1 || ^14.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | release: 9 | types: [ created ] 10 | 11 | # See https://docs.npmjs.com/trusted-publishers#step-2-configure-your-cicd-workflow 12 | permissions: 13 | id-token: write 14 | contents: read 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | 30 | - name: Install dependencies 31 | run: npm install 32 | 33 | - name: Run test suite 34 | run: npm run test 35 | 36 | publish: 37 | needs: build 38 | runs-on: ubuntu-latest 39 | if: ${{ github.event_name == 'release' }} 40 | 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v4 44 | 45 | - name: Setup Node 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: 20 49 | registry-url: https://registry.npmjs.org/ 50 | 51 | # Ensure npm 11.5.1 or later is installed 52 | - name: Update npm 53 | run: npm install -g npm@latest 54 | 55 | - name: Install dependencies 56 | run: npm install 57 | 58 | - name: Set new version 59 | run: npm version --allow-same-version --no-git-tag-version ${{ github.event.release.tag_name }} 60 | 61 | - name: Publish release 62 | run: npm publish 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NPM version](https://img.shields.io/npm/v/markdown-it-fancy-lists.svg?style=flat)](https://www.npmjs.org/package/markdown-it-fancy-lists) 2 | 3 | markdown-it-fancy-lists 4 | ======================= 5 | 6 | Plugin for the [markdown-it](https://github.com/markdown-it/markdown-it) 7 | markdown parser. 8 | 9 | Uses unofficial markdown syntax based on the syntax supported by 10 | [Pandoc](https://pandoc.org/MANUAL.html#extension-fancy_lists). 11 | See the section [Syntax](#syntax) below for details. 12 | 13 | Installation 14 | ------ 15 | This library can be installed from the NPM package registry. Using NPM: 16 | ``` 17 | npm install markdown-it-fancy-lists 18 | ``` 19 | or Yarn 20 | ``` 21 | yarn add markdown-it-fancy-lists 22 | ``` 23 | 24 | Usage 25 | ------ 26 | ES module: 27 | ```javascript 28 | import * as MarkdownIt from "markdown-it"; 29 | import { markdownItFancyListPlugin } from "markdown-it-fancy-lists"; 30 | 31 | const parser = new MarkdownIt("default"); 32 | parser.use(markdownItFancyListPlugin); 33 | parser.render(/* markdown string */); 34 | ``` 35 | 36 | CommonJS: 37 | ```javascript 38 | const MarkdownIt = require('markdown-it'); 39 | const markdownItFancyListPlugin = require("markdown-it-fancy-lists").markdownItFancyListPlugin; 40 | 41 | const parser = new MarkdownIt("default"); 42 | parser.use(markdownItFancyListPlugin); 43 | parser.render(/* markdown string */); 44 | ``` 45 | 46 | 47 | Syntax 48 | ------ 49 | The supported markdown syntax is based on the one used by 50 | [Pandoc](https://pandoc.org/MANUAL.html#extension-fancy_lists). 51 | 52 | A simple example: 53 | ```markdown 54 | i. foo 55 | ii. bar 56 | iii. baz 57 | ``` 58 | The will yield HTML output like: 59 | ```html 60 |
    61 |
  1. foo
  2. 62 |
  3. bar
  4. 63 |
  5. baz
  6. 64 |
65 | ``` 66 | 67 | A more complex example: 68 | ```markdown 69 | c. charlie 70 | #. delta 71 | iv) subfour 72 | #) subfive 73 | #) subsix 74 | #. echo 75 | ``` 76 | 77 | A short description of the syntactical rules: 78 | 79 | * Apart from numbers, also letters (uppercase or lowercase) and 80 | Roman numerals (uppercase or lowercase) can be used to number 81 | ordered list items. Like lists marked with numbers, they need to 82 | be followed by a single right-parenthesis or period. 83 | * Changing list marker types (also between uppercase and lowercase, 84 | or the symbol after the 'number') starts a new list. 85 | * The numeral of the first item determines the numbering of the list. 86 | If the first item is numbered "b", the next item will be numbered 87 | "c", even if it is marked "z" in the source. This corresponds to 88 | the normal `markdown-it` behavior for numeric lists, and 89 | essentially also implements [Pandoc's `startnum` extension](https://pandoc.org/MANUAL.html#extension-fancy_lists). 90 | * If the first list item is numbered "I" or "i", the list is considered 91 | to be numbered using Roman numerals, starting at 1. If the list 92 | starts with another single letter that could be interpreted as a 93 | Roman numeral, the list is numbered using letters: a first item 94 | marked with "C." uses uppercase letters starting at 3, not Roman 95 | numerals starting a 100. 96 | * In subsequent list items, such symbols can be used without any 97 | ambiguity: in "B.", "C.", "D." the "C" is the letter "C"; in 98 | "IC.", "C.", "CI." the "C" is a Roman 100. 99 | * A "#" may be used in place of any numeral to continue a list. If 100 | the first item in a list is marked with "#", that list is numbered 101 | "1", "2", "3", etc. 102 | * A list marker consisting of a single uppercase letter followed by 103 | a period (including Roman numerals like "I." or "V.") needs to be 104 | followed by at least two spaces ([rationale](https://pandoc.org/MANUAL.html#fn1)). 105 | 106 | All of the above are entirely compatible with how Pandoc works. There 107 | are two small differences with Pandoc's syntax: 108 | 109 | * This plugin does not support list numbers enclosed in parentheses, 110 | as the Commonmark spec does not support these either for lists 111 | numbered with Arabic numerals. 112 | * Pandoc does not allow any list to interrupt a paragraph. In the 113 | spirit of the Commonmark spec (which allows only lists starting 114 | with 1 to interrupt a paragraph), this plugins allows lists that 115 | start with "A", "a", "I" or "i" (i.e. all 'first numerals') to 116 | interrupt a paragraph. The same holds for the "#" generic numbered 117 | list item marker. 118 | For nested lists, any start number can interrupt a paragraph. 119 | 120 | Configuration 121 | ------------- 122 | Options can be provided as a second argument when registering the plugin: 123 | ```javascript 124 | parser.use(markdownItFancyListPlugin, { 125 | /* options */ 126 | }); 127 | ``` 128 | 129 | Supported configuration options: 130 | 131 | * `allowOrdinal` - Whether to allow an [ordinal indicator](https://en.wikipedia.org/wiki/Ordinal_indicator) 132 | (`º`) after the numeral, as occurs in e.g. legal documents (default: `false`). If this option is enabled, 133 | input like 134 | ```markdown 135 | 1º. foo 136 | 2º. bar 137 | 3º. baz 138 | ``` 139 | will be converted to 140 | ```html 141 |
    142 |
  1. foo
  2. 143 |
  3. bar
  4. 144 |
  5. baz
  6. 145 |
146 | ``` 147 | You will need [custom CSS](https://codepen.io/MoxioHD/pen/GRrjpRb) to re-insert the ordinal indicator 148 | into the displayed output based on the `ordinal` class. 149 | 150 | Because the ordinal indicator is commonly confused with other characters like the degree symbol, these 151 | characters are tolerated and considered equivalent to the ordinal indicator. 152 | * `allowMultiLetter` - Whether to allow multi-letter alphabetic numerals, to number lists beyond 26 153 | (default: `false`). If this option is enabled, input like 154 | ```markdown 155 | AA. foo 156 | AB. bar 157 | AC. baz 158 | ``` 159 | will be converted to 160 | ```html 161 |
    162 |
  1. foo
  2. 163 |
  3. bar
  4. 164 |
  5. baz
  6. 165 |
166 | ``` 167 | Multi-letter alphabetic numerals can consist of at most 3 characters, which should be enough for a 168 | typical list. When a list starts with a numeral that can be both Roman or multi-letter alphabetic, 169 | like "II", it is considered to be Roman. 170 | 171 | Versioning 172 | ---------- 173 | This project adheres to [Semantic Versioning](http://semver.org/). 174 | 175 | Contributing 176 | ------------ 177 | Contributions to this project are more than welcome. When reporting an issue, 178 | please include the input to reproduce the issue, along with the expected 179 | output. When submitting a PR, please include tests with your changes. 180 | 181 | License 182 | ------- 183 | This project is released under the MIT license. 184 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as StateBlock from "markdown-it/lib/rules_block/state_block"; 2 | import * as Token from "markdown-it/lib/token"; 3 | import { toArabic } from "roman-numerals"; 4 | import * as MarkdownIt from "markdown-it"; 5 | 6 | export type MarkdownItFancyListPluginOptions = { 7 | allowOrdinal?: boolean; 8 | allowMultiLetter?: boolean; 9 | }; 10 | 11 | export const markdownItFancyListPlugin = (markdownIt: MarkdownIt, options?: MarkdownItFancyListPluginOptions): void => { 12 | const isSpace = markdownIt.utils.isSpace; 13 | 14 | // Search `[-+*][\n ]`, returns next pos after marker on success 15 | // or -1 on fail. 16 | function parseUnorderedListMarker(state: StateBlock, startLine: number): { type: "*" | "-" | "+"; posAfterMarker: number } | null { 17 | 18 | let pos = state.bMarks[startLine] + state.tShift[startLine]; 19 | const max = state.eMarks[startLine]; 20 | 21 | const marker = state.src.charCodeAt(pos); 22 | pos += 1; 23 | 24 | // Check bullet 25 | if (marker !== 0x2A/* * */ && 26 | marker !== 0x2D/* - */ && 27 | marker !== 0x2B/* + */) { 28 | return null; 29 | } 30 | 31 | if (pos < max) { 32 | const ch = state.src.charCodeAt(pos); 33 | 34 | if (!isSpace(ch)) { 35 | // " -test " - is not a list item 36 | return null; 37 | } 38 | } 39 | 40 | return { 41 | type: state.src.charAt(pos - 1) as "*" | "-" | "+", 42 | posAfterMarker: pos, 43 | }; 44 | } 45 | 46 | // Search `^(\d{1,9}|[a-z]{1,3}|[A-Z]{1,3}|[ivxlcdm]+|[IVXLCDM]+|#)([\u00BA\u00B0\u02DA\u1D52]?)([.)])`, returns next pos after marker on success 47 | // or -1 on fail. 48 | function parseOrderedListMarker(state: StateBlock, startLine: number): { bulletChar: string; hasOrdinalIndicator: boolean, delimiter: ")" | "."; posAfterMarker: number } | null { 49 | const start = state.bMarks[startLine] + state.tShift[startLine]; 50 | const max = state.eMarks[startLine]; 51 | 52 | // List marker should have at least 2 chars (digit + dot) 53 | if (start + 1 >= max) { 54 | return null; 55 | } 56 | 57 | const stringContainingNumberAndMarker = state.src.substring(start, Math.min(max, start + 10)); 58 | 59 | const match = /^(\d{1,9}|[a-z]{1,3}|[A-Z]{1,3}|[ivxlcdm]+|[IVXLCDM]+|#)([\u00BA\u00B0\u02DA\u1D52]?)([.)])/.exec(stringContainingNumberAndMarker); 60 | if (match === null) { 61 | return null; 62 | } 63 | 64 | const markerPos = start + match[1].length; 65 | const markerChar = state.src.charAt(markerPos); 66 | 67 | let finalPos = start + match[0].length; 68 | const finalChar = state.src.charCodeAt(finalPos); 69 | 70 | // requires once space after marker or eol 71 | if (isSpace(finalChar) === false && finalPos !== max) { 72 | return null; 73 | } 74 | 75 | // requires two spaces after a capital letter and a period 76 | if (isCharCodeUppercaseAlpha(match[1].charCodeAt(0)) && match[1].length === 1 && markerChar === ".") { 77 | finalPos += 1; // consume another space 78 | const finalChar = state.src.charCodeAt(finalPos); 79 | if (isSpace(finalChar) === false) { 80 | return null; 81 | } 82 | } 83 | 84 | return { 85 | bulletChar: match[1], 86 | hasOrdinalIndicator: match[2] !== "", 87 | delimiter: match[3] as ")" | ".", 88 | posAfterMarker: finalPos, 89 | }; 90 | } 91 | 92 | function markTightParagraphs(state: StateBlock, idx: number) { 93 | let i: number, l; 94 | const level = state.level + 2; 95 | 96 | for (i = idx + 2, l = state.tokens.length - 2; i < l; i += 1) { 97 | if (state.tokens[i].level === level && state.tokens[i].type === "paragraph_open") { 98 | state.tokens[i + 2].hidden = true; 99 | state.tokens[i].hidden = true; 100 | i += 2; 101 | } 102 | } 103 | } 104 | 105 | function isCharCodeDigit(charChode: number) { 106 | return charChode >= 0x30 /* 0 */ && charChode <= 0x39 /* 9 */; 107 | } 108 | 109 | function isCharCodeLowercaseAlpha(charChode: number) { 110 | return charChode >= 0x61 /* a */ && charChode <= 0x7A /* z */; 111 | } 112 | 113 | function isCharCodeUppercaseAlpha(charChode: number) { 114 | return charChode >= 0x41 /* A */ && charChode <= 0x5A /* Z */; 115 | } 116 | 117 | const analyzeRoman = (romanNumeralString: string) => { 118 | let parsedRomanNumber: number = 1; 119 | let isValidRoman = true; 120 | try { 121 | parsedRomanNumber = toArabic(romanNumeralString); 122 | } catch (e) { 123 | isValidRoman = false; 124 | } 125 | return { 126 | parsedRomanNumber, 127 | isValidRoman, 128 | }; 129 | }; 130 | 131 | const convertAlphaMarkerToOrdinalNumber = (alphaMarker: string): number => { 132 | const lastLetterValue = alphaMarker.toLowerCase().charCodeAt(alphaMarker.length - 1) - "a".charCodeAt(0) + 1; 133 | if (alphaMarker.length > 1) { 134 | const prefixValue = convertAlphaMarkerToOrdinalNumber(alphaMarker.substring(0, alphaMarker.length - 1)); 135 | return prefixValue * 26 + lastLetterValue; 136 | } else { 137 | return lastLetterValue; 138 | } 139 | }; 140 | 141 | function analyseMarker(state: StateBlock, startLine: number, endLine: number, previousMarker: Marker | null, options: MarkdownItFancyListPluginOptions): Marker | null { 142 | const orderedListMarker = parseOrderedListMarker(state, startLine); 143 | if (orderedListMarker !== null) { 144 | const bulletChar = orderedListMarker.bulletChar; 145 | const charCode = orderedListMarker.bulletChar.charCodeAt(0); 146 | const delimiter = orderedListMarker.delimiter; 147 | 148 | if (isCharCodeDigit(charCode)) { 149 | return { 150 | isOrdered: true, 151 | isRoman: false, 152 | isAlpha: false, 153 | type: "0", 154 | start: Number.parseInt(bulletChar), 155 | ...orderedListMarker, 156 | }; 157 | } else if (isCharCodeLowercaseAlpha(charCode)) { 158 | const isValidAlpha = bulletChar.length === 1 || options.allowMultiLetter === true; 159 | const preferRoman = ((previousMarker !== null && previousMarker.isRoman === true) || ((previousMarker === null || previousMarker.isAlpha === false) && (bulletChar === "i" || bulletChar.length > 1))); 160 | const { parsedRomanNumber, isValidRoman } = analyzeRoman(bulletChar); 161 | 162 | if (isValidRoman === true && (isValidAlpha === false || preferRoman === true)) { 163 | return { 164 | isOrdered: true, 165 | isRoman: true, 166 | isAlpha: false, 167 | type: "i", 168 | start: parsedRomanNumber, 169 | ...orderedListMarker, 170 | }; 171 | } else if (isValidAlpha === true) { 172 | return { 173 | isOrdered: true, 174 | isRoman: false, 175 | isAlpha: true, 176 | type: "a", 177 | start: convertAlphaMarkerToOrdinalNumber(bulletChar), 178 | ...orderedListMarker, 179 | }; 180 | } 181 | return null; 182 | } else if (isCharCodeUppercaseAlpha(charCode)) { 183 | const isValidAlpha = bulletChar.length === 1 || options.allowMultiLetter === true; 184 | const preferRoman = ((previousMarker !== null && previousMarker.isRoman === true) || ((previousMarker === null || previousMarker.isAlpha === false) && (bulletChar === "I" || bulletChar.length > 1))); 185 | const { parsedRomanNumber, isValidRoman } = analyzeRoman(bulletChar); 186 | 187 | if (isValidRoman === true && (isValidAlpha === false || preferRoman === true)) { 188 | return { 189 | isOrdered: true, 190 | isRoman: true, 191 | isAlpha: false, 192 | type: "I", 193 | start: parsedRomanNumber, 194 | ...orderedListMarker, 195 | }; 196 | } else if (isValidAlpha === true) { 197 | return { 198 | isOrdered: true, 199 | isRoman: false, 200 | isAlpha: true, 201 | type: "A", 202 | start: convertAlphaMarkerToOrdinalNumber(bulletChar), 203 | ...orderedListMarker, 204 | }; 205 | } 206 | return null; 207 | } else { 208 | return { 209 | isOrdered: true, 210 | isRoman: false, 211 | isAlpha: false, 212 | type: "#", 213 | start: 1, 214 | ...orderedListMarker, 215 | }; 216 | } 217 | } 218 | const unorderedListMarker = parseUnorderedListMarker(state, startLine); 219 | if (unorderedListMarker !== null) { 220 | const start = state.bMarks[startLine] + state.tShift[startLine]; 221 | return { 222 | isOrdered: false, 223 | isRoman: false, 224 | isAlpha: false, 225 | bulletChar: "", 226 | hasOrdinalIndicator: false, 227 | delimiter: ")", 228 | start: 1, 229 | ...unorderedListMarker, 230 | }; 231 | } else { 232 | return null; 233 | } 234 | } 235 | 236 | type MarkerType = "0" | "a" | "A" | "i" | "I" | "#" | "*" | "-" | "+"; 237 | type Marker = { 238 | isOrdered: boolean; 239 | isRoman: boolean; 240 | isAlpha: boolean; 241 | type: MarkerType; 242 | bulletChar: string; 243 | hasOrdinalIndicator: boolean; 244 | delimiter: ")" | "."; 245 | start: number; 246 | posAfterMarker: number; 247 | } 248 | 249 | function areMarkersCompatible(previousMarker: Marker, currentMarker: Marker) { 250 | return previousMarker.isOrdered === currentMarker.isOrdered 251 | && (previousMarker.type === currentMarker.type || currentMarker.type === "#") 252 | && previousMarker.delimiter === currentMarker.delimiter 253 | && previousMarker.hasOrdinalIndicator === currentMarker.hasOrdinalIndicator; 254 | } 255 | 256 | const createFancyList = (options: MarkdownItFancyListPluginOptions) => { 257 | return (state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean => { 258 | 259 | // if it's indented more than 3 spaces, it should be a code block 260 | if (state.sCount[startLine] - state.blkIndent >= 4) { return false; } 261 | 262 | // Special case: 263 | // - item 1 264 | // - item 2 265 | // - item 3 266 | // - item 4 267 | // - this one is a paragraph continuation 268 | if (state.listIndent >= 0 && 269 | state.sCount[startLine] - state.listIndent >= 4 && 270 | state.sCount[startLine] < state.blkIndent) { 271 | return false; 272 | } 273 | 274 | let isTerminatingParagraph = false; 275 | // limit conditions when list can interrupt 276 | // a paragraph (validation mode only) 277 | if (silent && state.parentType === "paragraph") { 278 | // Next list item should still terminate previous list item; 279 | // 280 | // This code can fail if plugins use blkIndent as well as lists, 281 | // but I hope the spec gets fixed long before that happens. 282 | // 283 | if (state.tShift[startLine] >= state.blkIndent) { 284 | isTerminatingParagraph = true; 285 | } 286 | } 287 | 288 | let marker: Marker | null = analyseMarker(state, startLine, endLine, null, options); 289 | if (marker === null) { 290 | return false; 291 | } 292 | if (marker.hasOrdinalIndicator === true && options.allowOrdinal !== true) { 293 | return false; 294 | } 295 | 296 | // do not allow subsequent numbers to interrupt paragraphs in non-nested lists 297 | const isNestedList = state.listIndent !== -1; 298 | if (isTerminatingParagraph && marker.start !== 1 && isNestedList === false) { 299 | return false; 300 | } 301 | 302 | // If we're starting a new unordered list right after 303 | // a paragraph, first line should not be empty. 304 | if (isTerminatingParagraph) { 305 | if (state.skipSpaces(marker.posAfterMarker) >= state.eMarks[startLine]) return false; 306 | } 307 | 308 | // We should terminate list on style change. Remember first one to compare. 309 | const markerCharCode = state.src.charCodeAt(marker.posAfterMarker - 1); 310 | 311 | // For validation mode we can terminate immediately 312 | if (silent) { return true; } 313 | 314 | // Start list 315 | const listTokIdx = state.tokens.length; 316 | 317 | let token: Token; 318 | if (marker.isOrdered === true) { 319 | token = state.push("ordered_list_open", "ol", 1); 320 | const attrs: [ string, string ][] = []; 321 | if (marker.type !== "0" && marker.type !== "#") { 322 | attrs.push([ "type", marker.type ]); 323 | } 324 | if (marker.start !== 1) { 325 | attrs.push([ "start", marker.start.toString(10) ]); 326 | } 327 | if (marker.hasOrdinalIndicator === true) { 328 | attrs.push([ "class", "ordinal" ]); 329 | } 330 | token.attrs = attrs; 331 | 332 | } else { 333 | token = state.push("bullet_list_open", "ul", 1); 334 | } 335 | 336 | const listLines: [ number, number ] = [ startLine, 0 ]; 337 | token.map = listLines; 338 | token.markup = String.fromCharCode(markerCharCode); 339 | 340 | // 341 | // Iterate list items 342 | // 343 | 344 | let nextLine = startLine; 345 | let prevEmptyEnd = false; 346 | const terminatorRules = state.md.block.ruler.getRules("list"); 347 | 348 | const oldParentType = state.parentType; 349 | state.parentType = "list"; 350 | 351 | let tight = true; 352 | while (nextLine < endLine) { 353 | const nextMarker = analyseMarker(state, nextLine, endLine, marker, options); 354 | if (nextMarker === null || areMarkersCompatible(marker, nextMarker) === false) { 355 | break; 356 | } 357 | let pos: number = nextMarker.posAfterMarker; 358 | const max = state.eMarks[nextLine]; 359 | 360 | const initial = state.sCount[nextLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]); 361 | let offset = initial; 362 | 363 | while (pos < max) { 364 | const ch = state.src.charCodeAt(pos); 365 | 366 | if (ch === 0x09) { 367 | offset += 4 - (offset + state.bsCount[nextLine]) % 4; 368 | } else if (ch === 0x20) { 369 | offset += 1; 370 | } else { 371 | break; 372 | } 373 | 374 | pos += 1; 375 | } 376 | 377 | let contentStart = pos; 378 | 379 | let indentAfterMarker: number; 380 | if (contentStart >= max) { 381 | // trimming space in "- \n 3" case, indent is 1 here 382 | indentAfterMarker = 1; 383 | } else { 384 | indentAfterMarker = offset - initial; 385 | } 386 | 387 | // If we have more than 4 spaces, the indent is 1 388 | // (the rest is just indented code block) 389 | if (indentAfterMarker > 4) { indentAfterMarker = 1; } 390 | 391 | // " - test" 392 | // ^^^^^ - calculating total length of this thing 393 | const indent = initial + indentAfterMarker; 394 | 395 | // Run subparser & write tokens 396 | token = state.push("list_item_open", "li", 1); 397 | token.markup = String.fromCharCode(markerCharCode); 398 | const itemLines = [ startLine, 0 ] as [ number, number ]; 399 | token.map = itemLines; 400 | 401 | // change current state, then restore it after parser subcall 402 | const oldTight = state.tight; 403 | const oldTShift = state.tShift[startLine]; 404 | const oldSCount = state.sCount[startLine]; 405 | 406 | // - example list 407 | // ^ listIndent position will be here 408 | // ^ blkIndent position will be here 409 | // 410 | const oldListIndent = state.listIndent; 411 | state.listIndent = state.blkIndent; 412 | state.blkIndent = indent; 413 | 414 | state.tight = true; 415 | state.tShift[startLine] = contentStart - state.bMarks[startLine]; 416 | state.sCount[startLine] = offset; 417 | 418 | if (contentStart >= max && state.isEmpty(startLine + 1)) { 419 | // workaround for this case 420 | // (list item is empty, list terminates before "foo"): 421 | // ~~~~~~~~ 422 | // - 423 | // 424 | // foo 425 | // ~~~~~~~~ 426 | state.line = Math.min(state.line + 2, endLine); 427 | } else { 428 | state.md.block.tokenize(state, startLine, endLine); 429 | } 430 | 431 | // If any of list item is tight, mark list as tight 432 | if (!state.tight || prevEmptyEnd) { 433 | tight = false; 434 | } 435 | // Item become loose if finish with empty line, 436 | // but we should filter last element, because it means list finish 437 | prevEmptyEnd = (state.line - startLine) > 1 && state.isEmpty(state.line - 1); 438 | 439 | state.blkIndent = state.listIndent; 440 | state.listIndent = oldListIndent; 441 | state.tShift[startLine] = oldTShift; 442 | state.sCount[startLine] = oldSCount; 443 | state.tight = oldTight; 444 | 445 | token = state.push("list_item_close", "li", -1); 446 | token.markup = String.fromCharCode(markerCharCode); 447 | 448 | nextLine = startLine = state.line; 449 | itemLines[1] = nextLine; 450 | contentStart = state.bMarks[startLine]; 451 | 452 | if (nextLine >= endLine) { break; } 453 | 454 | // 455 | // Try to check if list is terminated or continued. 456 | // 457 | if (state.sCount[nextLine] < state.blkIndent) { break; } 458 | 459 | // if it's indented more than 3 spaces, it should be a code block 460 | if (state.sCount[startLine] - state.blkIndent >= 4) { break; } 461 | 462 | // fail if terminating block found 463 | let terminate = false; 464 | for (let i = 0, l = terminatorRules.length; i < l; i += 1) { 465 | if (terminatorRules[i](state, nextLine, endLine, true)) { 466 | terminate = true; 467 | break; 468 | } 469 | } 470 | if (terminate) { break; } 471 | 472 | marker = nextMarker; 473 | } 474 | 475 | // Finalize list 476 | if (marker.isOrdered) { 477 | token = state.push("ordered_list_close", "ol", -1); 478 | } else { 479 | token = state.push("bullet_list_close", "ul", -1); 480 | } 481 | token.markup = String.fromCharCode(markerCharCode); 482 | 483 | listLines[1] = nextLine; 484 | state.line = nextLine; 485 | 486 | state.parentType = oldParentType; 487 | 488 | // mark paragraphs tight if needed 489 | if (tight) { 490 | markTightParagraphs(state, listTokIdx); 491 | } 492 | 493 | return true; 494 | }; 495 | } 496 | 497 | markdownIt.block.ruler.at("list", createFancyList(options ?? {}), { alt: [ "paragraph", "reference", "blockquote" ] }); 498 | }; 499 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import * as MarkdownIt from "markdown-it"; 2 | import * as Token from "markdown-it/lib/token"; 3 | import { markdownItFancyListPlugin, MarkdownItFancyListPluginOptions } from "../src/index"; 4 | import { assert } from "chai"; 5 | import { HtmlDiffer } from "@markedjs/html-differ"; 6 | 7 | 8 | const assertHTML = async (expectedHtml: string, markdown: string, pluginOptions?: MarkdownItFancyListPluginOptions) => { 9 | const markdownConverter = new MarkdownIt("default", { 10 | "typographer": true, 11 | }); 12 | markdownConverter.use(markdownItFancyListPlugin, pluginOptions); 13 | const actualOutput = markdownConverter.render(markdown); 14 | 15 | const htmlDiffer = new HtmlDiffer(); 16 | const isEqual = await htmlDiffer.isEqual(actualOutput, expectedHtml); 17 | assert.isTrue(isEqual, `Expected:\n${expectedHtml}\n\nActual:\n${actualOutput}`); 18 | }; 19 | 20 | const assertTokens = (expectedTokens: Partial[], markdown: string, pluginOptions?: MarkdownItFancyListPluginOptions) => { 21 | const markdownConverter = new MarkdownIt("default", { 22 | "typographer": true, 23 | }); 24 | markdownConverter.use(markdownItFancyListPlugin, pluginOptions); 25 | const actualTokens = markdownConverter.parse(markdown, {}); 26 | 27 | assert.strictEqual(actualTokens.length, expectedTokens.length); 28 | expectedTokens.map((expectedToken, i) => { 29 | const keys = Object.keys(expectedToken); 30 | keys.map((key) => { 31 | assert.strictEqual(actualTokens[i][key], expectedToken[key], `Expected ${key} at token ${i}`); 32 | }); 33 | }); 34 | }; 35 | 36 | 37 | 38 | describe("markdownFancyLists", () => { 39 | it("does not alter ordinary ordered list syntax", async () => { 40 | const markdown = ` 41 | 1. foo 42 | 2. bar 43 | 3) baz 44 | `; 45 | const expectedHtml = ` 46 |
    47 |
  1. foo
  2. 48 |
  3. bar
  4. 49 |
50 |
    51 |
  1. baz
  2. 52 |
53 | `; 54 | await assertHTML(expectedHtml, markdown); 55 | }); 56 | 57 | it("does not not continue the list item if there is no list item content before the newline", async () => { 58 | const markdown = ` 59 | 1. 60 | foo 61 | 62 | 2. 63 | bar 64 | `; 65 | const expectedHtml = ` 66 |
    67 |
  1. 68 |
69 |

foo

70 |
    71 |
  1. 72 |
73 |

bar

74 | `; 75 | await assertHTML(expectedHtml, markdown); 76 | }); 77 | 78 | it("requires a space after list marker", async () => { 79 | const markdown = ` 80 | 1.2 foo 81 | 2.3 bar 82 | `; 83 | const expectedHtml = ` 84 |

85 | 1.2 foo 86 | 2.3 bar 87 |

88 | `; 89 | await assertHTML(expectedHtml, markdown); 90 | }); 91 | 92 | it("supports lowercase alphabetical numbering", async () => { 93 | const markdown = ` 94 | a. foo 95 | b. bar 96 | c. baz 97 | `; 98 | const expectedHtml = ` 99 |
    100 |
  1. foo
  2. 101 |
  3. bar
  4. 102 |
  5. baz
  6. 103 |
104 | `; 105 | await assertHTML(expectedHtml, markdown); 106 | }); 107 | 108 | it("supports offsets for lowercase alphabetical numbering", async () => { 109 | const markdown = ` 110 | b. foo 111 | c. bar 112 | d. baz 113 | `; 114 | const expectedHtml = ` 115 |
    116 |
  1. foo
  2. 117 |
  3. bar
  4. 118 |
  5. baz
  6. 119 |
120 | `; 121 | await assertHTML(expectedHtml, markdown); 122 | }); 123 | 124 | it("supports uppercase alphabetical numbering", async () => { 125 | const markdown = ` 126 | A) foo 127 | B) bar 128 | C) baz 129 | `; 130 | const expectedHtml = ` 131 |
    132 |
  1. foo
  2. 133 |
  3. bar
  4. 134 |
  5. baz
  6. 135 |
136 | `; 137 | await assertHTML(expectedHtml, markdown); 138 | }); 139 | 140 | it("supports offsets for uppercase alphabetical numbering", async () => { 141 | const markdown = ` 142 | B) foo 143 | C) bar 144 | D) baz 145 | `; 146 | const expectedHtml = ` 147 |
    148 |
  1. foo
  2. 149 |
  3. bar
  4. 150 |
  5. baz
  6. 151 |
152 | `; 153 | await assertHTML(expectedHtml, markdown); 154 | }); 155 | 156 | it("test supports lowercase roman numbering", async () => { 157 | const markdown = ` 158 | i. foo 159 | ii. bar 160 | iii. baz 161 | `; 162 | const expectedHtml = ` 163 |
    164 |
  1. foo
  2. 165 |
  3. bar
  4. 166 |
  5. baz
  6. 167 |
168 | `; 169 | await assertHTML(expectedHtml, markdown); 170 | }); 171 | 172 | it("supports offsets for lowercase roman numbering", async () => { 173 | const markdown = ` 174 | iv. foo 175 | v. bar 176 | vi. baz 177 | `; 178 | const expectedHtml = ` 179 |
    180 |
  1. foo
  2. 181 |
  3. bar
  4. 182 |
  5. baz
  6. 183 |
184 | `; 185 | await assertHTML(expectedHtml, markdown); 186 | }); 187 | 188 | it("supports uppercase roman numbering", async () => { 189 | const markdown = ` 190 | I) foo 191 | II) bar 192 | III) baz 193 | `; 194 | const expectedHtml = ` 195 |
    196 |
  1. foo
  2. 197 |
  3. bar
  4. 198 |
  5. baz
  6. 199 |
200 | `; 201 | await assertHTML(expectedHtml, markdown); 202 | }); 203 | 204 | it("supports offsets for uppercase roman numbering", async () => { 205 | const markdown = ` 206 | XII. foo 207 | XIII. bar 208 | XIV. baz 209 | `; 210 | const expectedHtml = ` 211 |
    212 |
  1. foo
  2. 213 |
  3. bar
  4. 214 |
  5. baz
  6. 215 |
216 | `; 217 | await assertHTML(expectedHtml, markdown); 218 | }); 219 | 220 | it("ignores invalid roman numerals as list marker", async () => { 221 | const markdown = ` 222 | VV. foo 223 | VVI. bar 224 | VVII. baz 225 | `; 226 | const expectedHtml = ` 227 |

VV. foo 228 | VVI. bar 229 | VVII. baz

230 | `; 231 | await assertHTML(expectedHtml, markdown); 232 | }); 233 | 234 | it("supports hash as list marker for subsequent items", async () => { 235 | const markdown = ` 236 | 1. foo 237 | #. bar 238 | #. baz 239 | `; 240 | const expectedHtml = ` 241 |
    242 |
  1. foo
  2. 243 |
  3. bar
  4. 244 |
  5. baz
  6. 245 |
246 | `; 247 | await assertHTML(expectedHtml, markdown); 248 | }); 249 | 250 | it("supports hash as list marker for subsequent roman numeric marker", async () => { 251 | const markdown = ` 252 | i. foo 253 | #. bar 254 | #. baz 255 | `; 256 | const expectedHtml = ` 257 |
    258 |
  1. foo
  2. 259 |
  3. bar
  4. 260 |
  5. baz
  6. 261 |
262 | `; 263 | await assertHTML(expectedHtml, markdown); 264 | }); 265 | 266 | it("supports hash as list marker for subsequent alphanumeric marker", async () => { 267 | const markdown = ` 268 | a. foo 269 | #. bar 270 | #. baz 271 | `; 272 | const expectedHtml = ` 273 |
    274 |
  1. foo
  2. 275 |
  3. bar
  4. 276 |
  5. baz
  6. 277 |
278 | `; 279 | await assertHTML(expectedHtml, markdown); 280 | }); 281 | 282 | it("supports hash as list marker for initial item", async () => { 283 | const markdown = ` 284 | #. foo 285 | #. bar 286 | #. baz 287 | `; 288 | const expectedHtml = ` 289 |
    290 |
  1. foo
  2. 291 |
  3. bar
  4. 292 |
  5. baz
  6. 293 |
294 | `; 295 | await assertHTML(expectedHtml, markdown); 296 | }); 297 | 298 | it("allows first numbers to interrupt paragraphs", async () => { 299 | const markdown = ` 300 | I need to buy 301 | a. new shoes 302 | b. a coat 303 | c. a plane ticket 304 | 305 | I also need to buy 306 | i. new shoes 307 | ii. a coat 308 | iii. a plane ticket 309 | `; 310 | const expectedHtml = ` 311 |

I need to buy

312 |
    313 |
  1. new shoes
  2. 314 |
  3. a coat
  4. 315 |
  5. a plane ticket
  6. 316 |
317 |

I also need to buy

318 |
    319 |
  1. new shoes
  2. 320 |
  3. a coat
  4. 321 |
  5. a plane ticket
  6. 322 |
323 | `; 324 | await assertHTML(expectedHtml, markdown); 325 | }); 326 | 327 | it("does not allow subsequent numbers to interrupt paragraphs", async () => { 328 | const markdown = ` 329 | I need to buy 330 | b. new shoes 331 | c. a coat 332 | d. a plane ticket 333 | 334 | I also need to buy 335 | ii. new shoes 336 | iii. a coat 337 | iv. a plane ticket 338 | `; 339 | const expectedHtml = ` 340 |

I need to buy 341 | b. new shoes 342 | c. a coat 343 | d. a plane ticket

344 |

I also need to buy 345 | ii. new shoes 346 | iii. a coat 347 | iv. a plane ticket

348 | `; 349 | await assertHTML(expectedHtml, markdown); 350 | }); 351 | 352 | it("supports nested lists", async () => { 353 | const markdown = ` 354 | 9) Ninth 355 | 10) Tenth 356 | 11) Eleventh 357 | i. subone 358 | ii. subtwo 359 | iii. subthree 360 | `; 361 | const expectedHtml = ` 362 |
    363 |
  1. Ninth
  2. 364 |
  3. Tenth
  4. 365 |
  5. Eleventh 366 |
      367 |
    1. subone
    2. 368 |
    3. subtwo
    4. 369 |
    5. subthree
    6. 370 |
    371 |
  6. 372 |
373 | `; 374 | await assertHTML(expectedHtml, markdown); 375 | }); 376 | 377 | it("supports nested lists with start", async () => { 378 | const markdown = ` 379 | c. charlie 380 | #. delta 381 | iv) subfour 382 | #) subfive 383 | #) subsix 384 | #. echo 385 | `; 386 | const expectedHtml = ` 387 |
    388 |
  1. charlie
  2. 389 |
  3. delta 390 |
      391 |
    1. subfour
    2. 392 |
    3. subfive
    4. 393 |
    5. subsix
    6. 394 |
    395 |
  4. 396 |
  5. echo
  6. 397 |
398 | `; 399 | await assertHTML(expectedHtml, markdown); 400 | }); 401 | 402 | it("supports nested lists with extra newline", async () => { 403 | const markdown = ` 404 | c. charlie 405 | #. delta 406 | 407 | sigma 408 | iv) subfour 409 | #) subfive 410 | #) subsix 411 | #. echo 412 | `; 413 | const expectedHtml = ` 414 |
    415 |
  1. charlie

  2. 416 |
  3. delta

    417 |

    sigma

    418 |
      419 |
    1. subfour
    2. 420 |
    3. subfive
    4. 421 |
    5. subsix
    6. 422 |
    423 |
  4. 424 |
  5. echo

  6. 425 |
426 | `; 427 | await assertHTML(expectedHtml, markdown); 428 | }); 429 | 430 | it("starts a new list when a different type of numbering is used", async () => { 431 | const markdown = ` 432 | 1) First 433 | A) First again 434 | i) Another first 435 | ii) Second 436 | `; 437 | const expectedHtml = ` 438 |
    439 |
  1. First
  2. 440 |
441 |
    442 |
  1. First again
  2. 443 |
444 |
    445 |
  1. Another first
  2. 446 |
  3. Second
  4. 447 |
448 | `; 449 | await assertHTML(expectedHtml, markdown); 450 | }); 451 | 452 | it("starts a new list when a sequence of letters is not a valid roman numeral", async () => { 453 | const markdown = ` 454 | I) First 455 | A) First again 456 | `; 457 | const expectedHtml = ` 458 |
    459 |
  1. First
  2. 460 |
461 |
    462 |
  1. First again
  2. 463 |
464 | `; 465 | await assertHTML(expectedHtml, markdown); 466 | }); 467 | 468 | it("marker is considered to be alphabetical when part of an alphabetical list", async () => { 469 | const markdown = ` 470 | A) First 471 | I) Second 472 | II) First of new list 473 | 474 | a) First 475 | i) Second 476 | ii) First of new list 477 | `; 478 | const expectedHtml = ` 479 |
    480 |
  1. First
  2. 481 |
  3. Second
  4. 482 |
483 |
    484 |
  1. First of new list
  2. 485 |
486 |
    487 |
  1. First
  2. 488 |
  3. Second
  4. 489 |
490 |
    491 |
  1. First of new list
  2. 492 |
493 | `; 494 | await assertHTML(expectedHtml, markdown); 495 | }); 496 | 497 | it("single letter roman numerals other than I are considered alphabetical without context", async () => { 498 | const markdown = ` 499 | v. foo 500 | 501 | X) foo 502 | 503 | l. foo 504 | 505 | C) foo 506 | 507 | d. foo 508 | 509 | M) foo 510 | `; 511 | const expectedHtml = ` 512 |
    513 |
  1. foo
  2. 514 |
515 |
    516 |
  1. foo
  2. 517 |
518 |
    519 |
  1. foo
  2. 520 |
521 |
    522 |
  1. foo
  2. 523 |
524 |
    525 |
  1. foo
  2. 526 |
527 |
    528 |
  1. foo
  2. 529 |
530 | `; 531 | await assertHTML(expectedHtml, markdown); 532 | }); 533 | 534 | it("requires two spaces after a capital letter and a period", async () => { 535 | const markdown = ` 536 | B. Russell was an English philosopher. 537 | 538 | I. Elba is an English actor. 539 | 540 | I. foo 541 | II. bar 542 | 543 | B. foo 544 | C. bar 545 | `; 546 | const expectedHtml = ` 547 |

B. Russell was an English philosopher.

548 |

I. Elba is an English actor.

549 |
    550 |
  1. foo
  2. 551 |
  3. bar
  4. 552 |
553 |
    554 |
  1. foo
  2. 555 |
  3. bar
  4. 556 |
557 | `; 558 | await assertHTML(expectedHtml, markdown); 559 | }); 560 | 561 | describe("support for ordinal indicator", () => { 562 | it("does not support an ordinal indicator by default", async () => { 563 | const markdown = ` 564 | 1º. foo 565 | 2º. bar 566 | 3º. baz 567 | `; 568 | const expectedHtml = ` 569 |

1º. foo 570 | 2º. bar 571 | 3º. baz

572 | `; 573 | await assertHTML(expectedHtml, markdown); 574 | }); 575 | 576 | it("supports an ordinal indicator if enabled in options", async () => { 577 | const markdown = ` 578 | 1º. foo 579 | 2º. bar 580 | 3º. baz 581 | `; 582 | const expectedHtml = ` 583 |
    584 |
  1. foo
  2. 585 |
  3. bar
  4. 586 |
  5. baz
  6. 587 |
588 | `; 589 | await assertHTML(expectedHtml, markdown, { 590 | allowOrdinal: true, 591 | }); 592 | }); 593 | 594 | it("allows ordinal indicators with Roman numerals", async () => { 595 | const markdown = ` 596 | IIº. foo 597 | IIIº. bar 598 | IVº. baz 599 | `; 600 | const expectedHtml = ` 601 |
    602 |
  1. foo
  2. 603 |
  3. bar
  4. 604 |
  5. baz
  6. 605 |
606 | `; 607 | await assertHTML(expectedHtml, markdown, { 608 | allowOrdinal: true, 609 | }); 610 | }); 611 | 612 | it("starts a new list when ordinal indicators are introduced or omitted", async () => { 613 | const markdown = ` 614 | 1) First 615 | 1º) First again 616 | 2º) Second 617 | 1) Another first 618 | `; 619 | const expectedHtml = ` 620 |
    621 |
  1. First
  2. 622 |
623 |
    624 |
  1. First again
  2. 625 |
  3. Second
  4. 626 |
627 |
    628 |
  1. Another first
  2. 629 |
630 | `; 631 | await assertHTML(expectedHtml, markdown, { 632 | allowOrdinal: true, 633 | }); 634 | }); 635 | 636 | it("tolerates characters commonly mistaken for ordinal indicators", async () => { 637 | const markdown = ` 638 | 1°. degree sign 639 | 2˚. ring above 640 | 3ᵒ. modifier letter small o 641 | 4º. ordinal indicator 642 | `; 643 | const expectedHtml = ` 644 |
    645 |
  1. degree sign
  2. 646 |
  3. ring above
  4. 647 |
  5. modifier letter small o
  6. 648 |
  7. ordinal indicator
  8. 649 |
650 | `; 651 | await assertHTML(expectedHtml, markdown, { 652 | allowOrdinal: true, 653 | }); 654 | }); 655 | 656 | it("produces correct markup character regression for issue#4", () => { 657 | const markdown = ` 658 | ### title 659 | 660 | a. first item 661 | #. second item 662 | 663 | 1) first item 664 | 2) second item 665 | 666 | 1°. degree sign 667 | 2˚. ring above 668 | 3ᵒ. modifier letter small o 669 | 4º. ordinal indicator 670 | `; 671 | assertTokens([ 672 | { type: "heading_open" }, 673 | { type: "inline", content: "title" }, 674 | { type: "heading_close" }, 675 | { type: "ordered_list_open" }, 676 | { type: "list_item_open", markup: "." }, 677 | { type: "paragraph_open" }, 678 | { type: "inline", content: "first item" }, 679 | { type: "paragraph_close" }, 680 | { type: "list_item_close" }, 681 | { type: "list_item_open", markup: "." }, 682 | { type: "paragraph_open" }, 683 | { type: "inline", content: "second item" }, 684 | { type: "paragraph_close" }, 685 | { type: "list_item_close" }, 686 | { type: "ordered_list_close" }, 687 | { type: "ordered_list_open" }, 688 | { type: "list_item_open", markup: ")" }, 689 | { type: "paragraph_open" }, 690 | { type: "inline", content: "first item" }, 691 | { type: "paragraph_close" }, 692 | { type: "list_item_close" }, 693 | { type: "list_item_open", markup: ")" }, 694 | { type: "paragraph_open" }, 695 | { type: "inline", content: "second item" }, 696 | { type: "paragraph_close" }, 697 | { type: "list_item_close" }, 698 | { type: "ordered_list_close" }, 699 | { type: "ordered_list_open" }, 700 | { type: "list_item_open", markup: "." }, 701 | { type: "paragraph_open" }, 702 | { type: "inline", content: "degree sign" }, 703 | { type: "paragraph_close" }, 704 | { type: "list_item_close" }, 705 | { type: "list_item_open", markup: "." }, 706 | { type: "paragraph_open" }, 707 | { type: "inline", content: "ring above" }, 708 | { type: "paragraph_close" }, 709 | { type: "list_item_close" }, 710 | { type: "list_item_open", markup: "." }, 711 | { type: "paragraph_open" }, 712 | { type: "inline", content: "modifier letter small o" }, 713 | { type: "paragraph_close" }, 714 | { type: "list_item_close" }, 715 | { type: "list_item_open", markup: "." }, 716 | { type: "paragraph_open" }, 717 | { type: "inline", content: "ordinal indicator" }, 718 | { type: "paragraph_close" }, 719 | { type: "list_item_close" }, 720 | { type: "ordered_list_close" } 721 | ], markdown, { 722 | allowOrdinal: true, 723 | }); 724 | }); 725 | }); 726 | 727 | describe("support for multi-letter list markers", () => { 728 | it("does not support multi-letter list markers by default", async () => { 729 | const markdown = ` 730 | AA) foo 731 | AB) bar 732 | AC) baz 733 | `; 734 | const expectedHtml = ` 735 |

AA) foo 736 | AB) bar 737 | AC) baz

738 | `; 739 | await assertHTML(expectedHtml, markdown); 740 | }); 741 | 742 | it("supports multi-letter list markers if enabled in options", async () => { 743 | const markdown = ` 744 | AA) foo 745 | AB) bar 746 | AC) baz 747 | `; 748 | const expectedHtml = ` 749 |
    750 |
  1. foo
  2. 751 |
  3. bar
  4. 752 |
  5. baz
  6. 753 |
754 | `; 755 | await assertHTML(expectedHtml, markdown, { 756 | allowMultiLetter: true, 757 | }); 758 | }); 759 | 760 | it("supports continuing a single-letter list with multi-letter list markers", async () => { 761 | const markdown = ` 762 | Z) foo 763 | AA) bar 764 | AB) baz 765 | `; 766 | const expectedHtml = ` 767 |
    768 |
  1. foo
  2. 769 |
  3. bar
  4. 770 |
  5. baz
  6. 771 |
772 | `; 773 | await assertHTML(expectedHtml, markdown, { 774 | allowMultiLetter: true, 775 | }); 776 | }); 777 | 778 | it("supports lowercase multi-letter list markers", async () => { 779 | const markdown = ` 780 | aa) foo 781 | ab) bar 782 | ac) baz 783 | `; 784 | const expectedHtml = ` 785 |
    786 |
  1. foo
  2. 787 |
  3. bar
  4. 788 |
  5. baz
  6. 789 |
790 | `; 791 | await assertHTML(expectedHtml, markdown, { 792 | allowMultiLetter: true, 793 | }); 794 | }); 795 | 796 | it("allows at most 3 characters for multi-letter list markers", async () => { 797 | const markdown = ` 798 | AAAA) foo 799 | AAAB) bar 800 | AAAC) baz 801 | `; 802 | const expectedHtml = ` 803 |

AAAA) foo 804 | AAAB) bar 805 | AAAC) baz

806 | `; 807 | await assertHTML(expectedHtml, markdown, { 808 | allowMultiLetter: true, 809 | }); 810 | }); 811 | 812 | it("does not support mixing uppercase and lowercase letters", async () => { 813 | const markdown = ` 814 | Aa) foo 815 | Ab) bar 816 | Ac) baz 817 | `; 818 | const expectedHtml = ` 819 |

Aa) foo 820 | Ab) bar 821 | Ac) baz

822 | `; 823 | await assertHTML(expectedHtml, markdown, { 824 | allowMultiLetter: true, 825 | }); 826 | }); 827 | 828 | it("prefers roman numerals over multi-letter alphabetic numerals", async () => { 829 | const markdown = ` 830 | II) foo 831 | III) bar 832 | IV) baz 833 | `; 834 | const expectedHtml = ` 835 |
    836 |
  1. foo
  2. 837 |
  3. bar
  4. 838 |
  5. baz
  6. 839 |
840 | `; 841 | await assertHTML(expectedHtml, markdown, { 842 | allowMultiLetter: true, 843 | }); 844 | }); 845 | 846 | it("prefers multi-letter alphabetic numerals over roman numerals when already in an alphabetic list", async () => { 847 | const markdown = ` 848 | IH) foo 849 | II) bar 850 | IJ) baz 851 | `; 852 | const expectedHtml = ` 853 |
    854 |
  1. foo
  2. 855 |
  3. bar
  4. 856 |
  5. baz
  6. 857 |
858 | `; 859 | await assertHTML(expectedHtml, markdown, { 860 | allowMultiLetter: true, 861 | }); 862 | }); 863 | }); 864 | }); 865 | --------------------------------------------------------------------------------