├── .gitignore ├── test ├── cjs.js ├── fixtures │ ├── footnote-prefixed.txt │ └── footnote.txt └── test.mjs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .eslintrc.yml ├── CHANGELOG.md ├── LICENSE ├── package.json ├── rollup.config.mjs ├── README.md └── index.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /test/cjs.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* eslint-env mocha */ 3 | 4 | const assert = require('node:assert') 5 | const fn = require('../') 6 | 7 | describe('CJS', () => { 8 | it('require', () => { 9 | assert.ok(typeof fn === 'function') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | 8 | - package-ecosystem: npm 9 | directory: / 10 | schedule: 11 | interval: daily 12 | allow: 13 | - dependency-type: production 14 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: standard 2 | 3 | overrides: 4 | - 5 | files: [ '*.mjs' ] 6 | rules: 7 | no-restricted-globals: [ 2, require, __dirname ] 8 | - 9 | files: [ 'test/**' ] 10 | env: { mocha: true } 11 | - 12 | files: [ 'lib/**', 'index.mjs' ] 13 | parserOptions: { ecmaVersion: 2015 } 14 | 15 | ignorePatterns: 16 | - demo/ 17 | - dist/ 18 | - benchmark/extra/ 19 | 20 | rules: 21 | camelcase: 0 22 | no-multi-spaces: 0 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * 3' 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [ '18' ] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - run: npm install 27 | 28 | - name: Test 29 | run: npm test 30 | 31 | - name: Upload coverage report to coveralls.io 32 | uses: coverallsapp/github-action@master 33 | with: 34 | github-token: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 4.0.0 / 2023-12-06 2 | ------------------ 3 | 4 | - Rewrite to ESM. 5 | - Remove `dist/` from repo (build on package publish). 6 | 7 | 8 | 3.0.3 / 2021-05-20 9 | ------------------ 10 | 11 | - Fix crash on nested footnotes, #42. 12 | 13 | 14 | 3.0.2 / 2019-07-09 15 | ------------------ 16 | 17 | - Fix URLs in inline footnotes, #33. 18 | 19 | 20 | 3.0.1 / 2016-08-05 21 | ------------------ 22 | 23 | - Fix anchor links in duplicate footnotes, #13. 24 | 25 | 26 | 3.0.0 / 2016-06-28 27 | ------------------ 28 | 29 | - Add `env.docId` support to guarantee unique ancors for differenet docements. 30 | - Add overridable helpers: `md.renderer.rules.footnote_anchor_name` 31 | & `md.renderer.rules.footnote_caption` to simplify templating. 32 | - Fixed anchor symbol display on iOS, #11. 33 | 34 | 35 | 2.0.0 / 2015-10-05 36 | ------------------ 37 | 38 | - Markdown-it 5.0.0 support. Use 1.x version for 4.x. 39 | 40 | 41 | 1.0.0 / 2015-03-12 42 | ------------------ 43 | 44 | - Markdown-it 4.0.0 support. Use previous version for 2.x-3.x. 45 | 46 | 47 | 0.1.0 / 2015-01-04 48 | ------------------ 49 | 50 | - First release. 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2015 Vitaly Puzrin, Alex Kocharin. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-it-footnote", 3 | "version": "4.0.0", 4 | "description": "Footnotes for markdown-it markdown parser.", 5 | "keywords": [ 6 | "markdown-it-plugin", 7 | "markdown-it", 8 | "markdown", 9 | "footnotes" 10 | ], 11 | "repository": "markdown-it/markdown-it-footnote", 12 | "license": "MIT", 13 | "main": "dist/index.cjs.js", 14 | "module": "index.mjs", 15 | "exports": { 16 | ".": { 17 | "require": "./dist/index.cjs.js", 18 | "import": "./index.mjs" 19 | }, 20 | "./*": { 21 | "require": "./*", 22 | "import": "./*" 23 | } 24 | }, 25 | "files": [ 26 | "index.mjs", 27 | "lib/", 28 | "dist/" 29 | ], 30 | "scripts": { 31 | "lint": "eslint .", 32 | "build": "rollup -c", 33 | "test": "npm run lint && npm run build && c8 --exclude dist --exclude test -r text -r html -r lcov mocha", 34 | "prepublishOnly": "npm run lint && npm run build" 35 | }, 36 | "devDependencies": { 37 | "@rollup/plugin-babel": "^6.0.4", 38 | "@rollup/plugin-node-resolve": "^15.2.3", 39 | "@rollup/plugin-terser": "^0.4.4", 40 | "c8": "^8.0.1", 41 | "eslint": "^8.55.0", 42 | "eslint-config-standard": "^17.1.0", 43 | "markdown-it": "^13.0.2", 44 | "markdown-it-testgen": "^0.1.6", 45 | "mocha": "^10.2.0", 46 | "rollup": "^4.6.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/fixtures/footnote-prefixed.txt: -------------------------------------------------------------------------------- 1 | . 2 | Here is a footnote reference,[^1] and another.[^longnote] 3 | 4 | [^1]: Here is the footnote. 5 | 6 | [^longnote]: Here's one with multiple blocks. 7 | 8 | Subsequent paragraphs are indented to show that they 9 | belong to the previous footnote. 10 | 11 | { some.code } 12 | 13 | The whole paragraph can be indented, or just the first 14 | line. In this way, multi-paragraph footnotes work like 15 | multi-paragraph list items. 16 | 17 | This paragraph won't be part of the note, because it 18 | isn't indented. 19 | . 20 |

Here is a footnote reference,[1] and another.[2]

21 |

This paragraph won't be part of the note, because it 22 | isn't indented.

23 |
24 |
25 |
    26 |
  1. Here is the footnote.

    27 |
  2. 28 |
  3. Here's one with multiple blocks.

    29 |

    Subsequent paragraphs are indented to show that they 30 | belong to the previous footnote.

    31 |
    { some.code }
    32 | 
    33 |

    The whole paragraph can be indented, or just the first 34 | line. In this way, multi-paragraph footnotes work like 35 | multi-paragraph list items.

    36 |
  4. 37 |
38 |
39 | . 40 | -------------------------------------------------------------------------------- /test/test.mjs: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import path from 'node:path' 3 | import assert from 'node:assert' 4 | import markdownit from 'markdown-it' 5 | import testgen from 'markdown-it-testgen' 6 | 7 | import footnote from '../index.mjs' 8 | 9 | // Most of the rest of this is inlined from generate(), but modified 10 | // so we can pass in an `env` object 11 | function generate (fixturePath, md, env) { 12 | testgen.load(fixturePath, {}, function (data) { 13 | data.meta = data.meta || {} 14 | 15 | const desc = data.meta.desc || path.relative(fixturePath, data.file); 16 | 17 | (data.meta.skip ? describe.skip : describe)(desc, function () { 18 | data.fixtures.forEach(function (fixture) { 19 | it('line ' + (fixture.first.range[0] - 1), function () { 20 | // add variant character after "↩", so we don't have to worry about 21 | // invisible characters in tests 22 | assert.strictEqual( 23 | md.render(fixture.first.text, Object.assign({}, env || {})), 24 | fixture.second.text.replace(/\u21a9(?!\ufe0e)/g, '\u21a9\ufe0e') 25 | ) 26 | }) 27 | }) 28 | }) 29 | }) 30 | } 31 | 32 | describe('footnote.txt', function () { 33 | const md = markdownit({ linkify: true }).use(footnote) 34 | 35 | // Check that defaults work correctly 36 | generate(fileURLToPath(new URL('fixtures/footnote.txt', import.meta.url)), md) 37 | }) 38 | 39 | describe('custom docId in env', function () { 40 | const md = markdownit().use(footnote) 41 | 42 | // Now check that using `env.documentId` works to prefix IDs 43 | generate(fileURLToPath(new URL('fixtures/footnote-prefixed.txt', import.meta.url)), md, { docId: 'test-doc-id' }) 44 | }) 45 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import terser from '@rollup/plugin-terser' 3 | import { babel } from '@rollup/plugin-babel' 4 | import { readFileSync } from 'fs' 5 | 6 | const pkg = JSON.parse(readFileSync(new URL('package.json', import.meta.url))) 7 | 8 | function globalName (name) { 9 | const parts = name.split('-') 10 | for (let i = 2; i < parts.length; i++) { 11 | parts[i] = parts[i][0].toUpperCase() + parts[i].slice(1) 12 | } 13 | return parts.join('') 14 | } 15 | 16 | const config_umd_full = { 17 | input: 'index.mjs', 18 | output: [ 19 | { 20 | file: `dist/${pkg.name}.js`, 21 | format: 'umd', 22 | name: globalName(pkg.name), 23 | plugins: [ 24 | // Here terser is used only to force ascii output 25 | terser({ 26 | mangle: false, 27 | compress: false, 28 | format: { comments: 'all', beautify: true, ascii_only: true, indent_level: 2 } 29 | }) 30 | ] 31 | }, 32 | { 33 | file: `dist/${pkg.name}.min.js`, 34 | format: 'umd', 35 | name: globalName(pkg.name), 36 | plugins: [ 37 | terser({ 38 | format: { ascii_only: true } 39 | }) 40 | ] 41 | } 42 | ], 43 | plugins: [ 44 | resolve(), 45 | babel({ babelHelpers: 'bundled' }), 46 | { 47 | banner () { 48 | return `/*! ${pkg.name} ${pkg.version} https://github.com/${pkg.repository} @license ${pkg.license} */` 49 | } 50 | } 51 | ] 52 | } 53 | 54 | const config_cjs_no_deps = { 55 | input: 'index.mjs', 56 | output: { 57 | file: 'dist/index.cjs.js', 58 | format: 'cjs' 59 | }, 60 | external: Object.keys(pkg.dependencies || {}), 61 | plugins: [ 62 | resolve(), 63 | babel({ babelHelpers: 'bundled' }) 64 | ] 65 | } 66 | 67 | let config = [ 68 | config_umd_full, 69 | config_cjs_no_deps 70 | ] 71 | 72 | if (process.env.CJS_ONLY) config = [config_cjs_no_deps] 73 | 74 | export default config 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markdown-it-footnote 2 | 3 | [![CI](https://github.com/markdown-it/markdown-it-footnote/actions/workflows/ci.yml/badge.svg)](https://github.com/markdown-it/markdown-it-footnote/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/markdown-it-footnote.svg?style=flat)](https://www.npmjs.org/package/markdown-it-footnote) 5 | [![Coverage Status](https://img.shields.io/coveralls/markdown-it/markdown-it-footnote/master.svg?style=flat)](https://coveralls.io/r/markdown-it/markdown-it-footnote?branch=master) 6 | 7 | > Footnotes plugin for [markdown-it](https://github.com/markdown-it/markdown-it) markdown parser. 8 | 9 | __v2.+ requires `markdown-it` v5.+, see changelog.__ 10 | 11 | Markup is based on [pandoc](http://johnmacfarlane.net/pandoc/README.html#footnotes) definition. 12 | 13 | __Normal footnote__: 14 | 15 | ``` 16 | Here is a footnote reference,[^1] and another.[^longnote] 17 | 18 | [^1]: Here is the footnote. 19 | 20 | [^longnote]: Here's one with multiple blocks. 21 | 22 | Subsequent paragraphs are indented to show that they 23 | belong to the previous footnote. 24 | ``` 25 | 26 | html: 27 | 28 | ```html 29 |

Here is a footnote reference,[1] and another.[2]

30 |

This paragraph won’t be part of the note, because it 31 | isn’t indented.

32 |
33 |
34 |
    35 |
  1. Here is the footnote.

    36 |
  2. 37 |
  3. Here’s one with multiple blocks.

    38 |

    Subsequent paragraphs are indented to show that they 39 | belong to the previous footnote.

    40 |
  4. 41 |
42 |
43 | ``` 44 | 45 | __Inline footnote__: 46 | 47 | ``` 48 | Here is an inline note.^[Inlines notes are easier to write, since 49 | you don't have to pick an identifier and move down to type the 50 | note.] 51 | ``` 52 | 53 | html: 54 | 55 | ```html 56 |

Here is an inline note.[1]

57 |
58 |
59 |
    60 |
  1. Inlines notes are easier to write, since 61 | you don’t have to pick an identifier and move down to type the 62 | note.

    63 |
  2. 64 |
65 |
66 | ``` 67 | 68 | 69 | ## Install 70 | 71 | node.js, browser: 72 | 73 | ```bash 74 | npm install markdown-it-footnote --save 75 | bower install markdown-it-footnote --save 76 | ``` 77 | 78 | ## Use 79 | 80 | ```js 81 | var md = require('markdown-it')() 82 | .use(require('markdown-it-footnote')); 83 | 84 | md.render(/*...*/) // See examples above 85 | ``` 86 | 87 | _Differences in browser._ If you load script directly into the page, without 88 | package system, module will add itself globally as `window.markdownitFootnote`. 89 | 90 | 91 | ### Customize 92 | 93 | If you want to customize the output, you'll need to replace the template 94 | functions. To see which templates exist and their default implementations, 95 | look in [`index.js`](index.js). The API of these template functions is out of 96 | scope for this plugin's documentation; you can read more about it [in the 97 | markdown-it 98 | documentation](https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer). 99 | 100 | To demonstrate with an example, here is how you might replace the `
` that 101 | this plugin emits by default with an `

` emitted by your own template 102 | function override: 103 | 104 | ```js 105 | const md = require('markdown-it')().use(require('markdown-it-footnote')); 106 | 107 | md.renderer.rules.footnote_block_open = () => ( 108 | '

Footnotes

\n' + 109 | '
\n' + 110 | '
    \n' 111 | ); 112 | ``` 113 | 114 | Here's another example that customizes footnotes for epub books: 115 | 116 | ```js 117 | const backrefLabel = 'back to text'; 118 | 119 | const epubRules = { 120 | footnote_ref: [' { 126 | let defaultRender = md.renderer.rules[rule]; 127 | md.renderer.rules[rule] = (tokens, idx, options, env, self) => { 128 | return defaultRender(tokens, idx, options, env, self).replace(...epubRules[rule]); 129 | } 130 | }) 131 | ``` 132 | 133 | ## License 134 | 135 | [MIT](https://github.com/markdown-it/markdown-it-footnote/blob/master/LICENSE) 136 | -------------------------------------------------------------------------------- /test/fixtures/footnote.txt: -------------------------------------------------------------------------------- 1 | 2 | Pandoc example: 3 | . 4 | Here is a footnote reference,[^1] and another.[^longnote] 5 | 6 | [^1]: Here is the footnote. 7 | 8 | [^longnote]: Here's one with multiple blocks. 9 | 10 | Subsequent paragraphs are indented to show that they 11 | belong to the previous footnote. 12 | 13 | { some.code } 14 | 15 | The whole paragraph can be indented, or just the first 16 | line. In this way, multi-paragraph footnotes work like 17 | multi-paragraph list items. 18 | 19 | This paragraph won't be part of the note, because it 20 | isn't indented. 21 | . 22 |

    Here is a footnote reference,[1] and another.[2]

    23 |

    This paragraph won't be part of the note, because it 24 | isn't indented.

    25 |
    26 |
    27 |
      28 |
    1. Here is the footnote.

      29 |
    2. 30 |
    3. Here's one with multiple blocks.

      31 |

      Subsequent paragraphs are indented to show that they 32 | belong to the previous footnote.

      33 |
      { some.code }
       34 | 
      35 |

      The whole paragraph can be indented, or just the first 36 | line. In this way, multi-paragraph footnotes work like 37 | multi-paragraph list items.

      38 |
    4. 39 |
    40 |
    41 | . 42 | 43 | 44 | 45 | They could terminate each other: 46 | 47 | . 48 | [^1][^2][^3] 49 | 50 | [^1]: foo 51 | [^2]: bar 52 | [^3]: baz 53 | . 54 |

    [1][2][3]

    55 |
    56 |
    57 |
      58 |
    1. foo

      59 |
    2. 60 |
    3. bar

      61 |
    4. 62 |
    5. baz

      63 |
    6. 64 |
    65 |
    66 | . 67 | 68 | 69 | They could be inside blockquotes, and are lazy: 70 | . 71 | [^foo] 72 | 73 | > [^foo]: bar 74 | baz 75 | . 76 |

    [1]

    77 |
    78 |
    79 |
    80 |
      81 |
    1. bar 82 | baz

      83 |
    2. 84 |
    85 |
    86 | . 87 | 88 | 89 | Their labels could not contain spaces or newlines: 90 | 91 | . 92 | [^ foo]: bar baz 93 | 94 | [^foo 95 | ]: bar baz 96 | . 97 |

    [^ foo]: bar baz

    98 |

    [^foo 99 | ]: bar baz

    100 | . 101 | 102 | 103 | We support inline notes too (pandoc example): 104 | 105 | . 106 | Here is an inline note.^[Inlines notes are easier to write, since 107 | you don't have to pick an identifier and move down to type the 108 | note.] 109 | . 110 |

    Here is an inline note.[1]

    111 |
    112 |
    113 |
      114 |
    1. Inlines notes are easier to write, since 115 | you don't have to pick an identifier and move down to type the 116 | note.

      117 |
    2. 118 |
    119 |
    120 | . 121 | 122 | 123 | They could have arbitrary markup: 124 | 125 | . 126 | foo^[ *bar* ] 127 | . 128 |

    foo[1]

    129 |
    130 |
    131 |
      132 |
    1. bar

      133 |
    2. 134 |
    135 |
    136 | . 137 | 138 | 139 | Duplicate footnotes: 140 | . 141 | [^xxxxx] [^xxxxx] 142 | 143 | [^xxxxx]: foo 144 | . 145 |

    [1] [1:1]

    146 |
    147 |
    148 |
      149 |
    1. foo ↩︎ ↩︎

      150 |
    2. 151 |
    152 |
    153 | . 154 | 155 | 156 | Indents: 157 | 158 | . 159 | [^xxxxx] [^yyyyy] 160 | 161 | [^xxxxx]: foo 162 | --- 163 | 164 | [^yyyyy]: foo 165 | --- 166 | . 167 |

    [1] [2]

    168 |
    169 |
    170 |
    171 |
      172 |
    1. foo

      173 |
    2. 174 |
    3. foo

      175 |
    4. 176 |
    177 |
    178 | . 179 | 180 | 181 | Indents for the first line: 182 | 183 | . 184 | [^xxxxx] [^yyyyy] 185 | 186 | [^xxxxx]: foo 187 | 188 | [^yyyyy]: foo 189 | . 190 |

    [1] [2]

    191 |
    192 |
    193 |
      194 |
    1. foo

      195 |
    2. 196 |
    3. foo
      197 | 
      198 |
    4. 199 |
    200 |
    201 | . 202 | 203 | Indents for the first line (tabs): 204 | . 205 | [^xxxxx] 206 | 207 | [^xxxxx]: foo 208 | . 209 |

    [1]

    210 |
    211 |
    212 |
      213 |
    1. foo

      214 |
    2. 215 |
    216 |
    217 | . 218 | 219 | 220 | Security 1 221 | . 222 | [^__proto__] 223 | 224 | [^__proto__]: blah 225 | . 226 |

    [1]

    227 |
    228 |
    229 |
      230 |
    1. blah

      231 |
    2. 232 |
    233 |
    234 | . 235 | 236 | 237 | Security 2 238 | . 239 | [^hasOwnProperty] 240 | 241 | [^hasOwnProperty]: blah 242 | . 243 |

    [1]

    244 |
    245 |
    246 |
      247 |
    1. blah

      248 |
    2. 249 |
    250 |
    251 | . 252 | 253 | 254 | Should allow links in inline footnotes 255 | . 256 | Example^[this is another example https://github.com] 257 | . 258 |

    Example[1]

    259 |
    260 |
    261 |
      262 |
    1. this is another example https://github.com ↩︎

      263 |
    2. 264 |
    265 |
    266 | . 267 | 268 | 269 | Regression test for #42 270 | . 271 | foo[^1] bar[^2]. 272 | 273 | [^1]:[^2]: baz 274 | . 275 |

    foo[1] bar[2].

    276 |
    277 |
    278 |
      279 |
    1. ↩︎
    2. 280 |
    3. baz ↩︎

      281 |
    4. 282 |
    283 |
    284 | . 285 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | // Process footnotes 2 | // 3 | 'use strict' 4 | 5 | /// ///////////////////////////////////////////////////////////////////////////// 6 | // Renderer partials 7 | 8 | function render_footnote_anchor_name (tokens, idx, options, env/*, slf */) { 9 | const n = Number(tokens[idx].meta.id + 1).toString() 10 | let prefix = '' 11 | 12 | if (typeof env.docId === 'string') prefix = `-${env.docId}-` 13 | 14 | return prefix + n 15 | } 16 | 17 | function render_footnote_caption (tokens, idx/*, options, env, slf */) { 18 | let n = Number(tokens[idx].meta.id + 1).toString() 19 | 20 | if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}` 21 | 22 | return `[${n}]` 23 | } 24 | 25 | function render_footnote_ref (tokens, idx, options, env, slf) { 26 | const id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf) 27 | const caption = slf.rules.footnote_caption(tokens, idx, options, env, slf) 28 | let refid = id 29 | 30 | if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}` 31 | 32 | return `${caption}` 33 | } 34 | 35 | function render_footnote_block_open (tokens, idx, options) { 36 | return (options.xhtmlOut ? '
    \n' : '
    \n') + 37 | '
    \n' + 38 | '
      \n' 39 | } 40 | 41 | function render_footnote_block_close () { 42 | return '
    \n
    \n' 43 | } 44 | 45 | function render_footnote_open (tokens, idx, options, env, slf) { 46 | let id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf) 47 | 48 | if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}` 49 | 50 | return `
  1. ` 51 | } 52 | 53 | function render_footnote_close () { 54 | return '
  2. \n' 55 | } 56 | 57 | function render_footnote_anchor (tokens, idx, options, env, slf) { 58 | let id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf) 59 | 60 | if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}` 61 | 62 | /* ↩ with escape code to prevent display as Apple Emoji on iOS */ 63 | return ` \u21a9\uFE0E` 64 | } 65 | 66 | export default function footnote_plugin (md) { 67 | const parseLinkLabel = md.helpers.parseLinkLabel 68 | const isSpace = md.utils.isSpace 69 | 70 | md.renderer.rules.footnote_ref = render_footnote_ref 71 | md.renderer.rules.footnote_block_open = render_footnote_block_open 72 | md.renderer.rules.footnote_block_close = render_footnote_block_close 73 | md.renderer.rules.footnote_open = render_footnote_open 74 | md.renderer.rules.footnote_close = render_footnote_close 75 | md.renderer.rules.footnote_anchor = render_footnote_anchor 76 | 77 | // helpers (only used in other rules, no tokens are attached to those) 78 | md.renderer.rules.footnote_caption = render_footnote_caption 79 | md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name 80 | 81 | // Process footnote block definition 82 | function footnote_def (state, startLine, endLine, silent) { 83 | const start = state.bMarks[startLine] + state.tShift[startLine] 84 | const max = state.eMarks[startLine] 85 | 86 | // line should be at least 5 chars - "[^x]:" 87 | if (start + 4 > max) return false 88 | 89 | if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false 90 | if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false 91 | 92 | let pos 93 | 94 | for (pos = start + 2; pos < max; pos++) { 95 | if (state.src.charCodeAt(pos) === 0x20) return false 96 | if (state.src.charCodeAt(pos) === 0x5D /* ] */) { 97 | break 98 | } 99 | } 100 | 101 | if (pos === start + 2) return false // no empty footnote labels 102 | if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false 103 | if (silent) return true 104 | pos++ 105 | 106 | if (!state.env.footnotes) state.env.footnotes = {} 107 | if (!state.env.footnotes.refs) state.env.footnotes.refs = {} 108 | const label = state.src.slice(start + 2, pos - 2) 109 | state.env.footnotes.refs[`:${label}`] = -1 110 | 111 | const token_fref_o = new state.Token('footnote_reference_open', '', 1) 112 | token_fref_o.meta = { label } 113 | token_fref_o.level = state.level++ 114 | state.tokens.push(token_fref_o) 115 | 116 | const oldBMark = state.bMarks[startLine] 117 | const oldTShift = state.tShift[startLine] 118 | const oldSCount = state.sCount[startLine] 119 | const oldParentType = state.parentType 120 | 121 | const posAfterColon = pos 122 | const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]) 123 | let offset = initial 124 | 125 | while (pos < max) { 126 | const ch = state.src.charCodeAt(pos) 127 | 128 | if (isSpace(ch)) { 129 | if (ch === 0x09) { 130 | offset += 4 - offset % 4 131 | } else { 132 | offset++ 133 | } 134 | } else { 135 | break 136 | } 137 | 138 | pos++ 139 | } 140 | 141 | state.tShift[startLine] = pos - posAfterColon 142 | state.sCount[startLine] = offset - initial 143 | 144 | state.bMarks[startLine] = posAfterColon 145 | state.blkIndent += 4 146 | state.parentType = 'footnote' 147 | 148 | if (state.sCount[startLine] < state.blkIndent) { 149 | state.sCount[startLine] += state.blkIndent 150 | } 151 | 152 | state.md.block.tokenize(state, startLine, endLine, true) 153 | 154 | state.parentType = oldParentType 155 | state.blkIndent -= 4 156 | state.tShift[startLine] = oldTShift 157 | state.sCount[startLine] = oldSCount 158 | state.bMarks[startLine] = oldBMark 159 | 160 | const token_fref_c = new state.Token('footnote_reference_close', '', -1) 161 | token_fref_c.level = --state.level 162 | state.tokens.push(token_fref_c) 163 | 164 | return true 165 | } 166 | 167 | // Process inline footnotes (^[...]) 168 | function footnote_inline (state, silent) { 169 | const max = state.posMax 170 | const start = state.pos 171 | 172 | if (start + 2 >= max) return false 173 | if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false 174 | if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false 175 | 176 | const labelStart = start + 2 177 | const labelEnd = parseLinkLabel(state, start + 1) 178 | 179 | // parser failed to find ']', so it's not a valid note 180 | if (labelEnd < 0) return false 181 | 182 | // We found the end of the link, and know for a fact it's a valid link; 183 | // so all that's left to do is to call tokenizer. 184 | // 185 | if (!silent) { 186 | if (!state.env.footnotes) state.env.footnotes = {} 187 | if (!state.env.footnotes.list) state.env.footnotes.list = [] 188 | const footnoteId = state.env.footnotes.list.length 189 | const tokens = [] 190 | 191 | state.md.inline.parse( 192 | state.src.slice(labelStart, labelEnd), 193 | state.md, 194 | state.env, 195 | tokens 196 | ) 197 | 198 | const token = state.push('footnote_ref', '', 0) 199 | token.meta = { id: footnoteId } 200 | 201 | state.env.footnotes.list[footnoteId] = { 202 | content: state.src.slice(labelStart, labelEnd), 203 | tokens 204 | } 205 | } 206 | 207 | state.pos = labelEnd + 1 208 | state.posMax = max 209 | return true 210 | } 211 | 212 | // Process footnote references ([^...]) 213 | function footnote_ref (state, silent) { 214 | const max = state.posMax 215 | const start = state.pos 216 | 217 | // should be at least 4 chars - "[^x]" 218 | if (start + 3 > max) return false 219 | 220 | if (!state.env.footnotes || !state.env.footnotes.refs) return false 221 | if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false 222 | if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false 223 | 224 | let pos 225 | 226 | for (pos = start + 2; pos < max; pos++) { 227 | if (state.src.charCodeAt(pos) === 0x20) return false 228 | if (state.src.charCodeAt(pos) === 0x0A) return false 229 | if (state.src.charCodeAt(pos) === 0x5D /* ] */) { 230 | break 231 | } 232 | } 233 | 234 | if (pos === start + 2) return false // no empty footnote labels 235 | if (pos >= max) return false 236 | pos++ 237 | 238 | const label = state.src.slice(start + 2, pos - 1) 239 | if (typeof state.env.footnotes.refs[`:${label}`] === 'undefined') return false 240 | 241 | if (!silent) { 242 | if (!state.env.footnotes.list) state.env.footnotes.list = [] 243 | 244 | let footnoteId 245 | 246 | if (state.env.footnotes.refs[`:${label}`] < 0) { 247 | footnoteId = state.env.footnotes.list.length 248 | state.env.footnotes.list[footnoteId] = { label, count: 0 } 249 | state.env.footnotes.refs[`:${label}`] = footnoteId 250 | } else { 251 | footnoteId = state.env.footnotes.refs[`:${label}`] 252 | } 253 | 254 | const footnoteSubId = state.env.footnotes.list[footnoteId].count 255 | state.env.footnotes.list[footnoteId].count++ 256 | 257 | const token = state.push('footnote_ref', '', 0) 258 | token.meta = { id: footnoteId, subId: footnoteSubId, label } 259 | } 260 | 261 | state.pos = pos 262 | state.posMax = max 263 | return true 264 | } 265 | 266 | // Glue footnote tokens to end of token stream 267 | function footnote_tail (state) { 268 | let tokens 269 | let current 270 | let currentLabel 271 | let insideRef = false 272 | const refTokens = {} 273 | 274 | if (!state.env.footnotes) { return } 275 | 276 | state.tokens = state.tokens.filter(function (tok) { 277 | if (tok.type === 'footnote_reference_open') { 278 | insideRef = true 279 | current = [] 280 | currentLabel = tok.meta.label 281 | return false 282 | } 283 | if (tok.type === 'footnote_reference_close') { 284 | insideRef = false 285 | // prepend ':' to avoid conflict with Object.prototype members 286 | refTokens[':' + currentLabel] = current 287 | return false 288 | } 289 | if (insideRef) { current.push(tok) } 290 | return !insideRef 291 | }) 292 | 293 | if (!state.env.footnotes.list) { return } 294 | const list = state.env.footnotes.list 295 | 296 | state.tokens.push(new state.Token('footnote_block_open', '', 1)) 297 | 298 | for (let i = 0, l = list.length; i < l; i++) { 299 | const token_fo = new state.Token('footnote_open', '', 1) 300 | token_fo.meta = { id: i, label: list[i].label } 301 | state.tokens.push(token_fo) 302 | 303 | if (list[i].tokens) { 304 | tokens = [] 305 | 306 | const token_po = new state.Token('paragraph_open', 'p', 1) 307 | token_po.block = true 308 | tokens.push(token_po) 309 | 310 | const token_i = new state.Token('inline', '', 0) 311 | token_i.children = list[i].tokens 312 | token_i.content = list[i].content 313 | tokens.push(token_i) 314 | 315 | const token_pc = new state.Token('paragraph_close', 'p', -1) 316 | token_pc.block = true 317 | tokens.push(token_pc) 318 | } else if (list[i].label) { 319 | tokens = refTokens[`:${list[i].label}`] 320 | } 321 | 322 | if (tokens) state.tokens = state.tokens.concat(tokens) 323 | 324 | let lastParagraph 325 | 326 | if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') { 327 | lastParagraph = state.tokens.pop() 328 | } else { 329 | lastParagraph = null 330 | } 331 | 332 | const t = list[i].count > 0 ? list[i].count : 1 333 | for (let j = 0; j < t; j++) { 334 | const token_a = new state.Token('footnote_anchor', '', 0) 335 | token_a.meta = { id: i, subId: j, label: list[i].label } 336 | state.tokens.push(token_a) 337 | } 338 | 339 | if (lastParagraph) { 340 | state.tokens.push(lastParagraph) 341 | } 342 | 343 | state.tokens.push(new state.Token('footnote_close', '', -1)) 344 | } 345 | 346 | state.tokens.push(new state.Token('footnote_block_close', '', -1)) 347 | } 348 | 349 | md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] }) 350 | md.inline.ruler.after('image', 'footnote_inline', footnote_inline) 351 | md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref) 352 | md.core.ruler.after('inline', 'footnote_tail', footnote_tail) 353 | }; 354 | --------------------------------------------------------------------------------