├── .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 | [](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 | - foo
62 | - bar
63 | - baz
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 | - foo
143 | - bar
144 | - baz
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 | - foo
163 | - bar
164 | - baz
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 | - foo
48 | - bar
49 |
50 |
51 | - baz
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 |
68 |
69 | foo
70 |
71 |
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 | - foo
101 | - bar
102 | - baz
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 | - foo
117 | - bar
118 | - baz
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 | - foo
133 | - bar
134 | - baz
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 | - foo
149 | - bar
150 | - baz
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 | - foo
165 | - bar
166 | - baz
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 | - foo
181 | - bar
182 | - baz
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 | - foo
197 | - bar
198 | - baz
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 | - foo
213 | - bar
214 | - baz
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 | - foo
243 | - bar
244 | - baz
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 | - foo
259 | - bar
260 | - baz
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 | - foo
275 | - bar
276 | - baz
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 | - foo
291 | - bar
292 | - baz
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 | - new shoes
314 | - a coat
315 | - a plane ticket
316 |
317 | I also need to buy
318 |
319 | - new shoes
320 | - a coat
321 | - a plane ticket
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 | - Ninth
364 | - Tenth
365 | - Eleventh
366 |
367 | - subone
368 | - subtwo
369 | - subthree
370 |
371 |
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 | - charlie
389 | - delta
390 |
391 | - subfour
392 | - subfive
393 | - subsix
394 |
395 |
396 | - echo
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 | charlie
416 | delta
417 | sigma
418 |
419 | - subfour
420 | - subfive
421 | - subsix
422 |
423 |
424 | echo
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 | - First
440 |
441 |
442 | - First again
443 |
444 |
445 | - Another first
446 | - Second
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 | - First
460 |
461 |
462 | - First again
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 | - First
481 | - Second
482 |
483 |
484 | - First of new list
485 |
486 |
487 | - First
488 | - Second
489 |
490 |
491 | - First of new list
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 | - foo
514 |
515 |
516 | - foo
517 |
518 |
519 | - foo
520 |
521 |
522 | - foo
523 |
524 |
525 | - foo
526 |
527 |
528 | - foo
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 | - foo
551 | - bar
552 |
553 |
554 | - foo
555 | - bar
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 | - foo
585 | - bar
586 | - baz
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 | - foo
603 | - bar
604 | - baz
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 | - First
622 |
623 |
624 | - First again
625 | - Second
626 |
627 |
628 | - Another first
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 | - degree sign
646 | - ring above
647 | - modifier letter small o
648 | - ordinal indicator
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 | - foo
751 | - bar
752 | - baz
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 | - foo
769 | - bar
770 | - baz
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 | - foo
787 | - bar
788 | - baz
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 | - foo
837 | - bar
838 | - baz
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 | - foo
855 | - bar
856 | - baz
857 |
858 | `;
859 | await assertHTML(expectedHtml, markdown, {
860 | allowMultiLetter: true,
861 | });
862 | });
863 | });
864 | });
865 |
--------------------------------------------------------------------------------