├── .eslintrc.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── LICENSE ├── README.md ├── docs ├── index.html └── main.css ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── directives │ ├── admonitions.ts │ ├── code.ts │ ├── images.ts │ ├── index.ts │ ├── main.ts │ ├── math.ts │ ├── options.ts │ ├── plugin.ts │ ├── tables.ts │ └── types.ts ├── index.ts ├── nestedCoreParse.ts ├── roles │ ├── html.ts │ ├── index.ts │ ├── main.ts │ ├── math.ts │ ├── plugin.ts │ ├── references.ts │ └── types.ts ├── state │ ├── plugin.ts │ └── utils.ts ├── style │ ├── _admonition.sass │ ├── _directive.sass │ ├── _icons.scss │ ├── _image.sass │ ├── _role.sass │ ├── _tables.sass │ ├── _variables.scss │ └── index.sass └── syntaxTree.ts ├── tests ├── colonFences.spec.ts ├── directiveStructure.spec.ts ├── fixtures │ ├── directives.admonitions.md │ ├── directives.fence.md │ ├── directives.images.md │ ├── directives.math.md │ ├── directives.md │ ├── roles.html.md │ ├── roles.math.md │ ├── roles.md │ ├── roles.references.eq.md │ └── roles.references.numref.md ├── fixturesDirectives.spec.ts ├── fixturesRoles.spec.ts ├── readFixtures.ts └── syntaxTree.spec.ts └── tsconfig.json /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | browser: true 4 | es6: true 5 | jest: true 6 | extends: 7 | - "eslint:recommended" 8 | - "plugin:@typescript-eslint/eslint-recommended" 9 | - "plugin:@typescript-eslint/recommended" 10 | - "plugin:jest/recommended" 11 | - "plugin:prettier/recommended" 12 | parser: "@typescript-eslint/parser" 13 | parserOptions: 14 | ecmaVersion: 2018 15 | sourceType: module 16 | plugins: 17 | - "@typescript-eslint" 18 | rules: {} 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, ci-*] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Cache packages 16 | id: cache-npm 17 | uses: actions/cache@v2 18 | with: 19 | path: ~/.npm 20 | key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} 21 | restore-keys: ${{ runner.os }}-npm- 22 | - name: Use Node.js 12.x 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: 12.x 26 | - name: Install dependencies 27 | run: npm ci 28 | - name: Run linting 29 | run: npm run lint 30 | 31 | tests: 32 | runs-on: ubuntu-latest 33 | strategy: 34 | matrix: 35 | node-version: [12.x, 14.x] 36 | 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Cache packages 40 | id: cache-npm 41 | uses: actions/cache@v2 42 | with: 43 | path: ~/.npm 44 | key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} 45 | restore-keys: ${{ runner.os }}-npm- 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v1 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | - name: Install dependencies 51 | run: npm ci 52 | - name: Run tests and generate coverage report 53 | run: npm test -- --coverage 54 | # - name: Upload coverage to Codecov 55 | # uses: codecov/codecov-action@v1 56 | - name: Test the build 57 | run: npm run build 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # development folders 2 | _build/ 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | 7 | # local configuration 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tests/fixtures/*.md 2 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 88 # equivalent to python black formatter 2 | trailingComma: "none" 3 | semi: false # no semi-colons, unless necessary 4 | singleQuote: false 5 | arrowParens: avoid 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2021 Chris Sewell 2 | 3 | Permission is hereby granted, free 4 | of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markdown-it-docutils [IN-DEVELOPMENT] 2 | 3 | [![ci-badge]][ci-link] 4 | [![npm-badge]][npm-link] 5 | 6 | A [markdown-it](https://github.com/markdown-it/markdown-it) plugin for implementing docutils style roles (inline extension point) and directives (block extension point). 7 | The package also vendors a default CSS, with light/dark mode adaptive colors and overridable [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties). 8 | 9 | See for a demonstration! 10 | 11 | ## Usage 12 | 13 | As a Node module: 14 | 15 | ```javascript 16 | import MarkdownIt from "markdown-it" 17 | import docutilsPlugin from "markdown-it-docutils" 18 | 19 | const text = MarkdownIt().use(docutilsPlugin).render("*a*") 20 | ``` 21 | 22 | In the browser: 23 | 24 | ```html 25 | 26 | 27 | 28 | Example Page 29 | 30 | 31 | 37 | 38 | 39 |
40 | 47 | 48 | 49 | ``` 50 | 51 | ## Supported roles (inline extensions) 52 | 53 | Roles are any token in the token, within an `inline` token's children with the `role` type: 54 | 55 | - `Token.meta = { name }` should contain the name of the role 56 | - `Token.content` should contain the content of the role 57 | 58 | By default (see `parseRoles` option), roles are parsed according to the MyST syntax: `` {name}`content` ``. 59 | 60 | All roles have a fallback renderer, but the the following are specifically handled: 61 | 62 | - HTML: 63 | - `sub`: Subscript (alternatively `subscript`) 64 | - `sup`: Superscript (alternatively `superscript`) 65 | - `abbr`: Abbreviation (alternatively `abbreviation`) 66 | - Referencing 67 | - `eq`: Reference labeled equations 68 | - `ref`: Reference any labeled or named block, showing title 69 | - `numref`: Numbered reference for any labeled or named block (use `Figure %s `) 70 | - Basic: 71 | - `raw` 72 | 73 | ## Supported directives (block extensions) 74 | 75 | Directives are any token in the token stream with the `directive` type: 76 | 77 | - `Token.info` should contain the name of the directive 78 | - `Token.meta = { arg: "" }` should contain the argument (first line) of the directive 79 | - `Token.content` should contain the body of the directive 80 | - `Token.map` should be set 81 | 82 | By default (see `replaceFences` option), all fences with a language delimited in braces will be converted to `directive` tokens, e.g. 83 | 84 | ```` 85 | ```{name} argument 86 | :option: value 87 | 88 | content 89 | ``` 90 | ```` 91 | 92 | All directives have a fallback renderer, but the the following are specifically handled: 93 | 94 | - Admonitions: 95 | - `admonition` 96 | - `note` 97 | - `attention` 98 | - `caution` 99 | - `danger` 100 | - `error` 101 | - `important` 102 | - `hint` 103 | - `note` 104 | - `seealso` 105 | - `tip` 106 | - `warning` 107 | - Image: 108 | - `image` 109 | - `figure` 110 | - Code: 111 | - `code` 112 | - `code-block` 113 | - `code-cell` 114 | - Tables: 115 | - `list-table` 116 | - Other: 117 | - `math` 118 | 119 | ## CSS Styling 120 | 121 | markdown-it-docutils distributes with a default `dist/css/style.min.css` styling, primarily adapted from the [furo sphinx theme](https://pradyunsg.me/furo). 122 | The CSS makes extensive use of [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties). 123 | These can be overridden by the user and are used for stylizing nearly all elements of the documentation. 124 | 125 | The colors are in light mode by default, switching to the dark mode when requested by the user’s browser (through `prefers-color-scheme: dark`). See the [`prefers-color-scheme` documentation](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) for details. 126 | 127 | As a consequence of this design, the dark mode inherits the variable definitions from the light mode, only overriding specific values to adapt the theme. 128 | While the mechanism for switching between light/dark mode is not configurable, the exact CSS variable definitions used in this process can be configured with [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties). 129 | 130 | ## Design Notes 131 | 132 | TODO improve this: 133 | 134 | - Parsing all directives/roles to "generic" directive/role tokens first (with fallback renderer), then "run" the directives/roles 135 | - this separates the logic for parsing these syntaxes, from the logic for interpreting their content, i.e. the syntax for a directive/role can in theory be anything, as long as it can be converted to the necessary token 136 | 137 | ## Development 138 | 139 | ### Features 140 | 141 | - TypeScript 142 | - Code Formatting ([prettier]) 143 | - Code Linting ([eslint]) 144 | - Testing and coverage ([jest]) 145 | - Continuous Integration ([GitHub Actions]) 146 | - Bundled as both UMD and ESM ([rollup]) 147 | - Upload as [NPM] package and [unpkg] CDN 148 | - Simple demonstration website ([GitHub Pages]) 149 | 150 | ### Getting Started 151 | 152 | 1. Create a GitHub repository [from the template](https://docs.github.com/en/github-ae@latest/github/creating-cloning-and-archiving-repositories/creating-a-repository-on-github/creating-a-repository-from-a-template). 153 | 2. Replace package details in the following files: 154 | - `package.json` 155 | - `LICENSE` 156 | - `README.md` 157 | - `rollup.config.js` 158 | 3. Install the `node_module` dependencies: `npm install` or `npm ci` (see [Install a project with a clean slate](https://docs.npmjs.com/cli/v7/commands/npm-ci)). 159 | 4. Run code formatting; `npm run format`, and linting: `npm run lint:fix`. 160 | 5. Run the unit tests; `npm test`, or with coverage; `npm test -- --coverage`. 161 | 162 | Now you can start to adapt the code in `src/index.ts` for your plugin, starting with the [markdown-it development recommendations](https://github.com/markdown-it/markdown-it/blob/master/docs/development.md). 163 | 164 | Modify the test in `tests/fixtures.spec.ts`, to load your plugin, then the "fixtures" in `tests/fixtures`, to provide a set of potential Markdown inputs and expected HTML outputs. 165 | 166 | On commits/PRs to the `main` branch, the GH actions will trigger, running the linting, unit tests, and build tests. 167 | Additionally setup and uncomment the [codecov](https://about.codecov.io/) action in `.github/workflows/ci.yml`, to provide automated CI coverage. 168 | 169 | Finally, you can update the version of your package, e.g.: `npm version patch -m "🚀 RELEASE: v%s"`, push to GitHub; `git push --follow-tags`, build; `npm run build`, and publish; `npm publish`. 170 | 171 | Finally, you can adapt the HTML document in `docs/`, to load both markdown-it and the plugin (from [unpkg]), then render text from an input area. 172 | This can be deployed by [GitHub Pages]. 173 | 174 | [ci-badge]: https://github.com/executablebooks/markdown-it-docutils/workflows/CI/badge.svg 175 | [ci-link]: https://github.com/executablebooks/markdown-it-docutils/actions 176 | [npm-badge]: https://img.shields.io/npm/v/markdown-it-docutils.svg 177 | [npm-link]: https://www.npmjs.com/package/markdown-it-docutils 178 | [github actions]: https://docs.github.com/en/actions 179 | [github pages]: https://docs.github.com/en/pages 180 | [prettier]: https://prettier.io/ 181 | [eslint]: https://eslint.org/ 182 | [jest]: https://facebook.github.io/jest/ 183 | [rollup]: https://rollupjs.org 184 | [npm]: https://www.npmjs.com 185 | [unpkg]: https://unpkg.com/ 186 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | markdown-it-docutils - demonstrator 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |

markdown-it-docutils

26 |
27 |
28 |

29 | This is a minimalist demonstration of the 30 | markdown-it-docutils 33 | plugin. 34 |

35 |

36 | Simply write in the text box below, then click away, and the text will be 37 | rendered.
Note the color scheme is adaptive to browser settings, see 38 | prefers-color-scheme 42 | for details. 43 |

44 | 117 |
118 |
119 |
120 | 121 | 152 | 153 | 154 | -------------------------------------------------------------------------------- /docs/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | .header { 6 | padding: 2px; 7 | text-align: center; 8 | color: black; 9 | background-color: lightgrey; 10 | } 11 | 12 | .content { 13 | padding: 10px; 14 | text-align: center; 15 | } 16 | 17 | .inputtext { 18 | width: 48%; 19 | display: inline-block; 20 | vertical-align: top; 21 | padding: 2px; 22 | min-height: 100px; 23 | resize: none; 24 | } 25 | 26 | .rendered { 27 | width: 48%; 28 | display: inline-block; 29 | vertical-align: top; 30 | margin-left: 10px; 31 | text-align: left; 32 | padding: 4px; 33 | padding-left: 10px; 34 | outline: 1px solid lightgrey; 35 | min-height: 100px; 36 | /* See https://stackoverflow.com/questions/2062258/floating-elements-within-a-div-floats-outside-of-div-why */ 37 | overflow-y: hidden; 38 | } 39 | 40 | @media only screen and (max-width: 768px) { 41 | 42 | /* For mobile phones: */ 43 | .inputtext, 44 | .rendered { 45 | width: 100%; 46 | } 47 | 48 | .inputtext { 49 | height: 200px; 50 | } 51 | 52 | .rendered { 53 | margin-top: 10px; 54 | margin-left: 0px; 55 | } 56 | } 57 | 58 | @media (prefers-color-scheme: dark) { 59 | body { 60 | background-color: #2a2a2c; 61 | color: #ffffffd9; 62 | } 63 | 64 | .inputtext { 65 | background-color: #2a2a2c; 66 | color: #ffffffd9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-it-docutils", 3 | "version": "0.1.6", 4 | "description": "A markdown-it plugin for implementing docutils style roles and directives", 5 | "author": "Chris Sewell", 6 | "homepage": "https://github.com/executablebooks/markdown-it-docutils", 7 | "license": "MIT", 8 | "keywords": [ 9 | "markdown", 10 | "markdown-it", 11 | "markdown-it-plugin", 12 | "docutils" 13 | ], 14 | "main": "dist/cjs/index.js", 15 | "module": "dist/esm/index.js", 16 | "unpkg": "dist/index.umd.min.js", 17 | "types": "dist/types/index.d.ts", 18 | "files": [ 19 | "src", 20 | "dist" 21 | ], 22 | "exports": { 23 | ".": { 24 | "require": "./dist/cjs/index.js", 25 | "import": "./dist/esm/index.js", 26 | "style": "./dist/css/style.min.css" 27 | } 28 | }, 29 | "scripts": { 30 | "clean": "rimraf dist", 31 | "format": "prettier --write src/**/*.ts tests/**/*.ts src/**/*.scss", 32 | "lint": "eslint -c .eslintrc.yml --max-warnings 1 src/**/*.ts tests/**/*.ts", 33 | "lint:fix": "eslint -c .eslintrc.yml --fix src/**/*.ts tests/**/*.ts", 34 | "test": "jest", 35 | "test:watch": "jest --watchAll", 36 | "test:cov": "jest --coverage", 37 | "sass": "sass --style=compressed --source-map --embed-sources src/style/index.sass dist/css/style.min.css && postcss dist/css/style.min.css --use autoprefixer -d dist/css/", 38 | "build:bundles": "rollup -c", 39 | "build:esm": "tsc --module es2015 --outDir dist/esm", 40 | "build:cjs": "tsc --module commonjs --outDir dist/cjs", 41 | "declarations": "tsc --declaration --emitDeclarationOnly --outDir dist/types", 42 | "build": "npm-run-all -l clean -p build:cjs build:esm build:bundles declarations sass", 43 | "build:watch": "rollup -c -w; npm run sass -w", 44 | "prepublishOnly": "npm run build" 45 | }, 46 | "jest": { 47 | "preset": "ts-jest" 48 | }, 49 | "engines": { 50 | "node": ">=12", 51 | "npm": ">=6" 52 | }, 53 | "peerDependencies": { 54 | "markdown-it": "^12.3.2" 55 | }, 56 | "dependencies": { 57 | "js-yaml": "^4.1.0" 58 | }, 59 | "devDependencies": { 60 | "@rollup/plugin-babel": "^5.3.0", 61 | "@rollup/plugin-commonjs": "^21.0.1", 62 | "@rollup/plugin-json": "^4.1.0", 63 | "@rollup/plugin-node-resolve": "^13.1.3", 64 | "@types/jest": "^27.4.0", 65 | "@types/js-yaml": "^4.0.5", 66 | "@types/markdown-it": "^12.2.3", 67 | "@typescript-eslint/eslint-plugin": "^5.12.0", 68 | "@typescript-eslint/parser": "^5.12.0", 69 | "autoprefixer": "^10.4.2", 70 | "eslint": "^7.32.0", 71 | "eslint-config-prettier": "^8.3.0", 72 | "eslint-config-standard": "^16.0.3", 73 | "eslint-plugin-import": "^2.25.4", 74 | "eslint-plugin-jest": "^26.1.0", 75 | "eslint-plugin-node": "^11.1.0", 76 | "eslint-plugin-prettier": "^4.0.0", 77 | "jest": "^27.5.1", 78 | "markdown-it": "^12.3.2", 79 | "markdown-it-myst-extras": "^0.2.0", 80 | "npm-run-all": "^4.1.5", 81 | "postcss": "^8.4.6", 82 | "postcss-cli": "^9.1.0", 83 | "prettier": "^2.5.1", 84 | "rimraf": "^3.0.2", 85 | "rollup": "^2.67.2", 86 | "rollup-plugin-terser": "^7.0.2", 87 | "rollup-plugin-typescript2": "^0.31.2", 88 | "sass": "^1.49.7", 89 | "ts-jest": "^27.1.3", 90 | "typescript": "^4.5.5" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2" 2 | import commonjs from "@rollup/plugin-commonjs" 3 | import babel from "@rollup/plugin-babel" 4 | import resolve from "@rollup/plugin-node-resolve" 5 | import { terser } from "rollup-plugin-terser" 6 | import json from "@rollup/plugin-json" 7 | 8 | export default { 9 | input: "src/index.ts", 10 | plugins: [ 11 | typescript(), // Integration between Rollup and Typescript 12 | commonjs(), // Convert CommonJS modules to ES6 13 | babel({ babelHelpers: "bundled" }), // transpile ES6/7 code 14 | resolve(), // resolve third party modules in node_modules 15 | json() 16 | ], 17 | output: [ 18 | { 19 | file: "dist/index.umd.js", 20 | format: "umd", // commonJS 21 | name: "markdownitDocutils", // window.name if script loaded directly in browser 22 | sourcemap: true 23 | }, 24 | { 25 | file: "dist/index.umd.min.js", 26 | format: "umd", 27 | name: "markdownitDocutils", 28 | plugins: [terser()], 29 | sourcemap: true 30 | }, 31 | { 32 | file: "dist/index.esm.js", 33 | format: "esm", 34 | sourcemap: true 35 | }, 36 | { 37 | file: "dist/index.esm.min.js", 38 | format: "esm", // ES Modules 39 | plugins: [terser()], 40 | sourcemap: true 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/directives/admonitions.ts: -------------------------------------------------------------------------------- 1 | /** Directives for creating admonitions, also known as call-outs, 2 | * for including side content without significantly interrupting the document flow. 3 | */ 4 | import type Token from "markdown-it/lib/token" 5 | import { class_option, unchanged } from "./options" 6 | import { Directive, IDirectiveData } from "./main" 7 | 8 | /** Directives for admonition boxes. 9 | * 10 | * Apdapted from: docutils/docutils/parsers/rst/directives/admonitions.py 11 | */ 12 | class BaseAdmonition extends Directive { 13 | public final_argument_whitespace = true 14 | public has_content = true 15 | public option_spec = { 16 | class: class_option, 17 | // TODO handle name option 18 | name: unchanged 19 | } 20 | public title = "" 21 | public kind = "" 22 | run(data: IDirectiveData): Token[] { 23 | const newTokens: Token[] = [] 24 | 25 | // we create an overall container, then individual containers for the title and body 26 | const adToken = this.createToken("admonition_open", "aside", 1, { 27 | map: data.map, 28 | block: true, 29 | meta: { kind: this.kind } 30 | }) 31 | if (data.options.class?.length >= 1) { 32 | // Custom class information must go first for styling 33 | // For example, `class=tip, kind=seealso` should be styled as a `tip` 34 | adToken.attrSet("class", data.options.class.join(" ")) 35 | adToken.attrJoin("class", "admonition") 36 | } else { 37 | adToken.attrSet("class", "admonition") 38 | } 39 | if (this.kind) { 40 | adToken.attrJoin("class", this.kind) 41 | } 42 | newTokens.push(adToken) 43 | 44 | const adTokenTitle = this.createToken("admonition_title_open", "header", 1) 45 | adTokenTitle.attrSet("class", "admonition-title") 46 | newTokens.push(adTokenTitle) 47 | 48 | // we want the title to be parsed as Markdown during the inline phase 49 | const title = data.args[0] || this.title 50 | newTokens.push( 51 | this.createToken("inline", "", 0, { 52 | map: [data.map[0], data.map[0]], 53 | content: title, 54 | children: [] 55 | }) 56 | ) 57 | 58 | newTokens.push( 59 | this.createToken("admonition_title_close", "header", -1, { block: true }) 60 | ) 61 | 62 | // run a recursive parse on the content of the admonition upto this stage 63 | const bodyTokens = this.nestedParse(data.body, data.bodyMap[0]) 64 | newTokens.push(...bodyTokens) 65 | 66 | newTokens.push(this.createToken("admonition_close", "aside", -1, { block: true })) 67 | 68 | return newTokens 69 | } 70 | } 71 | 72 | export class Admonition extends BaseAdmonition { 73 | public required_arguments = 1 74 | } 75 | 76 | export class Attention extends BaseAdmonition { 77 | public title = "Attention" 78 | public kind = "attention" 79 | } 80 | 81 | export class Caution extends BaseAdmonition { 82 | public title = "Caution" 83 | public kind = "caution" 84 | } 85 | 86 | export class Danger extends BaseAdmonition { 87 | public title = "Danger" 88 | public kind = "danger" 89 | } 90 | 91 | export class Error extends BaseAdmonition { 92 | public title = "Error" 93 | public kind = "error" 94 | } 95 | 96 | export class Important extends BaseAdmonition { 97 | public title = "Important" 98 | public kind = "important" 99 | } 100 | 101 | export class Hint extends BaseAdmonition { 102 | public title = "Hint" 103 | public kind = "hint" 104 | } 105 | 106 | export class Note extends BaseAdmonition { 107 | public title = "Note" 108 | public kind = "note" 109 | } 110 | 111 | export class SeeAlso extends BaseAdmonition { 112 | public title = "See Also" 113 | public kind = "seealso" 114 | } 115 | 116 | export class Tip extends BaseAdmonition { 117 | public title = "Tip" 118 | public kind = "tip" 119 | } 120 | 121 | export class Warning extends BaseAdmonition { 122 | public title = "Warning" 123 | public kind = "warning" 124 | } 125 | 126 | export const admonitions = { 127 | admonition: Admonition, 128 | attention: Attention, 129 | caution: Caution, 130 | danger: Danger, 131 | error: Error, 132 | important: Important, 133 | hint: Hint, 134 | note: Note, 135 | seealso: SeeAlso, 136 | tip: Tip, 137 | warning: Warning 138 | } 139 | -------------------------------------------------------------------------------- /src/directives/code.ts: -------------------------------------------------------------------------------- 1 | /** Admonitions to visualise programming codes */ 2 | import type Token from "markdown-it/lib/token" 3 | import { Directive, IDirectiveData } from "./main" 4 | import { 5 | class_option, 6 | flag, 7 | int, 8 | optional_int, 9 | unchanged, 10 | unchanged_required 11 | } from "./options" 12 | 13 | // TODO add Highlight directive 14 | 15 | /** Mark up content of a code block 16 | * 17 | * Adapted from sphinx/directives/patches.py 18 | */ 19 | export class Code extends Directive { 20 | public required_arguments = 0 21 | public optional_arguments = 1 22 | public final_argument_whitespace = false 23 | public has_content = true 24 | public option_spec = { 25 | /** Add line numbers, optionally starting from a particular number. */ 26 | "number-lines": optional_int, 27 | /** Ignore minor errors on highlighting */ 28 | force: flag, 29 | name: unchanged, 30 | class: class_option 31 | } 32 | run(data: IDirectiveData): Token[] { 33 | // TODO handle options 34 | this.assert_has_content(data) 35 | const token = this.createToken("fence", "code", 0, { 36 | // TODO if not specified, the language should come from a central configuration "highlight_language" 37 | info: data.args ? data.args[0] : "", 38 | content: data.body, 39 | map: data.bodyMap 40 | }) 41 | return [token] 42 | } 43 | } 44 | 45 | /** Mark up content of a code block, with more settings 46 | * 47 | * Adapted from sphinx/directives/code.py 48 | */ 49 | export class CodeBlock extends Directive { 50 | public required_arguments = 0 51 | public optional_arguments = 1 52 | public final_argument_whitespace = false 53 | public has_content = true 54 | public option_spec = { 55 | /** Add line numbers. */ 56 | linenos: flag, 57 | /** Start line numbering from a particular value. */ 58 | "lineno-start": int, 59 | /** Strip indentation characters from the code block. 60 | * When number given, leading N characters are removed 61 | */ 62 | dedent: optional_int, 63 | /** Emphasize particular lines (comma-separated numbers) */ 64 | "emphasize-lines": unchanged_required, 65 | caption: unchanged_required, 66 | /** Ignore minor errors on highlighting */ 67 | force: flag, 68 | name: unchanged, 69 | class: class_option 70 | } 71 | run(data: IDirectiveData): Token[] { 72 | // TODO handle options 73 | this.assert_has_content(data) 74 | const token = this.createToken("fence", "code", 0, { 75 | // TODO if not specified, the language should come from a central configuration "highlight_language" 76 | info: data.args ? data.args[0] : "", 77 | content: data.body, 78 | map: data.bodyMap 79 | }) 80 | return [token] 81 | } 82 | } 83 | 84 | /** A code cell is a special MyST based cell, signifying executable code. */ 85 | export class CodeCell extends Directive { 86 | public required_arguments = 0 87 | public optional_arguments = 1 88 | public final_argument_whitespace = false 89 | public has_content = true 90 | public rawOptions = true 91 | 92 | run(data: IDirectiveData): Token[] { 93 | // TODO store options and the fact that this is a code cell rather than a fence? 94 | const token = this.createToken("fence", "code", 0, { 95 | info: data.args ? data.args[0] : "", 96 | content: data.body, 97 | map: data.bodyMap 98 | }) 99 | return [token] 100 | } 101 | } 102 | 103 | export const code = { 104 | code: Code, 105 | "code-block": CodeBlock, 106 | "code-cell": CodeCell 107 | } 108 | -------------------------------------------------------------------------------- /src/directives/images.ts: -------------------------------------------------------------------------------- 1 | /** Directives for image visualisation */ 2 | import type Token from "markdown-it/lib/token" 3 | import { newTarget, Target, TargetKind } from "../state/utils" 4 | import { Directive, IDirectiveData } from "./main" 5 | import { 6 | class_option, 7 | create_choice, 8 | length_or_percentage_or_unitless, 9 | length_or_percentage_or_unitless_figure, 10 | length_or_unitless, 11 | percentage, 12 | unchanged, 13 | unchanged_required, 14 | uri 15 | } from "./options" 16 | 17 | const shared_option_spec = { 18 | alt: unchanged, 19 | height: length_or_unitless, 20 | width: length_or_percentage_or_unitless, 21 | // TODO handle scale option 22 | scale: percentage, 23 | // TODO handle target option 24 | target: unchanged_required, 25 | class: class_option, 26 | // TODO handle name option (note: should be applied to figure for Figure) 27 | name: unchanged 28 | } 29 | 30 | /** Directive for a single image. 31 | * 32 | * Adapted from: docutils/docutils/parsers/rst/directives/images.py 33 | */ 34 | export class Image extends Directive { 35 | public required_arguments = 1 36 | public optional_arguments = 0 37 | public final_argument_whitespace = true 38 | public option_spec = { 39 | ...shared_option_spec, 40 | align: create_choice(["left", "center", "right", "top", "middle", "bottom"]) 41 | } 42 | create_image(data: IDirectiveData): Token { 43 | // get URI 44 | const src = uri(data.args[0] || "") 45 | 46 | const token = this.createToken("image", "img", 0, { map: data.map, block: true }) 47 | token.attrSet("src", src) 48 | token.attrSet("alt", data.options.alt || "") 49 | // TODO markdown-it default renderer requires the alt as children tokens 50 | const altTokens: Token[] = [] 51 | if (data.options.alt) { 52 | this.state.md.inline.parse( 53 | data.options.alt, 54 | this.state.md, 55 | this.state.env, 56 | altTokens 57 | ) 58 | } 59 | token.children = altTokens 60 | if (data.options.height) { 61 | token.attrSet("height", data.options.height) 62 | } 63 | if (data.options.width) { 64 | token.attrSet("width", data.options.width) 65 | } 66 | if (data.options.align) { 67 | token.attrJoin("class", `align-${data.options.align}`) 68 | } 69 | if (data.options.class) { 70 | token.attrJoin("class", data.options.class.join(" ")) 71 | } 72 | 73 | return token 74 | } 75 | run(data: IDirectiveData): Token[] { 76 | return [this.create_image(data)] 77 | } 78 | } 79 | 80 | /** Directive for an image with caption. 81 | * 82 | * Adapted from: docutils/docutils/parsers/rst/directives/images.py, 83 | * and sphinx/directives/patches.py (patch to apply name to figure instead of image) 84 | */ 85 | export class Figure extends Image { 86 | public option_spec = { 87 | ...shared_option_spec, 88 | align: create_choice(["left", "center", "right"]), 89 | figwidth: length_or_percentage_or_unitless_figure, 90 | figclass: class_option 91 | } 92 | public has_content = true 93 | run(data: IDirectiveData): Token[] { 94 | const openToken = this.createToken("figure_open", "figure", 1, { 95 | map: data.map, 96 | block: true 97 | }) 98 | if (data.options.figclass) { 99 | openToken.attrJoin("class", data.options.figclass.join(" ")) 100 | } 101 | if (data.options.align) { 102 | openToken.attrJoin("class", `align-${data.options.align}`) 103 | } 104 | if (data.options.figwidth && data.options.figwidth !== "image") { 105 | // TODO handle figwidth == "image"? 106 | openToken.attrSet("width", data.options.figwidth) 107 | } 108 | let target: Target | undefined 109 | if (data.options.name) { 110 | // TODO: figure out how to pass silent here 111 | target = newTarget( 112 | this.state, 113 | openToken, 114 | TargetKind.figure, 115 | data.options.name, 116 | // TODO: a better title? 117 | data.body.trim() 118 | ) 119 | openToken.attrJoin("class", "numbered") 120 | } 121 | const imageToken = this.create_image(data) 122 | imageToken.map = [data.map[0], data.map[0]] 123 | let captionTokens: Token[] = [] 124 | let legendTokens: Token[] = [] 125 | if (data.body) { 126 | const [caption, ...legendParts] = data.body.split("\n\n") 127 | const legend = legendParts.join("\n\n") 128 | const captionMap = data.bodyMap[0] 129 | const openCaption = this.createToken("figure_caption_open", "figcaption", 1, { 130 | block: true 131 | }) 132 | if (target) { 133 | openCaption.attrSet("number", `${target.number}`) 134 | } 135 | // TODO in docutils caption can only be single paragraph (or ignored if comment) 136 | // then additional content is figure legend 137 | const captionBody = this.nestedParse(caption, captionMap) 138 | const closeCaption = this.createToken("figure_caption_close", "figcaption", -1, { 139 | block: true 140 | }) 141 | captionTokens = [openCaption, ...captionBody, closeCaption] 142 | if (legend) { 143 | const legendMap = captionMap + caption.split("\n").length + 1 144 | const openLegend = this.createToken("figure_legend_open", "", 1, { 145 | block: true 146 | }) 147 | const legendBody = this.nestedParse(legend, legendMap) 148 | const closeLegend = this.createToken("figure_legend_close", "", -1, { 149 | block: true 150 | }) 151 | legendTokens = [openLegend, ...legendBody, closeLegend] 152 | } 153 | } 154 | const closeToken = this.createToken("figure_close", "figure", -1, { block: true }) 155 | return [openToken, imageToken, ...captionTokens, ...legendTokens, closeToken] 156 | } 157 | } 158 | 159 | export const images = { 160 | image: Image, 161 | figure: Figure 162 | } 163 | -------------------------------------------------------------------------------- /src/directives/index.ts: -------------------------------------------------------------------------------- 1 | export { Directive, IDirectiveData } from "./main" 2 | export { default as directivePlugin } from "./plugin" 3 | export * as directiveOptions from "./options" 4 | export type { IOptions as IDirectiveOptions } from "./types" 5 | 6 | export { admonitions } from "./admonitions" 7 | export { code } from "./code" 8 | export { images } from "./images" 9 | export { tables } from "./tables" 10 | export { math } from "./math" 11 | 12 | import { admonitions } from "./admonitions" 13 | import { code } from "./code" 14 | import { images } from "./images" 15 | import { tables } from "./tables" 16 | import { math } from "./math" 17 | 18 | export const directivesDefault = { 19 | ...admonitions, 20 | ...images, 21 | ...code, 22 | ...tables, 23 | ...math 24 | } 25 | -------------------------------------------------------------------------------- /src/directives/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /** Convert a directives first line and content to its structural components 3 | * 4 | * The code is adapted from: myst_parser/parse_directives.py 5 | * and is common for all directives 6 | */ 7 | 8 | import yaml from "js-yaml" 9 | import type StateCore from "markdown-it/lib/rules_core/state_core" 10 | import type Token from "markdown-it/lib/token" 11 | import { OptionSpecConverter } from "./options" 12 | import { nestedCoreParse } from "../nestedCoreParse" 13 | 14 | /** token specification for a directive */ 15 | export class DirectiveToken implements Token { 16 | public type = "directive" 17 | public tag = "" 18 | public attrs = null 19 | public nesting = 0 as 1 | 0 | -1 20 | public level = 0 21 | public children = null 22 | public markup = "" 23 | public block = true 24 | public hidden = false 25 | public info: string 26 | public meta: { arg: string } 27 | public content: string 28 | public map: [number, number] 29 | constructor(name: string, arg: string, content: string, map: [number, number]) { 30 | this.info = name 31 | this.meta = { arg } 32 | this.content = content 33 | this.map = map 34 | } 35 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 36 | attrIndex(name: string): number { 37 | return -1 38 | } 39 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 40 | attrPush(attrData: [string, string]): void { 41 | throw new Error("not implemented") 42 | } 43 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 44 | attrSet(name: string, value: string): void { 45 | throw new Error("not implemented") 46 | } 47 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 48 | attrGet(name: string): null { 49 | return null 50 | } 51 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 52 | attrJoin(name: string, value: string): void { 53 | throw new Error("not implemented") 54 | } 55 | } 56 | 57 | /** Data required to parse a directive first line and content to its structure */ 58 | export interface IDirectiveSpec { 59 | /** number of required arguments */ 60 | required_arguments?: number 61 | /** number of optional arguments */ 62 | optional_arguments?: number 63 | /** indicating if the final argument may contain whitespace */ 64 | final_argument_whitespace?: boolean 65 | /** if body content is allowed */ 66 | has_content?: boolean 67 | /** mapping known option names to conversion functions */ 68 | option_spec?: Record 69 | /** If true, do not attempt to validate/convert options. */ 70 | rawOptions?: boolean 71 | } 72 | 73 | /** A class to define a single directive */ 74 | export class Directive implements IDirectiveSpec { 75 | public required_arguments = 0 76 | public optional_arguments = 0 77 | public final_argument_whitespace = false 78 | public has_content = false 79 | public option_spec = {} 80 | public rawOptions = false 81 | public state: StateCore 82 | constructor(state: StateCore) { 83 | this.state = state 84 | } 85 | /** Convert the directive data to tokens */ 86 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 87 | run(data: IDirectiveData): Token[] { 88 | return [] 89 | } 90 | assert(test: boolean, msg: string): void { 91 | if (!test) { 92 | throw new Error(msg) 93 | } 94 | } 95 | /** throw error is no body content parsed. */ 96 | assert_has_content(data: IDirectiveData): void { 97 | if (!data.body) { 98 | throw new Error("Content block expected, but none found.") 99 | } 100 | } 101 | /** Create a single token */ 102 | createToken( 103 | type: string, 104 | tag: string, 105 | nesting: Token.Nesting, 106 | optional?: { 107 | content?: string 108 | level?: number 109 | map?: null | [number, number] 110 | meta?: any 111 | info?: string 112 | block?: boolean 113 | children?: Token[] 114 | } 115 | ): Token { 116 | const token = new this.state.Token(type, tag, nesting) 117 | if (optional?.content !== undefined) { 118 | token.content = optional.content 119 | } 120 | if (optional?.level !== undefined) { 121 | token.level = optional.level 122 | } 123 | if (optional?.map !== undefined) { 124 | token.map = optional.map 125 | } 126 | if (optional?.block !== undefined) { 127 | token.block = optional.block 128 | } 129 | if (optional?.info !== undefined) { 130 | token.info = optional.info 131 | } 132 | if (optional?.meta !== undefined) { 133 | token.meta = optional.meta 134 | } 135 | if (optional?.children !== undefined) { 136 | token.children = optional.children 137 | } 138 | return token 139 | } 140 | /** parse block of text to tokens (does not run inline parse) */ 141 | nestedParse(block: string, initLine: number): Token[] { 142 | return nestedCoreParse( 143 | this.state.md, 144 | "run_directives", 145 | block, 146 | this.state.env, 147 | initLine, 148 | true 149 | ) 150 | } 151 | } 152 | 153 | /** Data structure of a directive */ 154 | export interface IDirectiveData { 155 | map: [number, number] 156 | args: string[] 157 | options: Record 158 | body: string 159 | bodyMap: [number, number] 160 | } 161 | 162 | /** Raise on parsing/validation error. */ 163 | export class DirectiveParsingError extends Error { 164 | name = "DirectiveParsingError" 165 | } 166 | 167 | /** 168 | * This function contains the logic to take the first line of a directive, 169 | * and the content, and turn it into the three core components: 170 | * arguments (list), options (key: value mapping), and body (text). 171 | */ 172 | export default function directiveToData( 173 | token: Token, 174 | directive: IDirectiveSpec 175 | ): IDirectiveData { 176 | const firstLine = token.meta.arg || "" 177 | const content = token.content 178 | let body = content.trim() ? content.split(/\r?\n/) : [] 179 | let bodyOffset = 0 180 | let options = {} 181 | if (Object.keys(directive.option_spec || {}) || directive.rawOptions) { 182 | ;[body, options, bodyOffset] = parseDirectiveOptions(body, directive) 183 | } 184 | let args: string[] = [] 185 | if (!directive.required_arguments && !directive.optional_arguments) { 186 | if (firstLine) { 187 | bodyOffset = 0 188 | body = [firstLine].concat(body) 189 | } 190 | } else { 191 | args = parseDirectiveArguments(firstLine, directive) 192 | } 193 | // remove first line of body if blank, to allow space between the options and the content 194 | if (body.length && !body[0].trim()) { 195 | body.shift() 196 | bodyOffset++ 197 | } 198 | // check for body content 199 | if (body.length && !directive.has_content) { 200 | throw new DirectiveParsingError("Has content but content not allowed") 201 | } 202 | return { 203 | map: token.map ? token.map : [0, 0], 204 | args, 205 | options, 206 | body: body.join("\n"), 207 | bodyMap: token.map 208 | ? [ 209 | body.length > 0 ? token.map[0] + bodyOffset : token.map[1], 210 | body.length > 0 ? token.map[1] - 1 : token.map[1] 211 | ] 212 | : [0, 0] 213 | } 214 | } 215 | 216 | export function parseDirectiveOptions( 217 | content: string[], 218 | fullSpec: IDirectiveSpec 219 | ): [string[], { [key: string]: any }, number] { 220 | // instantiate options 221 | let bodyOffset = 1 222 | let options: { [key: string]: any } = {} 223 | let yamlBlock: null | string[] = null 224 | 225 | // TODO allow for indented content (I can't remember why this was needed?) 226 | 227 | if (content.length && content[0].startsWith("---")) { 228 | // options contained in YAML block, ending with '---' 229 | bodyOffset++ 230 | const newContent: string[] = [] 231 | yamlBlock = [] 232 | let foundDivider = false 233 | for (const line of content.slice(1)) { 234 | if (line.startsWith("---")) { 235 | bodyOffset++ 236 | foundDivider = true 237 | continue 238 | } 239 | if (foundDivider) { 240 | newContent.push(line) 241 | } else { 242 | bodyOffset++ 243 | yamlBlock.push(line) 244 | } 245 | } 246 | content = newContent 247 | } else if (content.length && content[0].startsWith(":")) { 248 | const newContent: string[] = [] 249 | yamlBlock = [] 250 | let foundDivider = false 251 | for (const line of content) { 252 | if (!foundDivider && !line.startsWith(":")) { 253 | foundDivider = true 254 | newContent.push(line) 255 | continue 256 | } 257 | if (foundDivider) { 258 | newContent.push(line) 259 | } else { 260 | bodyOffset++ 261 | yamlBlock.push(line.slice(1)) 262 | } 263 | } 264 | content = newContent 265 | } 266 | 267 | if (yamlBlock !== null) { 268 | try { 269 | const output = yaml.load(yamlBlock.join("\n")) 270 | if (output !== null && typeof output === "object") { 271 | options = output 272 | } else { 273 | throw new DirectiveParsingError(`not dict: ${output}`) 274 | } 275 | } catch (error) { 276 | throw new DirectiveParsingError(`Invalid options YAML: ${error}`) 277 | } 278 | } 279 | 280 | if (fullSpec.rawOptions) { 281 | return [content, options, bodyOffset] 282 | } 283 | 284 | for (const [name, value] of Object.entries(options)) { 285 | const convertor = fullSpec.option_spec ? fullSpec.option_spec[name] : null 286 | if (!convertor) { 287 | throw new DirectiveParsingError(`Unknown option: ${name}`) 288 | } 289 | let converted_value = value 290 | if (value === null || value === false) { 291 | converted_value = "" 292 | } 293 | try { 294 | // In docutils all values are simply read as strings, 295 | // but loading with YAML these can be converted to other types, so we convert them back first 296 | // TODO check that it is sufficient to simply do this conversion, or if there is a better way 297 | converted_value = convertor(`${converted_value || ""}`) 298 | } catch (error) { 299 | throw new DirectiveParsingError( 300 | `Invalid option value: (option: '${name}'; value: ${value})\n${error}` 301 | ) 302 | } 303 | options[name] = converted_value 304 | } 305 | 306 | return [content, options, bodyOffset] 307 | } 308 | 309 | function parseDirectiveArguments( 310 | firstLine: string, 311 | fullSpec: IDirectiveSpec 312 | ): string[] { 313 | let args = firstLine.trim() ? firstLine.trim()?.split(/\s+/) : [] 314 | const totalArgs = 315 | (fullSpec.required_arguments || 0) + (fullSpec.optional_arguments || 0) 316 | if (args.length < (fullSpec.required_arguments || 0)) { 317 | throw new DirectiveParsingError( 318 | `${fullSpec.required_arguments} argument(s) required, ${args.length} supplied` 319 | ) 320 | } else if (args.length > totalArgs) { 321 | if (fullSpec.final_argument_whitespace) { 322 | // note split limit does not work the same as in python 323 | const arr = firstLine.split(/\s+/) 324 | args = arr.splice(0, totalArgs - 1) 325 | // TODO is it ok that we effectively replace all whitespace with single spaces? 326 | args.push(arr.join(" ")) 327 | } else { 328 | throw new DirectiveParsingError( 329 | `maximum ${totalArgs} argument(s) allowed, ${args.length} supplied` 330 | ) 331 | } 332 | } 333 | return args 334 | } 335 | -------------------------------------------------------------------------------- /src/directives/math.ts: -------------------------------------------------------------------------------- 1 | /** Admonitions to visualise programming codes */ 2 | import type Token from "markdown-it/lib/token" 3 | import { newTarget, Target, TargetKind } from "../state/utils" 4 | import { Directive, IDirectiveData } from "./main" 5 | import { unchanged } from "./options" 6 | 7 | /** Math directive with a label 8 | */ 9 | export class Math extends Directive { 10 | public required_arguments = 0 11 | public optional_arguments = 0 12 | public final_argument_whitespace = false 13 | public has_content = true 14 | public option_spec = { 15 | label: unchanged 16 | } 17 | run(data: IDirectiveData): Token[] { 18 | // TODO handle options 19 | this.assert_has_content(data) 20 | const token = this.createToken("math_block", "div", 0, { 21 | content: data.body, 22 | map: data.bodyMap, 23 | block: true 24 | }) 25 | token.attrSet("class", "math block") 26 | if (data.options.label) { 27 | token.attrSet("id", data.options.label) 28 | const target: Target = newTarget( 29 | this.state, 30 | token, 31 | TargetKind.equation, 32 | data.options.label, 33 | "" 34 | ) 35 | token.attrSet("number", `${target.number}`) 36 | token.info = data.options.label 37 | token.meta = { label: data.options.label, numbered: true, number: target.number } 38 | } 39 | return [token] 40 | } 41 | } 42 | 43 | export const math = { 44 | math: Math 45 | } 46 | -------------------------------------------------------------------------------- /src/directives/options.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /** Functions for converting and validating directive options 3 | * 4 | * Primarily adapted from: docutils/docutils/parsers/rst/directives/__init__.py 5 | */ 6 | 7 | /** 8 | * Normalize a string to HTML4 id 9 | * 10 | * Adapted from docutils/nodes.py::make_id, 11 | * it should be noted that in HTML5 the only requirement is no whitespace. 12 | * */ 13 | export function make_id(name: string): string { 14 | // TODO make more complete 15 | return name 16 | .toLowerCase() 17 | .split(/\s+/) 18 | .join("-") 19 | .replace(/[^a-z0-9]+/, "-") 20 | .replace(/^[-0-9]+|-+$/, "") 21 | } 22 | 23 | /** convert and validate an option value */ 24 | export type OptionSpecConverter = (value: string, options?: any) => any 25 | 26 | /** Error to throw when an option is invalid. */ 27 | export class OptionSpecError extends Error { 28 | name = "OptionSpecError" 29 | } 30 | 31 | /** Leave value unchanged */ 32 | export const unchanged: OptionSpecConverter = (value: string): string => value 33 | 34 | /** Leave value unchanged, but assert non-empty string */ 35 | export const unchanged_required: OptionSpecConverter = (value: string): string => { 36 | if (!value) { 37 | throw new OptionSpecError("Argument required but none supplied") 38 | } 39 | return value 40 | } 41 | 42 | /** A flag option (no argument) */ 43 | export const flag: OptionSpecConverter = (value: string): null => { 44 | if (value.trim()) { 45 | throw new OptionSpecError(`No argument is allowed: "${value}" supplied`) 46 | } 47 | return null 48 | } 49 | 50 | /** Split values by whitespace and normalize to HTML4 id */ 51 | export const class_option: OptionSpecConverter = (value: string): string[] => { 52 | return `${value || ""}`.split(/\s+/).map(name => make_id(name)) 53 | } 54 | 55 | /** Check for an integer argument and convert */ 56 | export function int(argument: string): number { 57 | if (!argument) { 58 | throw new OptionSpecError("Value is not set") 59 | } 60 | const value = Number.parseFloat(argument) 61 | if (Number.isNaN(value) || !Number.isInteger(value)) { 62 | throw new OptionSpecError(`Value "${argument}" is not an integer`) 63 | } 64 | return value 65 | } 66 | 67 | /** Check for a non-negative integer argument and convert */ 68 | export function nonnegative_int(argument: string): number { 69 | const value = int(argument) 70 | if (value < 0) { 71 | throw new OptionSpecError(`Value "${argument}" must be positive or zero`) 72 | } 73 | return value 74 | } 75 | 76 | /** A non-negative integer or null. */ 77 | export const optional_int: OptionSpecConverter = (value: string): null | number => { 78 | if (!value) { 79 | return null 80 | } 81 | return nonnegative_int(value) 82 | } 83 | 84 | /** Check for an integer percentage value with optional percent sign. */ 85 | export const percentage: OptionSpecConverter = (value: string): number => { 86 | value = `${value || ""}`.replace(/\s+%$/, "") 87 | return nonnegative_int(value) 88 | } 89 | 90 | /** Check for a positive argument of one of the units and return a 91 | normalized string of the form "" (without space in 92 | between). 93 | */ 94 | function get_measure(argument: string, units: string[]): string { 95 | const regex = new RegExp(`^(?[0-9.]+)\\s*(?${units.join("|")})$`) 96 | const match = regex.exec(argument) 97 | if (!match || !match.groups) { 98 | throw new OptionSpecError( 99 | `not a positive measure of one of the following units: ${units.join("|")}` 100 | ) 101 | } 102 | return match.groups.number + match.groups.units 103 | } 104 | 105 | const length_units = ["em", "ex", "px", "in", "cm", "mm", "pt", "pc"] 106 | 107 | /** Check for a positive argument of a length unit, allowing for no unit. */ 108 | export const length_or_unitless: OptionSpecConverter = (value: string): string => { 109 | return get_measure(value, [...length_units, ""]) 110 | } 111 | 112 | /** 113 | Return normalized string of a length or percentage unit. 114 | 115 | Add if there is no unit. Raise ValueError if the argument is not 116 | a positive measure of one of the valid CSS units (or without unit). 117 | 118 | >>> length_or_percentage_or_unitless('3 pt') 119 | '3pt' 120 | >>> length_or_percentage_or_unitless('3%', 'em') 121 | '3%' 122 | >>> length_or_percentage_or_unitless('3') 123 | '3' 124 | >>> length_or_percentage_or_unitless('3', 'px') 125 | '3px' 126 | 127 | */ 128 | export const length_or_percentage_or_unitless: OptionSpecConverter = ( 129 | argument: string, 130 | defaultUnit = "" 131 | ): string => { 132 | try { 133 | return get_measure(argument, [...length_units, "%"]) 134 | } catch { 135 | return length_or_unitless(argument) + defaultUnit 136 | } 137 | } 138 | 139 | export const length_or_percentage_or_unitless_figure: OptionSpecConverter = ( 140 | argument: string, 141 | defaultUnit = "" 142 | ): string => { 143 | if (argument.toLowerCase() === "image") { 144 | return "image" 145 | } 146 | return length_or_percentage_or_unitless(argument, defaultUnit) 147 | } 148 | 149 | /** Create an option that asserts the (lower-cased & trimmed) value is a member of a choice set. */ 150 | export function create_choice(choices: string[]): OptionSpecConverter { 151 | return (argument: string): string => { 152 | argument = argument.toLowerCase().trim() 153 | if (choices.includes(argument)) { 154 | return argument 155 | } 156 | throw new OptionSpecError(`must be in: ${choices.join("|")}`) 157 | } 158 | } 159 | 160 | /** Return the URI argument with unescaped whitespace removed. */ 161 | export const uri: OptionSpecConverter = (value: string): string => { 162 | // TODO implement whitespace removal 163 | return value 164 | } 165 | -------------------------------------------------------------------------------- /src/directives/plugin.ts: -------------------------------------------------------------------------------- 1 | import type MarkdownIt from "markdown-it/lib" 2 | import type StateCore from "markdown-it/lib/rules_core/state_core" 3 | import directiveToData, { Directive, parseDirectiveOptions } from "./main" 4 | import { IOptions } from "./types" 5 | 6 | export default function directivePlugin(md: MarkdownIt, options: IOptions): void { 7 | let after = options.directivesAfter || "block" 8 | if (options.replaceFences ?? true) { 9 | md.core.ruler.after(after, "fence_to_directive", replaceFences) 10 | after = "fence_to_directive" 11 | } 12 | md.core.ruler.after(after, "run_directives", runDirectives(options.directives || {})) 13 | 14 | // fallback renderer for unhandled directives 15 | md.renderer.rules["directive"] = (tokens, idx) => { 16 | const token = tokens[idx] 17 | return `\n` 18 | } 19 | md.renderer.rules["directive_error"] = (tokens, idx) => { 20 | const token = tokens[idx] 21 | let content = "" 22 | if (token.content) { 23 | content = `\n---\n${token.content}` 24 | } 25 | return `\n` 26 | } 27 | } 28 | 29 | /** Convert fences identified as directives to `directive` tokens */ 30 | function replaceFences(state: StateCore): boolean { 31 | for (const token of state.tokens) { 32 | if (token.type === "fence" || token.type === "colon_fence") { 33 | const match = token.info.match(/^\{([^\s}]+)\}\s*(.*)$/) 34 | if (match) { 35 | token.type = "directive" 36 | token.info = match[1] 37 | token.meta = { arg: match[2] } 38 | } 39 | } 40 | } 41 | return true 42 | } 43 | 44 | /** Run all directives, replacing the original token */ 45 | function runDirectives(directives: { 46 | [key: string]: typeof Directive 47 | }): (state: StateCore) => boolean { 48 | function func(state: StateCore): boolean { 49 | const finalTokens = [] 50 | for (const token of state.tokens) { 51 | // TODO directive name translations 52 | if (token.type === "directive" && token.info in directives) { 53 | try { 54 | const directive = new directives[token.info](state) 55 | const data = directiveToData(token, directive) 56 | const [content, opts] = parseDirectiveOptions( 57 | token.content.trim() ? token.content.split(/\r?\n/) : [], 58 | directive 59 | ) 60 | const directiveOpen = new state.Token("parsed_directive_open", "", 1) 61 | directiveOpen.info = token.info 62 | directiveOpen.hidden = true 63 | directiveOpen.content = content.join("\n").trim() 64 | directiveOpen.meta = { 65 | arg: token.meta.arg, 66 | opts 67 | } 68 | const newTokens = [directiveOpen] 69 | newTokens.push(...directive.run(data)) 70 | const directiveClose = new state.Token("parsed_directive_close", "", -1) 71 | directiveClose.hidden = true 72 | newTokens.push(directiveClose) 73 | // Ensure `meta` exists and add the directive options to parsed child 74 | newTokens[1].meta = { 75 | directive: true, 76 | ...data.options, 77 | ...newTokens[1].meta 78 | } 79 | finalTokens.push(...newTokens) 80 | } catch (err) { 81 | const errorToken = new state.Token("directive_error", "", 0) 82 | errorToken.content = token.content 83 | errorToken.info = token.info 84 | errorToken.meta = token.meta 85 | errorToken.map = token.map 86 | errorToken.meta.error_message = (err as Error).message 87 | errorToken.meta.error_name = (err as Error).name 88 | finalTokens.push(errorToken) 89 | } 90 | } else { 91 | finalTokens.push(token) 92 | } 93 | } 94 | state.tokens = finalTokens 95 | return true 96 | } 97 | return func 98 | } 99 | -------------------------------------------------------------------------------- /src/directives/tables.ts: -------------------------------------------------------------------------------- 1 | /** Directives for creating tables */ 2 | import type Token from "markdown-it/lib/token" 3 | import { SyntaxTreeNode } from "../syntaxTree" 4 | import { Directive, DirectiveParsingError, IDirectiveData } from "./main" 5 | import { 6 | class_option, 7 | create_choice, 8 | length_or_percentage_or_unitless, 9 | nonnegative_int, 10 | unchanged 11 | } from "./options" 12 | 13 | export class ListTable extends Directive { 14 | public required_arguments = 0 15 | public optional_arguments = 1 16 | public final_argument_whitespace = true 17 | public has_content = true 18 | public option_spec = { 19 | "header-rows": nonnegative_int, 20 | "stub-columns": nonnegative_int, 21 | width: length_or_percentage_or_unitless, 22 | widths: unchanged, // TODO use correct widths option validator 23 | class: class_option, 24 | name: unchanged, 25 | align: create_choice(["left", "center", "right"]) 26 | } 27 | run(data: IDirectiveData): Token[] { 28 | // TODO support all options (add colgroup for widths) 29 | // Parse content 30 | this.assert_has_content(data) 31 | const headerRows = (data.options["header-rows"] || 0) as number 32 | const listTokens = this.nestedParse(data.body, data.bodyMap[0]) 33 | // Check content is a list 34 | if ( 35 | listTokens.length < 2 || 36 | listTokens[0].type !== "bullet_list_open" || 37 | listTokens[listTokens.length - 1].type !== "bullet_list_close" 38 | ) { 39 | throw new DirectiveParsingError("Content is not a single bullet list") 40 | } 41 | 42 | // generate tokens 43 | const tokens: Token[] = [] 44 | 45 | // table opening 46 | const tableOpen = this.createToken("table_open", "table", 1, { map: data.bodyMap }) 47 | if (data.options.align) { 48 | tableOpen.attrJoin("class", `align-${data.options.align}`) 49 | } 50 | if (data.options.class) { 51 | tableOpen.attrJoin("class", data.options.class.join(" ")) 52 | } 53 | tokens.push(tableOpen) 54 | 55 | // add caption 56 | if (data.args.length && data.args[0]) { 57 | tokens.push(this.createToken("table_caption_open", "caption", 1)) 58 | tokens.push( 59 | this.createToken("inline", "", 0, { 60 | map: [data.map[0], data.map[0]], 61 | content: data.args[0], 62 | children: [] 63 | }) 64 | ) 65 | tokens.push(this.createToken("table_caption_close", "caption", -1)) 66 | } 67 | 68 | let colType: "th" | "td" = "th" 69 | if (headerRows) { 70 | tokens.push(this.createToken("thead_open", "thead", 1, { level: 1 })) 71 | colType = "th" 72 | } else { 73 | tokens.push(this.createToken("tbody_open", "tbody", 1, { level: 1 })) 74 | colType = "td" 75 | } 76 | 77 | let rowLength: number | undefined = undefined 78 | let rowNumber = 0 79 | for (const child of new SyntaxTreeNode(listTokens.slice(1, -1)).children) { 80 | rowNumber += 1 81 | this.assert( 82 | child.type === "list_item", 83 | `list item ${rowNumber} not of type 'list_item': ${child.type}` 84 | ) 85 | this.assert( 86 | child.children.length === 1 && child.children[0].type === "bullet_list", 87 | `list item ${rowNumber} content not a nested bullet list` 88 | ) 89 | const row = child.children[0].children 90 | if (rowLength === undefined) { 91 | rowLength = row.length 92 | } else { 93 | this.assert( 94 | row.length === rowLength, 95 | `list item ${rowNumber} does not contain the same number of columns as previous items` 96 | ) 97 | } 98 | if (headerRows && rowNumber === headerRows + 1) { 99 | tokens.push(this.createToken("thead_close", "thead", -1, { level: 1 })) 100 | tokens.push(this.createToken("tbody_open", "tbody", 1, { level: 1 })) 101 | colType = "td" 102 | } 103 | tokens.push(this.createToken("tr_open", "tr", 1, { map: child.map, level: 2 })) 104 | for (const column of row) { 105 | tokens.push( 106 | this.createToken(`${colType}_open`, colType, 1, { map: column.map, level: 3 }) 107 | ) 108 | // TODO if the list is not tight then all paragraphs will be un-hidden maybe we don't want this? 109 | tokens.push(...column.to_tokens().slice(1, -1)) 110 | tokens.push(this.createToken(`${colType}_close`, colType, -1, { level: 3 })) 111 | } 112 | tokens.push(this.createToken("tr_close", "tr", -1, { level: 2 })) 113 | } 114 | 115 | if (headerRows && rowNumber < headerRows) { 116 | throw new Error( 117 | `Insufficient rows (${rowNumber}) for required header rows (${headerRows})` 118 | ) 119 | } 120 | 121 | // closing tokens 122 | if (colType === "td") { 123 | tokens.push(this.createToken("tbody_close", "tbody", -1, { level: 1 })) 124 | } else { 125 | tokens.push(this.createToken("thead_close", "thead", -1, { level: 1 })) 126 | } 127 | tokens.push(this.createToken("table_close", "table", -1)) 128 | 129 | return tokens 130 | } 131 | } 132 | 133 | export const tables = { 134 | "list-table": ListTable 135 | } 136 | -------------------------------------------------------------------------------- /src/directives/types.ts: -------------------------------------------------------------------------------- 1 | import type { Directive } from "./main" 2 | 3 | /** Allowed options for directive plugin */ 4 | export interface IOptions { 5 | /** Replace fence tokens with directive tokens, if there language is of the form `{name}` */ 6 | replaceFences?: boolean 7 | /** Core rule to run directives after (default: block or fence_to_directive) */ 8 | directivesAfter?: string 9 | /** Mapping of names to directives */ 10 | directives?: { [key: string]: typeof Directive } 11 | // TODO new token render rules 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type MarkdownIt from "markdown-it/lib" 2 | import { rolesDefault, Role, rolePlugin, IRoleOptions, IRoleData } from "./roles" 3 | import { 4 | directivesDefault, 5 | Directive, 6 | directivePlugin, 7 | directiveOptions, 8 | IDirectiveOptions, 9 | IDirectiveData 10 | } from "./directives" 11 | import statePlugin from "./state/plugin" 12 | 13 | export { rolesDefault, rolePlugin, Role } 14 | export { directivesDefault, directivePlugin, Directive, directiveOptions } 15 | 16 | export type { IRoleData, IRoleOptions, IDirectiveData, IDirectiveOptions } 17 | 18 | /** Allowed options for docutils plugin */ 19 | export interface IOptions extends IDirectiveOptions, IRoleOptions { 20 | // TODO new token render rules 21 | } 22 | 23 | /** Default options for docutils plugin */ 24 | const OptionDefaults: IOptions = { 25 | parseRoles: true, 26 | replaceFences: true, 27 | rolesAfter: "inline", 28 | directivesAfter: "block", 29 | directives: directivesDefault, 30 | roles: rolesDefault 31 | } 32 | 33 | /** 34 | * A markdown-it plugin for implementing docutils style roles and directives. 35 | */ 36 | export function docutilsPlugin(md: MarkdownIt, options?: IOptions): void { 37 | const fullOptions = { ...OptionDefaults, ...options } 38 | 39 | md.use(rolePlugin, fullOptions) 40 | md.use(directivePlugin, fullOptions) 41 | md.use(statePlugin, fullOptions) 42 | } 43 | 44 | // Note: Exporting default and the function as a named export. 45 | // This helps with Jest integration in downstream packages. 46 | export default docutilsPlugin 47 | -------------------------------------------------------------------------------- /src/nestedCoreParse.ts: -------------------------------------------------------------------------------- 1 | import type MarkdownIt from "markdown-it" 2 | import type Token from "markdown-it/lib/token" 3 | 4 | /** Perform a nested parse upto and including a particular ruleName 5 | * 6 | * The main use for this function is to perform nested parses 7 | * upto but not including inline parsing. 8 | */ 9 | export function nestedCoreParse( 10 | md: MarkdownIt, 11 | pluginRuleName: string, 12 | src: string, 13 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any 14 | env: any, 15 | initLine: number, 16 | includeRule = true 17 | ): Token[] { 18 | // disable all core rules after pluginRuleName 19 | const tempDisabledCore: string[] = [] 20 | // TODO __rules__ is currently not exposed in typescript, but is the only way to get the rule names, 21 | // since md.core.ruler.getRules('') only returns the rule functions 22 | // we should upstream a getRuleNames() function or similar 23 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 24 | // @ts-ignore TS2339 25 | for (const rule of [...md.core.ruler.__rules__].reverse()) { 26 | if (rule.name === pluginRuleName) { 27 | if (!includeRule) { 28 | tempDisabledCore.push(rule.name) 29 | } 30 | break 31 | } 32 | if (rule.name) { 33 | tempDisabledCore.push(rule.name) 34 | } 35 | } 36 | 37 | md.core.ruler.disable(tempDisabledCore) 38 | 39 | let tokens = [] 40 | try { 41 | tokens = md.parse(src, env) 42 | } finally { 43 | md.core.ruler.enable(tempDisabledCore) 44 | } 45 | for (const token of tokens) { 46 | token.map = 47 | token.map !== null 48 | ? [token.map[0] + initLine, token.map[1] + initLine] 49 | : token.map 50 | } 51 | return tokens 52 | } 53 | -------------------------------------------------------------------------------- /src/roles/html.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module contains roles that directly map to HTML semantic tags 3 | */ 4 | import type Token from "markdown-it/lib/token" 5 | import { IRoleData, Role } from "./main" 6 | 7 | export class Subscript extends Role { 8 | run(data: IRoleData): Token[] { 9 | const open = new this.state.Token("sub_open", "sub", 1) 10 | open.markup = "~" 11 | const text = new this.state.Token("text", "", 0) 12 | text.content = data.content 13 | const close = new this.state.Token("sub_close", "sub", -1) 14 | close.markup = "~" 15 | return [open, text, close] 16 | } 17 | } 18 | 19 | export class Superscript extends Role { 20 | run(data: IRoleData): Token[] { 21 | const open = new this.state.Token("sup_open", "sup", 1) 22 | open.markup = "~" 23 | const text = new this.state.Token("text", "", 0) 24 | text.content = data.content 25 | const close = new this.state.Token("sup_close", "sup", -1) 26 | close.markup = "~" 27 | return [open, text, close] 28 | } 29 | } 30 | 31 | const ABBR_PATTERN = /^(.+?)\(([^()]+)\)$/ // e.g. 'CSS (Cascading Style Sheets)' 32 | 33 | export class Abbreviation extends Role { 34 | run(data: IRoleData): Token[] { 35 | const match = ABBR_PATTERN.exec(data.content) 36 | const content = match?.[1]?.trim() ?? data.content.trim() 37 | const title = match?.[2]?.trim() ?? null 38 | const open = new this.state.Token("abbr_open", "abbr", 1) 39 | if (title) open.attrSet("title", title) 40 | const text = new this.state.Token("text", "", 0) 41 | text.content = content 42 | const close = new this.state.Token("abbr_close", "abbr", -1) 43 | return [open, text, close] 44 | } 45 | } 46 | 47 | export const html = { 48 | // Subscript 49 | subscript: Subscript, 50 | sub: Subscript, 51 | // Superscript 52 | superscript: Superscript, 53 | sup: Superscript, 54 | // Abbreviation 55 | abbreviation: Abbreviation, 56 | abbr: Abbreviation 57 | } 58 | -------------------------------------------------------------------------------- /src/roles/index.ts: -------------------------------------------------------------------------------- 1 | export { Role, main, IRoleData } from "./main" 2 | export { default as rolePlugin } from "./plugin" 3 | export type { IOptions as IRoleOptions } from "./types" 4 | export { math } from "./math" 5 | export { html } from "./html" 6 | export { references } from "./references" 7 | 8 | import { main } from "./main" 9 | import { math } from "./math" 10 | import { html } from "./html" 11 | import { references } from "./references" 12 | 13 | export const rolesDefault = { ...main, ...html, ...math, ...references } 14 | -------------------------------------------------------------------------------- /src/roles/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import type StateCore from "markdown-it/lib/rules_core/state_core" 4 | import type Token from "markdown-it/lib/token" 5 | 6 | /** Data structure of a role */ 7 | export interface IRoleData { 8 | /** The map of the containing inline token */ 9 | parentMap: [number, number] | null 10 | // TODO how to get line and position in line? 11 | content: string 12 | // TODO validate/convert 13 | options?: { [key: string]: any } 14 | } 15 | 16 | /** A class to define a single role */ 17 | export class Role { 18 | public state: StateCore 19 | constructor(state: StateCore) { 20 | this.state = state 21 | } 22 | /** Convert the role to tokens */ 23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 24 | run(data: IRoleData): Token[] { 25 | return [] 26 | } 27 | } 28 | 29 | export class RawRole extends Role { 30 | run(data: IRoleData): Token[] { 31 | // TODO options 32 | const token = new this.state.Token("code_inline", "code", 0) 33 | token.content = data.content 34 | return [token] 35 | } 36 | } 37 | 38 | export const main = { 39 | raw: RawRole 40 | } 41 | -------------------------------------------------------------------------------- /src/roles/math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module contains roles that relate to mathematics 3 | */ 4 | import type MarkdownIt from "markdown-it/lib" 5 | import type Token from "markdown-it/lib/token" 6 | import { IRoleData, Role } from "./main" 7 | import { IOptions } from "./types" 8 | 9 | const INLINE_MATH_RULE = "math_inline" 10 | 11 | export class Math extends Role { 12 | run(data: IRoleData): Token[] { 13 | const inline = new this.state.Token(INLINE_MATH_RULE, "span", 0) 14 | inline.attrSet("class", "math inline") 15 | inline.markup = "$" 16 | inline.content = data.content 17 | return [inline] 18 | } 19 | } 20 | 21 | export function inlineMathRenderer(md: MarkdownIt, options?: IOptions): void { 22 | // Only create the renderer if it does not exist 23 | // For example, this may be defined in markdown-it-dollarmath 24 | if (!options?.roles?.math || md.renderer.rules[INLINE_MATH_RULE]) return 25 | 26 | md.renderer.rules[INLINE_MATH_RULE] = (tokens, idx) => { 27 | const renderer = options?.opts?.math?.renderer ?? (c => md.utils.escapeHtml(c)) 28 | const token = tokens[idx] 29 | const content = token.content.trim() 30 | const math = renderer(content, { displayMode: false }) 31 | return `${math}` 32 | } 33 | } 34 | 35 | export const math = { 36 | math: Math 37 | } 38 | -------------------------------------------------------------------------------- /src/roles/plugin.ts: -------------------------------------------------------------------------------- 1 | /** Parse a role, in MyST format */ 2 | // Ported from https://github.com/executablebooks/markdown-it-py/blob/master/markdown_it/extensions/myst_role/index.py 3 | // MIT License: https://github.com/executablebooks/markdown-it-py/blob/master/LICENSE 4 | 5 | import type MarkdownIt from "markdown-it/lib" 6 | import type StateCore from "markdown-it/lib/rules_core/state_core" 7 | import type StateInline from "markdown-it/lib/rules_inline/state_inline" 8 | import { Role } from "./main" 9 | import { IOptions } from "./types" 10 | import { inlineMathRenderer } from "./math" 11 | 12 | export default function rolePlugin(md: MarkdownIt, options: IOptions): void { 13 | if (options.parseRoles) { 14 | md.inline.ruler.before("backticks", "parse_roles", roleRule) 15 | } 16 | md.core.ruler.after( 17 | options.rolesAfter || "inline", 18 | "run_roles", 19 | runRoles(options.roles || {}) 20 | ) 21 | // fallback renderer for unhandled roles 22 | md.renderer.rules["role"] = (tokens, idx) => { 23 | const token = tokens[idx] 24 | return `${token.meta.name}${token.content}` 25 | } 26 | 27 | // TODO: when another renderer comes up, refactor into something a bit more scalable 28 | inlineMathRenderer(md, options) 29 | 30 | // TODO role_error renderer 31 | } 32 | 33 | function roleRule(state: StateInline, silent: boolean): boolean { 34 | // Check if the role is escaped 35 | if (state.src.charCodeAt(state.pos - 1) === 0x5c) { 36 | /* \ */ 37 | // TODO: this could be improved in the case of edge case '\\{', also multi-line 38 | return false 39 | } 40 | const match = ROLE_PATTERN.exec(state.src.slice(state.pos)) 41 | if (match == null) return false 42 | const [str, name, , content] = match 43 | // eslint-disable-next-line no-param-reassign 44 | state.pos += str.length 45 | 46 | if (!silent) { 47 | const token = state.push("role", "", 0) 48 | token.meta = { name } 49 | token.content = content 50 | } 51 | return true 52 | } 53 | 54 | // MyST role syntax format e.g. {role}`text` 55 | // TODO: support role with no value e.g. {role}`` 56 | let _x: RegExp 57 | try { 58 | _x = new RegExp("^\\{([a-zA-Z_\\-+:]{1,36})\\}(`+)(?!`)(.+?)(? boolean { 70 | function func(state: StateCore): boolean { 71 | for (const token of state.tokens) { 72 | if (token.type === "inline" && token.children) { 73 | const childTokens = [] 74 | for (const child of token.children) { 75 | // TODO role name translations 76 | if (child.type === "role" && child.meta?.name in roles) { 77 | try { 78 | const role = new roles[child.meta.name](state) 79 | const roleOpen = new state.Token("parsed_role_open", "", 1) 80 | roleOpen.content = child.content 81 | roleOpen.hidden = true 82 | roleOpen.meta = { name: child.meta.name } 83 | roleOpen.block = false 84 | const newTokens = [roleOpen] 85 | newTokens.push( 86 | ...role.run({ 87 | parentMap: token.map, 88 | content: child.content 89 | }) 90 | ) 91 | const roleClose = new state.Token("parsed_role_close", "", -1) 92 | roleClose.block = false 93 | roleClose.hidden = true 94 | newTokens.push(roleClose) 95 | childTokens.push(...newTokens) 96 | } catch (err) { 97 | const errorToken = new state.Token("role_error", "", 0) 98 | errorToken.content = child.content 99 | errorToken.info = child.info 100 | errorToken.meta = child.meta 101 | errorToken.map = child.map 102 | errorToken.meta.error_message = (err as Error).message 103 | errorToken.meta.error_name = (err as Error).name 104 | childTokens.push(errorToken) 105 | } 106 | } else { 107 | childTokens.push(child) 108 | } 109 | } 110 | token.children = childTokens 111 | } 112 | } 113 | return true 114 | } 115 | return func 116 | } 117 | -------------------------------------------------------------------------------- /src/roles/references.ts: -------------------------------------------------------------------------------- 1 | import type Token from "markdown-it/lib/token" 2 | import { resolveRefLater, TargetKind } from "../state/utils" 3 | import { IRoleData, Role } from "./main" 4 | 5 | const REF_PATTERN = /^(.+?)<([^<>]+)>$/ // e.g. 'Labeled Reference ' 6 | 7 | export class Eq extends Role { 8 | run(data: IRoleData): Token[] { 9 | const open = new this.state.Token("ref_open", "a", 1) 10 | const content = new this.state.Token("text", "", 0) 11 | const close = new this.state.Token("ref_close", "a", -1) 12 | resolveRefLater( 13 | this.state, 14 | { open, content, close }, 15 | { kind: "eq", label: data.content }, 16 | { 17 | kind: TargetKind.equation, 18 | contentFromTarget: target => { 19 | return `(${target.number})` 20 | } 21 | } 22 | ) 23 | return [open, content, close] 24 | } 25 | } 26 | 27 | export class NumRef extends Role { 28 | run(data: IRoleData): Token[] { 29 | const match = REF_PATTERN.exec(data.content) 30 | const [, modified, ref] = match ?? [] 31 | const withoutLabel = modified?.trim() 32 | const open = new this.state.Token("ref_open", "a", 1) 33 | const content = new this.state.Token("text", "", 0) 34 | const close = new this.state.Token("ref_close", "a", -1) 35 | resolveRefLater( 36 | this.state, 37 | { open, content, close }, 38 | { kind: "numref", label: ref || data.content, value: withoutLabel }, 39 | { 40 | contentFromTarget: target => { 41 | if (!match) return target.title.trim() 42 | return withoutLabel 43 | .replace(/%s/g, String(target.number)) 44 | .replace(/\{number\}/g, String(target.number)) 45 | } 46 | } 47 | ) 48 | return [open, content, close] 49 | } 50 | } 51 | 52 | export class Ref extends Role { 53 | run(data: IRoleData): Token[] { 54 | const match = REF_PATTERN.exec(data.content) 55 | const [, modified, ref] = match ?? [] 56 | const withoutLabel = modified?.trim() 57 | const open = new this.state.Token("ref_open", "a", 1) 58 | const content = new this.state.Token("text", "", 0) 59 | const close = new this.state.Token("ref_close", "a", -1) 60 | resolveRefLater( 61 | this.state, 62 | { open, content, close }, 63 | { kind: "ref", label: ref || data.content, value: withoutLabel }, 64 | { 65 | contentFromTarget: target => { 66 | return withoutLabel || target.title 67 | } 68 | } 69 | ) 70 | return [open, content, close] 71 | } 72 | } 73 | 74 | export const references = { 75 | eq: Eq, 76 | ref: Ref, 77 | numref: NumRef 78 | } 79 | -------------------------------------------------------------------------------- /src/roles/types.ts: -------------------------------------------------------------------------------- 1 | import type { Role } from "./main" 2 | 3 | export interface IMathRoleOptions { 4 | renderer?: (content: string, opts: { displayMode: boolean }) => string 5 | } 6 | 7 | /** Allowed options for directive plugin */ 8 | export interface IOptions { 9 | /** Parse roles of the form `` {name}`content` `` */ 10 | parseRoles?: boolean 11 | /** Core rule to run roles after (default: inline) */ 12 | rolesAfter?: string 13 | /** Mapping of names to roles */ 14 | roles?: Record 15 | opts?: { 16 | math?: IMathRoleOptions 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/state/plugin.ts: -------------------------------------------------------------------------------- 1 | import type MarkdownIt from "markdown-it" 2 | import { RuleCore } from "markdown-it/lib/parser_core" 3 | import type StateCore from "markdown-it/lib/rules_core/state_core" 4 | import { getDocState, Target } from "./utils" 5 | 6 | /** Allowed options for state plugin */ 7 | export type IOptions = Record // TODO: Figure out state numbering options 8 | 9 | function numberingRule(options: IOptions): RuleCore { 10 | return (state: StateCore) => { 11 | const env = getDocState(state) 12 | 13 | env.references.forEach(ref => { 14 | const { label, tokens, contentFromTarget } = ref 15 | 16 | const setError = (details: string, error?: Target) => { 17 | tokens.open.attrJoin("class", "error") 18 | tokens.open.tag = tokens.close.tag = "code" 19 | if (contentFromTarget && error) { 20 | tokens.content.content = contentFromTarget(error) 21 | } else { 22 | tokens.content.content = details 23 | } 24 | return true 25 | } 26 | 27 | const target = env.targets[label] 28 | if (!target) 29 | return setError(label, { 30 | kind: ref.kind || "", 31 | label, 32 | title: label, 33 | number: `"${label}"` 34 | }) 35 | if (ref.kind && target.kind !== ref.kind) { 36 | return setError(`Reference "${label}" does not match kind "${ref.kind}"`) 37 | } 38 | tokens.open.attrSet("href", `#${target.label}`) 39 | if (target.title) tokens.open.attrSet("title", target.title) 40 | if (contentFromTarget) tokens.content.content = contentFromTarget(target).trim() 41 | }) 42 | 43 | // TODO: Math that wasn't pre-numbered? 44 | return true 45 | } 46 | } 47 | 48 | /** 49 | * Create a rule that runs at the end of a markdown-it parser to go through all 50 | * references and add their targets. 51 | * 52 | * This `Rule` is done *last*, as you may reference a figure/equation, when that `Target` 53 | * has not yet been created. The references call `resolveRefLater` when they are being 54 | * created and pass their tokens such that the content of those tokens can be 55 | * dynamically updated. 56 | * 57 | * @param options (none currently) 58 | * @returns The markdown-it Rule 59 | */ 60 | export default function statePlugin(md: MarkdownIt, options: IOptions): void { 61 | md.core.ruler.push("docutils_number", numberingRule(options)) 62 | } 63 | -------------------------------------------------------------------------------- /src/state/utils.ts: -------------------------------------------------------------------------------- 1 | import StateCore from "markdown-it/lib/rules_core/state_core" 2 | import Token from "markdown-it/lib/token" 3 | 4 | /** The kind of the target as a TargetKind enum ('fig', 'eq', etc.) */ 5 | export enum TargetKind { 6 | equation = "eq", 7 | figure = "fig", 8 | table = "table", 9 | code = "code", 10 | section = "sec" 11 | } 12 | 13 | /** 14 | * Targets are created by figures or equations. 15 | * They are "things" that you can reference in documentation, e.g. Figure 1. 16 | */ 17 | export type Target = { 18 | /** The identifier or label of the target. */ 19 | label: string 20 | /** TargetKind enum ('fig', 'eq', etc.) or a custom string */ 21 | kind: TargetKind | string 22 | /** The default title that may be resolved in other places in a document. */ 23 | title: string // TODO: This should support markdown. 24 | /** This is the number that will be given to this target. 25 | * Note that it may be a `Number` or a `String` depending on 26 | * if there is Section numbering in place (e.g. `Figure 1.2`) 27 | */ 28 | number: number | string 29 | } 30 | 31 | export type Reference = { 32 | /** The identifier or label of the target. */ 33 | label: string 34 | tokens: { open: Token; content: Token; close: Token } 35 | /** TargetKind enum ('fig', 'eq', etc.) or a custom string */ 36 | kind?: TargetKind | string 37 | /** Return the content that should be shown in a reference given a target. 38 | * 39 | * For example, in a `numref`, you will replace `%s` with the `target.number`. 40 | */ 41 | contentFromTarget?: (target: Target) => string 42 | } 43 | 44 | /** 45 | * The `DocState` keeps track of targets, references and numbering. 46 | * 47 | * This is on the the state.env (see `getDocState`), and there 48 | * should only be one per markdown-it instance. 49 | */ 50 | export type DocState = { 51 | // Targets are something to link to, they are aranged by `name`, use `newTarget` 52 | targets: Record 53 | // Use `resolveRefLater` function to provide a reference that will resolve 54 | references: Reference[] 55 | // Keep track of numbering totals for any known, or arbitrary targets 56 | numbering: Record // TODO: this can also be a string 57 | } 58 | 59 | /** Safely create the document state for docutils */ 60 | export function getDocState(state: StateCore): DocState { 61 | const env = (state.env?.docutils as DocState) ?? {} 62 | if (!env.targets) env.targets = {} 63 | if (!env.references) env.references = [] 64 | if (!env.numbering) env.numbering = {} 65 | if (!state.env.docutils) state.env.docutils = env 66 | return env 67 | } 68 | 69 | /** 70 | * This is the information on `token.meta.docutils` 71 | */ 72 | export type MetaState = { 73 | /** Target included in the `token.meta.docutils` state. */ 74 | target: Target 75 | } 76 | 77 | /** 78 | * Safely create a namespaced meta information on a token 79 | * @param token A markdown-it token that will contain the target 80 | * @returns An object containing a `Target` 81 | */ 82 | export function getNamespacedMeta(token: Token): MetaState { 83 | const meta = token.meta?.docutils ?? {} 84 | if (!token.meta) token.meta = {} 85 | if (!token.meta.docutils) token.meta.docutils = meta 86 | return meta 87 | } 88 | 89 | /** Get the next number for an equation, figure, code or table 90 | * 91 | * Can input `{ docutils: { numbering: { eq: 100 } } }` to start counting at a different number. 92 | * 93 | * @param state MarkdownIt state that will be modified 94 | */ 95 | function nextNumber(state: StateCore, kind: TargetKind | string) { 96 | const env = getDocState(state) 97 | if (env.numbering[kind] == null) { 98 | env.numbering[kind] = 1 99 | } else { 100 | env.numbering[kind] += 1 101 | } 102 | return env.numbering[kind] 103 | } 104 | 105 | /** Create a new internal target. 106 | * 107 | * @param state MarkdownIt state that will be modified 108 | * @param label The reference label that will be normalized and used to associate the target. Note some directives use "name". 109 | * @param kind The target kind: "eq", "code", "table" or "fig" 110 | */ 111 | export function newTarget( 112 | state: StateCore, 113 | token: Token, 114 | kind: TargetKind, 115 | label: string, 116 | title: string, 117 | silent = false 118 | ): Target { 119 | const env = getDocState(state) 120 | const number = nextNumber(state, kind) 121 | const target: Target = { 122 | label, 123 | kind, 124 | number, 125 | title 126 | } 127 | if (!silent) { 128 | // Put the token in both the token.meta and the central environment 129 | const meta = getNamespacedMeta(token) 130 | meta.target = target 131 | token.attrSet("id", label) 132 | // TODO: raise error on duplicates 133 | env.targets[label] = target 134 | } 135 | return target 136 | } 137 | 138 | /** 139 | * Resolve a reference **in-place** in a following numbering pass. 140 | * 141 | * @param state Reference to the state object 142 | * @param tokens The open/content/close tokens of the reference 143 | * @param name Name/label/identifier of the target 144 | * @param opts Includes the reference `kind` and an optional way to create the reference content 145 | */ 146 | export function resolveRefLater( 147 | state: StateCore, 148 | tokens: Reference["tokens"], 149 | data: { label: string; kind: string; value?: string }, 150 | opts?: { 151 | kind?: TargetKind 152 | contentFromTarget?: Reference["contentFromTarget"] 153 | } 154 | ): void { 155 | tokens.open.meta = tokens.open.meta ?? {} 156 | tokens.open.meta.kind = data.kind 157 | tokens.open.meta.label = data.label 158 | tokens.open.meta.value = data.value 159 | const env = getDocState(state) 160 | env.references.push({ 161 | label: data.label, 162 | tokens, 163 | ...opts 164 | }) 165 | } 166 | -------------------------------------------------------------------------------- /src/style/_admonition.sass: -------------------------------------------------------------------------------- 1 | // Copied from: https://github.com/pradyunsg/furo/blob/fe0088363a163cd9d6ffaf274560533501e935b5/src/furo/assets/styles/content/_admonitions.sass#L1 2 | .admonition 3 | margin: 1rem auto 4 | padding: 0 0.5rem 0.5rem 0.5rem 5 | 6 | background: var(--color-admonition-background) 7 | // copied from base/_theme.sass body 8 | color: var(--color-foreground-primary) 9 | 10 | border-radius: 0.2rem 11 | border-left: 0.2rem solid var(--color-admonition-title) 12 | box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1) 13 | 14 | font-size: var(--admonition-font-size) 15 | 16 | overflow: auto 17 | page-break-inside: avoid 18 | 19 | // First element should have no margin, since the title has it. 20 | > :nth-child(2) 21 | margin-top: 0 22 | 23 | // Last item should have no margin, since we'll control that w/ padding 24 | > :last-child 25 | margin-bottom: 0 26 | 27 | 28 | // Defaults for all admonitions 29 | .admonition-title 30 | position: relative 31 | margin: 0 -0.5rem 0.5rem 32 | padding: 0.5rem 0.5rem 0.5rem 2rem 33 | 34 | font-weight: 500 35 | font-size: var(--admonition-title-font-size) 36 | background-color: var(--color-admonition-title-background) 37 | 38 | line-height: 1.3 39 | 40 | // Our fancy icon 41 | &::before 42 | content: "" 43 | position: absolute 44 | left: 0.5rem 45 | width: 1rem 46 | height: 1rem 47 | // color: var(--color-admonition-title) 48 | background-color: var(--color-admonition-title) 49 | 50 | mask-image: var(--icon-admonition-default) 51 | mask-repeat: no-repeat 52 | 53 | // 54 | // Variants 55 | // 56 | @each $type, $value in $admonitions 57 | &.#{$type} 58 | border-left-color: var(--color-admonition-title--#{$type}) 59 | > .admonition-title 60 | background-color: var(--color-admonition-title-background--#{$type}) 61 | &::before 62 | background-color: var(--color-admonition-title--#{$type}) 63 | mask-image: var(--icon-#{nth($value, 2)}) 64 | 65 | .admonition-todo > .admonition-title 66 | text-transform: uppercase 67 | -------------------------------------------------------------------------------- /src/style/_directive.sass: -------------------------------------------------------------------------------- 1 | .directive-unhandled, .directive-error 2 | margin: 1rem auto 3 | padding: 0.5rem 4 | outline: 0.0625rem solid lightgrey 5 | border-radius: 0.2rem 6 | overflow: auto 7 | 8 | // text color 9 | color: var(--color-foreground-primary) 10 | 11 | // First element should have no margin, since the title has it. 12 | > :nth-child(2) 13 | margin-top: 0 14 | 15 | // Last item should have no margin, since we'll control that w/ padding 16 | > :last-child 17 | margin-bottom: 0 18 | 19 | header 20 | margin-bottom: 0.5rem 21 | 22 | // The name of the directive 23 | mark 24 | text-decoration-line: underline 25 | background-color: unset 26 | color: var(--color-foreground-primary) 27 | 28 | .directive-unhandled 29 | outline: 0.0625rem solid lightgrey 30 | background-color: var(--color-directive-unhandled-background) 31 | 32 | .directive-error 33 | outline: 0.0625rem solid lightcoral 34 | background-color: var(--color-directive-error-background) 35 | -------------------------------------------------------------------------------- /src/style/_icons.scss: -------------------------------------------------------------------------------- 1 | // This file defines the various icons that are used throughout this theme. 2 | 3 | $icons: ( 4 | // Adapted from tabler-icons 5 | // url: https://tablericons.com/ 6 | "search": 7 | url('data:image/svg+xml;charset=utf-8,'), 8 | // Factored out from mkdocs-material on 24-Aug-2020. 9 | // url: https://squidfunk.github.io/mkdocs-material/reference/admonitions/ 10 | "pencil": 11 | url('data:image/svg+xml;charset=utf-8,'), 12 | "abstract": 13 | url('data:image/svg+xml;charset=utf-8,'), 14 | "info": 15 | url('data:image/svg+xml;charset=utf-8,'), 16 | "flame": 17 | url('data:image/svg+xml;charset=utf-8,'), 18 | "question": 19 | url('data:image/svg+xml;charset=utf-8,'), 20 | "warning": 21 | url('data:image/svg+xml;charset=utf-8,'), 22 | "failure": 23 | url('data:image/svg+xml;charset=utf-8,'), 24 | "spark": 25 | url('data:image/svg+xml;charset=utf-8,') 26 | ); 27 | -------------------------------------------------------------------------------- /src/style/_image.sass: -------------------------------------------------------------------------------- 1 | img 2 | box-sizing: border-box 3 | max-width: 100% 4 | height: auto 5 | 6 | img, figure 7 | &.align-left 8 | clear: left 9 | float: left 10 | margin-right: 1em 11 | 12 | &.align-right 13 | clear: right 14 | float: right 15 | margin-right: 1em 16 | 17 | &.align-center, &.align-default 18 | display: block 19 | margin-left: auto 20 | margin-right: auto 21 | 22 | figcaption 23 | font-style: italic 24 | text-align: center 25 | -------------------------------------------------------------------------------- /src/style/_role.sass: -------------------------------------------------------------------------------- 1 | .role-unhandled 2 | outline: 1px solid lightgrey 3 | border-radius: 0.2rem 4 | padding-left: 0.125rem 5 | padding-right: 0.125rem 6 | 7 | // the name of the role 8 | mark 9 | text-decoration-line: underline 10 | background-color: unset 11 | color: unset 12 | 13 | // the content of the role 14 | code 15 | padding-left: 0.15rem 16 | -------------------------------------------------------------------------------- /src/style/_tables.sass: -------------------------------------------------------------------------------- 1 | table 2 | border-radius: 0.2rem 3 | border-spacing: 0 4 | border-collapse: collapse 5 | 6 | box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1) 7 | 8 | & > caption 9 | text-align: center 10 | margin-bottom: 0.25rem 11 | 12 | th 13 | background: var(--color-background-secondary) 14 | color: var(--color-foreground-primary) 15 | 16 | td, 17 | th 18 | // Space things out properly 19 | padding: 0 0.25rem 20 | 21 | // Get the borders looking just-right. 22 | border-left: 1px solid var(--color-background-border) 23 | border-right: 1px solid var(--color-background-border) 24 | border-bottom: 1px solid var(--color-background-border) 25 | 26 | p 27 | margin: 0.25rem 28 | &:first-child 29 | border-left: none 30 | &:last-child 31 | border-right: none 32 | -------------------------------------------------------------------------------- /src/style/_variables.scss: -------------------------------------------------------------------------------- 1 | // This file defines all the knobs that can be tweaked by end users. 2 | // adapated from: https://github.com/pradyunsg/furo/blob/fe0088363a163cd9d6ffaf274560533501e935b5/src/furo/assets/styles/variables/_index.scss#L1 3 | 4 | // Admonitions 5 | // 6 | // Structure of these: (color, key-in-$icons). 7 | // The colors are translated into CSS variables below, and icons are used for 8 | // the declarations. 9 | $admonition-default: #651fff "abstract"; 10 | $admonitions: ( 11 | // Each of these has a reST directives for it. 12 | "caution": #ff9100 "spark", 13 | "warning": #ff9100 "warning", 14 | "danger": #ff5252 "spark", 15 | "attention": #ff5252 "warning", 16 | "error": #ff5252 "failure", 17 | "hint": #00c852 "question", 18 | "important": #00bfa5 "flame", 19 | "note": #00b0ff "pencil", 20 | "seealso": #448aff "info", 21 | "tip": #00c852 "info", 22 | "admonition-todo": #808080 "pencil" 23 | ); 24 | 25 | :root { 26 | // Base Colors 27 | --color-foreground-primary: black; // for main text and headings 28 | --color-foreground-secondary: #5a5c63; // for secondary text 29 | --color-foreground-border: #878787; // for content borders 30 | 31 | --color-background-primary: white; // for content 32 | --color-background-secondary: #f8f9fb; // for navigation + ToC 33 | --color-background-border: #eeebee; // for UI borders 34 | 35 | // Icons 36 | @each $name, $glyph in $icons { 37 | --icon-#{$name}: #{$glyph}; 38 | } 39 | --icon-admonition-default: var(--icon-#{nth($admonition-default, 2)}); 40 | 41 | // Directives 42 | --color-directive-unhandled-background: rgba(255, 255, 255, 0.3); 43 | --color-directive-error-background: rgba(255, 0, 0, 0.4); 44 | 45 | // Admonitions 46 | --admonition-font-size: 0.8125rem; 47 | --admonition-title-font-size: 0.8125rem; 48 | --icon-admonition-default: var(--icon-#{nth($admonition-default, 2)}); 49 | // Note this background is transparent in furo, but we want dark text to still work for dark backgrounds 50 | --color-admonition-background: rgba(255, 255, 255, 0.3); 51 | --color-admonition-title: #{nth($admonition-default, 1)}; 52 | --color-admonition-title-background: #{rgba(nth($admonition-default, 1), 0.1)}; 53 | 54 | @each $name, $values in $admonitions { 55 | --color-admonition-title--#{$name}: #{nth($values, 1)}; 56 | --color-admonition-title-background--#{$name}: #{rgba(nth($values, 1), 0.1)}; 57 | } 58 | } 59 | 60 | @media (prefers-color-scheme: dark) { 61 | :root { 62 | // Base Colors 63 | --color-foreground-primary: #ffffffd9; // for main text and headings 64 | --color-foreground-secondary: #9ca0a5; // for secondary text 65 | --color-foreground-border: #666666; // for content borders 66 | 67 | --color-background-primary: #131416; // for content 68 | --color-background-secondary: #1a1c1e; // for navigation + ToC 69 | --color-background-border: #303335; // for UI borders 70 | 71 | // Directives 72 | --color-directive-unhandled-background: #333338; 73 | --color-directive-error-background: rgba(180, 6, 6, 0.8); 74 | 75 | // Admonitions 76 | --color-admonition-background: #18181a; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/style/index.sass: -------------------------------------------------------------------------------- 1 | @import "icons" 2 | @import "variables" 3 | @import "role" 4 | @import "directive" 5 | @import "admonition" 6 | @import "image" 7 | @import "tables" 8 | -------------------------------------------------------------------------------- /src/syntaxTree.ts: -------------------------------------------------------------------------------- 1 | /** A tree representation of a linear markdown-it token stream. 2 | * 3 | * Ported from: markdown-it-py/markdown_it/tree.py 4 | */ 5 | import Token from "markdown-it/lib/token" 6 | 7 | interface NesterTokens { 8 | opening: Token 9 | closing: Token 10 | } 11 | 12 | /**A Markdown syntax tree node. 13 | 14 | A class that can be used to construct a tree representation of a linear 15 | `markdown-it` token stream. 16 | 17 | Each node in the tree represents either: 18 | - root of the Markdown document 19 | - a single unnested `Token` 20 | - a `Token` "_open" and "_close" token pair, and the tokens nested in 21 | between 22 | */ 23 | export class SyntaxTreeNode { 24 | private token?: Token 25 | private nester_tokens?: NesterTokens 26 | public parent?: SyntaxTreeNode 27 | public children: SyntaxTreeNode[] = [] 28 | /** Initialize a `SyntaxTreeNode` from a token stream. */ 29 | constructor(tokens: Token[], create_root = true) { 30 | this.children = [] 31 | if (create_root) { 32 | this._set_children_from_tokens(tokens) 33 | return 34 | } 35 | if (tokens.length === 0) { 36 | throw new Error("Tree creation: Can only create root from empty token sequence.") 37 | } 38 | if (tokens.length === 1) { 39 | const inline_token = tokens[0] 40 | if (inline_token.nesting) { 41 | throw new Error("Unequal nesting level at the start and end of token stream.") 42 | } 43 | this.token = inline_token 44 | if (inline_token.children !== null && inline_token.children.length > 0) { 45 | this._set_children_from_tokens(inline_token.children) 46 | } 47 | } else { 48 | this.nester_tokens = { opening: tokens[0], closing: tokens[tokens.length - 1] } 49 | this._set_children_from_tokens(tokens.slice(1, -1)) 50 | } 51 | } 52 | private _set_children_from_tokens(tokens: Token[]): void { 53 | const revered_tokens = [...tokens].reverse() 54 | let token: Token | undefined 55 | while (revered_tokens.length > 0) { 56 | token = revered_tokens.pop() 57 | if (!token) { 58 | break 59 | } 60 | if (!token.nesting) { 61 | this._add_child([token]) 62 | continue 63 | } 64 | if (token.nesting !== 1) { 65 | throw new Error("Invalid token nesting") 66 | } 67 | const nested_tokens = [token] 68 | let nesting = 1 69 | while (revered_tokens.length > 0 && nesting !== 0) { 70 | token = revered_tokens.pop() 71 | if (token) { 72 | nested_tokens.push(token) 73 | nesting += token.nesting 74 | } 75 | } 76 | if (nesting) { 77 | throw new Error(`unclosed tokens starting: ${nested_tokens[0]}`) 78 | } 79 | this._add_child(nested_tokens) 80 | } 81 | } 82 | private _add_child(tokens: Token[]): void { 83 | const child = new SyntaxTreeNode(tokens, false) 84 | child.parent = this 85 | this.children.push(child) 86 | } 87 | /** Recover the linear token stream. */ 88 | to_tokens(): Token[] { 89 | function recursive_collect_tokens(node: SyntaxTreeNode, token_list: Token[]): void { 90 | if (node.type === "root") { 91 | for (const child of node.children) { 92 | recursive_collect_tokens(child, token_list) 93 | } 94 | } else if (node.token) { 95 | token_list.push(node.token) 96 | } else { 97 | if (!node.nester_tokens) { 98 | throw new Error("No nested token available") 99 | } 100 | token_list.push(node.nester_tokens.opening) 101 | for (const child of node.children) { 102 | recursive_collect_tokens(child, token_list) 103 | } 104 | token_list.push(node.nester_tokens.closing) 105 | } 106 | } 107 | const tokens: Token[] = [] 108 | recursive_collect_tokens(this, tokens) 109 | return tokens 110 | } 111 | /** Is the node a special root node? */ 112 | get is_root(): boolean { 113 | return !(this.token || this.nester_tokens) 114 | } 115 | /** Is this node nested? */ 116 | get is_nested(): boolean { 117 | return !!this.nester_tokens 118 | } 119 | /** Get siblings of the node (including self). */ 120 | get siblings(): SyntaxTreeNode[] { 121 | if (!this.parent) { 122 | return [this] 123 | } 124 | return this.parent.children 125 | } 126 | /** Recursively yield all descendant nodes in the tree starting at self. 127 | * 128 | * The order mimics the order of the underlying linear token stream (i.e. depth first). 129 | */ 130 | *walk(include_self = true): Generator { 131 | if (include_self) { 132 | yield this 133 | } 134 | for (const child of this.children) { 135 | yield* child.walk(true) 136 | } 137 | } 138 | /** Get a string type of the represented syntax. 139 | * 140 | - "root" for root nodes 141 | - `Token.type` if the node represents an un-nested token 142 | - `Token.type` of the opening token, with "_open" suffix stripped, if 143 | the node represents a nester token pair 144 | */ 145 | get type(): string { 146 | if (this.is_root) { 147 | return "root" 148 | } 149 | if (this.token) { 150 | return this.token.type 151 | } 152 | if (this.nester_tokens?.opening.type.endsWith("_open")) { 153 | return this.nester_tokens?.opening.type.slice(0, -5) 154 | } 155 | if (this.nester_tokens) { 156 | return this.nester_tokens?.opening.type 157 | } 158 | throw new Error("no internal token") 159 | } 160 | private attribute_token(): Token { 161 | if (this.token) { 162 | return this.token 163 | } 164 | if (this.nester_tokens) { 165 | return this.nester_tokens.opening 166 | } 167 | throw new Error("Tree node does not have the accessed attribute") 168 | } 169 | get tag(): string { 170 | return this.attribute_token().tag 171 | } 172 | get level(): number { 173 | return this.attribute_token().level 174 | } 175 | get content(): string { 176 | return this.attribute_token().content 177 | } 178 | get markup(): string { 179 | return this.attribute_token().markup 180 | } 181 | get info(): string { 182 | return this.attribute_token().info 183 | } 184 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 185 | get meta(): any { 186 | return this.attribute_token().meta 187 | } 188 | get block(): boolean { 189 | return this.attribute_token().block 190 | } 191 | get hidden(): boolean { 192 | return this.attribute_token().hidden 193 | } 194 | get map(): [number, number] | null { 195 | return this.attribute_token().map 196 | } 197 | get attrs(): [string, string][] | null { 198 | return this.attribute_token().attrs 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /tests/colonFences.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/valid-title */ 2 | import MarkdownIt from "markdown-it" 3 | import { colonFencePlugin } from "markdown-it-myst-extras" 4 | import docutils_plugin from "../src" 5 | import readFixtures from "./readFixtures" 6 | 7 | // We have to test compatibility with colonFences here! 8 | 9 | describe("Parses colonFences", () => { 10 | test("colon fences parse", () => { 11 | const mdit = MarkdownIt().use(colonFencePlugin).use(docutils_plugin) 12 | const parse = mdit.parse(`:::{tip}\nThis is a tip in a fence!\n:::`, {}) 13 | expect(parse[0].type).toBe("parsed_directive_open") 14 | }) 15 | test("colon fences render", () => { 16 | const mdit = MarkdownIt().use(colonFencePlugin).use(docutils_plugin) 17 | const rendered = mdit.render(`:::{tip}\nfence\n:::`) 18 | expect(rendered.trim()).toEqual( 19 | '' 20 | ) 21 | }) 22 | }) 23 | 24 | describe("Parses fenced directives", () => { 25 | readFixtures("directives.fence").forEach(([name, text, expected]) => { 26 | const mdit = MarkdownIt().use(colonFencePlugin).use(docutils_plugin) 27 | const rendered = mdit.render(text) 28 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tests/directiveStructure.spec.ts: -------------------------------------------------------------------------------- 1 | import directiveToData, { DirectiveToken } from "../src/directives/main" 2 | import { unchanged, class_option } from "../src/directives/options" 3 | 4 | describe("directive parser", () => { 5 | it('parses a "null" directive (no args, content)', () => { 6 | const token = new DirectiveToken("name", "", "", [0, 1]) 7 | const { args, options, body } = directiveToData(token, {}) 8 | expect(args).toEqual([]) 9 | expect(options).toEqual({}) 10 | expect(body).toEqual("") 11 | }) 12 | it("parses a single arg directive", () => { 13 | const token = new DirectiveToken("name", "arg with space", "", [0, 1]) 14 | const output = directiveToData(token, { 15 | required_arguments: 1, 16 | final_argument_whitespace: true 17 | }) 18 | expect(output).toEqual({ 19 | map: [0, 1], 20 | args: ["arg with space"], 21 | options: {}, 22 | body: "", 23 | bodyMap: [1, 1] 24 | }) 25 | }) 26 | it("parses a multi arg directive", () => { 27 | const token = new DirectiveToken("name", "arg with space", "", [0, 1]) 28 | const output = directiveToData(token, { 29 | required_arguments: 2, 30 | final_argument_whitespace: true 31 | }) 32 | expect(output).toEqual({ 33 | map: [0, 1], 34 | args: ["arg", "with space"], 35 | options: {}, 36 | body: "", 37 | bodyMap: [1, 1] 38 | }) 39 | }) 40 | it("parses a directive with content only", () => { 41 | const token = new DirectiveToken("name", "first line content", "", [0, 1]) 42 | const output = directiveToData(token, { 43 | has_content: true 44 | }) 45 | expect(output).toEqual({ 46 | map: [0, 1], 47 | args: [], 48 | options: {}, 49 | body: "first line content", 50 | bodyMap: [0, 0] 51 | }) 52 | }) 53 | it("parses a directive with options as ---", () => { 54 | const token = new DirectiveToken( 55 | "name", 56 | "", 57 | "---\na: 1\nb: class1 class2\n---", 58 | [0, 5] 59 | ) 60 | const output = directiveToData(token, { 61 | option_spec: { a: unchanged, b: class_option } 62 | }) 63 | expect(output).toEqual({ 64 | map: [0, 5], 65 | args: [], 66 | options: { a: "1", b: ["class1", "class2"] }, 67 | body: "", 68 | bodyMap: [5, 5] 69 | }) 70 | }) 71 | it("parses a directive with options as :", () => { 72 | const token = new DirectiveToken("name", "", ":a: 1", [0, 2]) 73 | const output = directiveToData(token, { option_spec: { a: unchanged } }) 74 | expect(output).toEqual({ 75 | map: [0, 2], 76 | args: [], 77 | options: { a: "1" }, 78 | body: "", 79 | bodyMap: [2, 2] 80 | }) 81 | }) 82 | it("parses a directive with options as --- and content", () => { 83 | const token = new DirectiveToken( 84 | "name", 85 | "", 86 | "---\na: 1\n---\ncontent\nlines", 87 | [0, 6] 88 | ) 89 | const output = directiveToData(token, { 90 | has_content: true, 91 | option_spec: { a: unchanged } 92 | }) 93 | expect(output).toEqual({ 94 | map: [0, 6], 95 | args: [], 96 | options: { a: "1" }, 97 | body: "content\nlines", 98 | bodyMap: [4, 5] 99 | }) 100 | }) 101 | it("parses a directive with options as : and content", () => { 102 | const token = new DirectiveToken("name", "", ":a: 1\n\ncontent\nlines", [0, 5]) 103 | const output = directiveToData(token, { 104 | has_content: true, 105 | option_spec: { a: unchanged } 106 | }) 107 | expect(output).toEqual({ 108 | map: [0, 5], 109 | args: [], 110 | options: { a: "1" }, 111 | body: "content\nlines", 112 | bodyMap: [3, 4] 113 | }) 114 | }) 115 | it("parses a directive with options as : and content with no newline", () => { 116 | const token = new DirectiveToken("name", "", ":a: 1\ncontent\nlines", [0, 4]) 117 | const output = directiveToData(token, { 118 | has_content: true, 119 | option_spec: { a: unchanged } 120 | }) 121 | expect(output).toEqual({ 122 | map: [0, 4], 123 | args: [], 124 | options: { a: "1" }, 125 | body: "content\nlines", 126 | bodyMap: [2, 3] 127 | }) 128 | }) 129 | }) 130 | 131 | // TODO more tests, including exception states (parity with myst-parser) 132 | -------------------------------------------------------------------------------- /tests/fixtures/directives.admonitions.md: -------------------------------------------------------------------------------- 1 | Admonition: 2 | . 3 | ```{admonition} This is a **title** 4 | An example of an admonition with custom _title_. 5 | ``` 6 | . 7 | 11 | . 12 | 13 | Note on split lines: 14 | . 15 | ```{note} An example 16 | of an admonition on two lines. 17 | ``` 18 | . 19 | 24 | . 25 | 26 | [FIX] Note on a single line [See #154](https://github.com/executablebooks/MyST-Parser/issues/154) 27 | . 28 | ```{danger} An example of an admonition on a single line. 29 | ``` 30 | . 31 | 35 | . 36 | 37 | Admonition with overridding class name 38 | . 39 | ```{admonition} This is a title 40 | :class: tip 41 | An example of a `tip` with a custom _title_. 42 | ``` 43 | . 44 | 48 | . 49 | 50 | nested-admonition 51 | . 52 | ````{note} This is a note 53 | ```{warning} This is a nested warning 54 | ``` 55 | ```` 56 | . 57 | 66 | . 67 | 68 | `attention` admonition: 69 | . 70 | ```{attention} 71 | An example of a attention admonition. 72 | ``` 73 | . 74 | 78 | . 79 | 80 | `caution` admonition: 81 | . 82 | ```{caution} 83 | An example of a caution admonition. 84 | ``` 85 | . 86 | 90 | . 91 | 92 | `danger` admonition: 93 | . 94 | ```{danger} 95 | An example of a danger admonition. 96 | ``` 97 | . 98 | 102 | . 103 | 104 | `error` admonition: 105 | . 106 | ```{error} 107 | An example of an error admonition. 108 | ``` 109 | . 110 | 114 | . 115 | 116 | `hint` admonition: 117 | . 118 | ```{hint} 119 | An example of a hint admonition. 120 | ``` 121 | . 122 | 126 | . 127 | 128 | `important` admonition: 129 | . 130 | ```{important} 131 | An example of an important admonition. 132 | ``` 133 | . 134 | 138 | . 139 | 140 | `note` admonition: 141 | . 142 | ```{note} 143 | An example of a note admonition. 144 | ``` 145 | . 146 | 150 | . 151 | 152 | `tip` admonition: 153 | . 154 | ```{tip} 155 | An example of a tip admonition. 156 | ``` 157 | . 158 | 162 | . 163 | 164 | `warning` admonition: 165 | . 166 | ```{warning} 167 | An example of a warning admonition. 168 | ``` 169 | . 170 | 174 | . 175 | 176 | `see also` admonition: 177 | . 178 | ```{seealso} 179 | See other things here! 180 | ``` 181 | . 182 | 186 | . 187 | 188 | 189 | `see also` admonition with class, bump title 190 | . 191 | ```{seealso} Not a title 192 | :class: tip 193 | See other things here! 194 | ``` 195 | . 196 | 201 | . 202 | 203 | 204 | `see also` admonition with class, bump title new paragraph 205 | . 206 | ```{seealso} Not a title 207 | :class: tip 208 | 209 | See other things here! 210 | ``` 211 | . 212 | 217 | . 218 | -------------------------------------------------------------------------------- /tests/fixtures/directives.fence.md: -------------------------------------------------------------------------------- 1 | Unknown fence directive 2 | . 3 | :::{unknown} argument 4 | content 5 | ::: 6 | . 7 | 11 | . 12 | 13 | Fenced admonition: 14 | . 15 | :::{admonition} This is a **title** 16 | An example of an admonition with custom _title_. 17 | ::: 18 | . 19 | 23 | . 24 | 25 | Fenced note on split lines: 26 | . 27 | :::{note} An example 28 | of an admonition on two lines. 29 | ::: 30 | . 31 | 36 | . 37 | -------------------------------------------------------------------------------- /tests/fixtures/directives.images.md: -------------------------------------------------------------------------------- 1 | image 2 | . 3 | ```{image} https://via.placeholder.com/150 4 | ``` 5 | . 6 | 7 | . 8 | 9 | image with options 10 | . 11 | ```{image} https://via.placeholder.com/150 12 | :align: center 13 | :alt: some *alt* 14 | :class: other 15 | ``` 16 | . 17 | some alt 18 | . 19 | 20 | basic figure 21 | . 22 | ```{figure} https://jupyterbook.org/_static/logo.png 23 | The Jupyter Book Logo! 24 | ``` 25 | . 26 |
27 | 28 |
29 |

The Jupyter Book Logo!

30 |
31 |
32 | . 33 | 34 | figure with options 35 | . 36 | ```{figure} https://via.placeholder.com/150 37 | :align: center 38 | :alt: description 39 | 40 | A **caption** 41 | ``` 42 | . 43 |
44 | description 45 |
46 |

A caption

47 |
48 |
49 | . 50 | 51 | named figure 52 | . 53 | ```{figure} https://via.placeholder.com/150 54 | :align: center 55 | :name: placeholder 56 | 57 | A **caption** 58 | ``` 59 | . 60 |
61 | 62 |
63 |

A caption

64 |
65 |
66 | . 67 | 68 | named figure with no space between options 69 | . 70 | ```{figure} https://jupyterbook.org/_static/logo.png 71 | :name: test2 72 | The Jupyter Book Logo! 73 | ``` 74 | . 75 |
76 | 77 |
78 |

The Jupyter Book Logo!

79 |
80 |
81 | . 82 | -------------------------------------------------------------------------------- /tests/fixtures/directives.math.md: -------------------------------------------------------------------------------- 1 | math directive 2 | . 3 | ```{math} 4 | w_{t+1} = (1 + r_{t+1}) s(w_t) + y_{t+1} 5 | ``` 6 | . 7 |
8 | w_{t+1} = (1 + r_{t+1}) s(w_t) + y_{t+1} 9 |
10 | . 11 | 12 | math directive with label 13 | . 14 | ```{math} 15 | :label: my_label 16 | w_{t+1} = (1 + r_{t+1}) s(w_t) + y_{t+1} 17 | ``` 18 | . 19 |
20 | w_{t+1} = (1 + r_{t+1}) s(w_t) + y_{t+1} 21 |
22 | . 23 | -------------------------------------------------------------------------------- /tests/fixtures/directives.md: -------------------------------------------------------------------------------- 1 | unknown 2 | . 3 | ```{unknown} argument 4 | content 5 | ``` 6 | . 7 | 11 | . 12 | 13 | code 14 | . 15 | ```{code} 16 | a 17 | ``` 18 | . 19 |
a
 20 | 
21 | . 22 | 23 | code-language 24 | . 25 | ```{code} python 26 | a 27 | ``` 28 | . 29 |
a
 30 | 
31 | . 32 | 33 | code-block 34 | . 35 | ```{code-block} 36 | a 37 | ``` 38 | . 39 |
a
 40 | 
41 | . 42 | 43 | code-block-language 44 | . 45 | ```{code-block} python 46 | a 47 | ``` 48 | . 49 |
a
 50 | 
51 | . 52 | 53 | code-cell 54 | . 55 | ```{code-cell} 56 | a 57 | ``` 58 | . 59 |
a
 60 | 
61 | . 62 | 63 | code-cell-language 64 | . 65 | ```{code-cell} python 66 | :other: value 67 | 68 | a 69 | ``` 70 | . 71 |
a
 72 | 
73 | . 74 | 75 | list-table 76 | . 77 | ```{list-table} 78 | * - Row 1, Column 1 79 | - Row 1, Column 2 80 | - Row 1, Column 3 81 | * - Row 2, Column 1 82 | - Row 2, Column 2 83 | - Row 2, Column 3 84 | * - Row 3, Column 1 85 | - Row 3, Column 2 86 | - Row 3, Column 3 87 | ``` 88 | . 89 |
Row 1, Column 1Row 1, Column 2Row 1, Column 3
Row 2, Column 1Row 2, Column 2Row 2, Column 3
Row 3, Column 1Row 3, Column 2Row 3, Column 3
90 | . 91 | 92 | list-table-with-head 93 | . 94 | ```{list-table} Caption *text* 95 | :header-rows: 1 96 | :align: center 97 | :class: myclass 98 | 99 | * - Head 1, Column 1 100 | - Head 1, Column 2 101 | - Head 1, Column 3 102 | * - Row 1, Column 1 103 | - Row 1, Column 2 104 | - Row 1, Column 3 105 | * - Row 2, Column 1 106 | - Row 2, Column 2 107 | - Row 2, **Column 3** 108 | ``` 109 | . 110 |
Caption text
Head 1, Column 1Head 1, Column 2Head 1, Column 3
Row 1, Column 1Row 1, Column 2Row 1, Column 3
Row 2, Column 1Row 2, Column 2Row 2, Column 3
111 | . 112 | -------------------------------------------------------------------------------- /tests/fixtures/roles.html.md: -------------------------------------------------------------------------------- 1 | Subscript: 2 | . 3 | H{sub}`2`O 4 | . 5 |

H2O

6 | . 7 | 8 | Superscript: 9 | . 10 | 4{sup}`th` of July 11 | . 12 |

4th of July

13 | . 14 | 15 | Abbr with title: 16 | . 17 | {abbr}`CSS (Cascading Style Sheets)` 18 | . 19 |

CSS

20 | . 21 | 22 | Abbr without title: 23 | . 24 | {abbr}`CSS` 25 | . 26 |

CSS

27 | . 28 | 29 | Abbr with poor brackets: 30 | . 31 | {abbr}`CSS (Cascading) Style( Sheets)` 32 | . 33 |

CSS (Cascading) Style

34 | . 35 | -------------------------------------------------------------------------------- /tests/fixtures/roles.math.md: -------------------------------------------------------------------------------- 1 | single character inline equation. (valid=True) 2 | . 3 | {math}`a` 4 | . 5 |

a

6 | . 7 | 8 | inline equation with single greek character (valid=True) 9 | . 10 | {math}`\\varphi` 11 | . 12 |

\\varphi

13 | . 14 | 15 | simple equation starting and ending with numbers. (valid=True) 16 | . 17 | {math}`1+1=2` 18 | . 19 |

1+1=2

20 | . 21 | 22 | simple equation including special html character. (valid=True) 23 | . 24 | {math}`1+1<3` 25 | . 26 |

1+1<3

27 | . 28 | -------------------------------------------------------------------------------- /tests/fixtures/roles.md: -------------------------------------------------------------------------------- 1 | role-unhandled 2 | . 3 | A role {name}`content` in paragraph 4 | . 5 |

A role namecontent in paragraph

6 | . 7 | 8 | role-raw 9 | . 10 | {raw}`content` 11 | . 12 |

content

13 | . 14 | -------------------------------------------------------------------------------- /tests/fixtures/roles.references.eq.md: -------------------------------------------------------------------------------- 1 | math directive with label and references in eq role 2 | . 3 | ```{math} 4 | :label: my_label 5 | w_{t+1} = (1 + r_{t+1}) s(w_t) + y_{t+1} 6 | ``` 7 | Check out {eq}`my_label`! 8 | . 9 |
10 | w_{t+1} = (1 + r_{t+1}) s(w_t) + y_{t+1} 11 |
12 | 13 |

Check out (1)!

14 | . 15 | -------------------------------------------------------------------------------- /tests/fixtures/roles.references.numref.md: -------------------------------------------------------------------------------- 1 | Testing named figures and numbered references 2 | . 3 | ```{figure} https://via.placeholder.com/150 4 | :name: test3 5 | 6 | Fig 1 7 | ``` 8 | 9 | ```{figure} https://via.placeholder.com/150 10 | :name: test4 11 | 12 | Fig 2 13 | ``` 14 | The reference to {ref}`test3` and {ref}`test4`. 15 | {numref}`test3` 16 | {numref}`test4` 17 | {numref}`Hi 1 ` 18 | {numref}`Hi 2 ` 19 | {numref}`This is 1: %s ` 20 | {numref}`This is 2: %s ` 21 | {numref}`This is 2: {number} ` 22 | {numref}`test5` 23 | {numref}`Not there %s ` 24 | {numref}`Not there {number} ` 25 | {numref}`Not there {number}` 26 | . 27 |
28 | 29 |
30 |

Fig 1

31 |
32 |
33 | 34 |
35 | 36 |
37 |

Fig 2

38 |
39 |
40 | 41 |

The reference to Fig 1 and Fig 2. 42 | Fig 1 43 | Fig 2 44 | Hi 1 45 | Hi 2 46 | This is 1: 1 47 | This is 2: 2 48 | This is 2: 2 49 | test5 50 | Not there "test5" 51 | Not there "test5" 52 | Not there {number}

53 | . 54 | -------------------------------------------------------------------------------- /tests/fixturesDirectives.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/valid-title */ 2 | import MarkdownIt from "markdown-it" 3 | import docutils_plugin from "../src" 4 | import readFixtures, { basicMathRenderer } from "./readFixtures" 5 | 6 | describe("Parses directives", () => { 7 | readFixtures("directives").forEach(([name, text, expected]) => { 8 | const mdit = MarkdownIt().use(docutils_plugin) 9 | const rendered = mdit.render(text) 10 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 11 | }) 12 | readFixtures("directives.admonitions").forEach(([name, text, expected]) => { 13 | const mdit = MarkdownIt().use(docutils_plugin) 14 | const rendered = mdit.render(text) 15 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 16 | }) 17 | readFixtures("directives.images").forEach(([name, text, expected]) => { 18 | const mdit = MarkdownIt().use(docutils_plugin) 19 | const rendered = mdit.render(text) 20 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 21 | }) 22 | }) 23 | 24 | describe("Parses math directives", () => { 25 | readFixtures("directives.math").forEach(([name, text, expected]) => { 26 | const mdit = MarkdownIt().use(docutils_plugin) 27 | basicMathRenderer(mdit) 28 | const rendered = mdit.render(text) 29 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /tests/fixturesRoles.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/valid-title */ 2 | import MarkdownIt from "markdown-it" 3 | import docutils_plugin from "../src" 4 | import readFixtures, { basicMathRenderer } from "./readFixtures" 5 | 6 | describe("Parses roles", () => { 7 | readFixtures("roles").forEach(([name, text, expected]) => { 8 | const mdit = MarkdownIt().use(docutils_plugin) 9 | const rendered = mdit.render(text) 10 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 11 | }) 12 | readFixtures("roles.html").forEach(([name, text, expected]) => { 13 | const mdit = MarkdownIt().use(docutils_plugin) 14 | const rendered = mdit.render(text) 15 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 16 | }) 17 | readFixtures("roles.math").forEach(([name, text, expected]) => { 18 | const mdit = MarkdownIt().use(docutils_plugin) 19 | const rendered = mdit.render(text) 20 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 21 | }) 22 | readFixtures("roles.references.numref").forEach(([name, text, expected]) => { 23 | const mdit = MarkdownIt().use(docutils_plugin) 24 | const rendered = mdit.render(text) 25 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 26 | }) 27 | readFixtures("roles.references.eq").forEach(([name, text, expected]) => { 28 | const mdit = MarkdownIt().use(docutils_plugin) 29 | basicMathRenderer(mdit) 30 | const rendered = mdit.render(text) 31 | it(name, () => expect(rendered.trim()).toEqual((expected || "").trim())) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /tests/readFixtures.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import type MarkdownIt from "markdown-it" 3 | 4 | /** Read a "fixtures" file, containing a set of tests: 5 | * 6 | * test name 7 | * . 8 | * input text 9 | * . 10 | * expected output 11 | * . 12 | * 13 | * */ 14 | export default function readFixtures(name: string): string[][] { 15 | const fixtures = fs.readFileSync(`tests/fixtures/${name}.md`).toString() 16 | return fixtures.split("\n.\n\n").map(s => s.split("\n.\n")) 17 | } 18 | 19 | /** 20 | * The markdown math renderers are in other packages. This is for tests. 21 | */ 22 | export function basicMathRenderer(mdit: MarkdownIt): void { 23 | mdit.renderer.rules.math_block = (tokens, idx) => { 24 | const token = tokens[idx] 25 | return `
\n${token.content.trimEnd()}\n
\n` 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/syntaxTree.spec.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it" 2 | import { SyntaxTreeNode } from "../src/syntaxTree" 3 | 4 | const EXAMPLE_MARKDOWN = ` 5 | ## Heading here 6 | 7 | Some paragraph text and **emphasis here** and more text here. 8 | ` 9 | 10 | describe("syntaxTree class", () => { 11 | test("round-trip", () => { 12 | const tokens = MarkdownIt().parse(EXAMPLE_MARKDOWN, {}) 13 | const tokens_after_roundtrip = new SyntaxTreeNode(tokens).to_tokens() 14 | expect(tokens_after_roundtrip).toEqual(tokens) 15 | }) 16 | test("property pass-through", () => { 17 | const tokens = MarkdownIt().parse(EXAMPLE_MARKDOWN, {}) 18 | const heading_open = tokens[0] 19 | const heading_node = new SyntaxTreeNode(tokens).children[0] 20 | expect(heading_node.tag).toEqual(heading_open.tag) 21 | expect(heading_node.map).toEqual(heading_open.map) 22 | expect(heading_node.level).toEqual(heading_open.level) 23 | expect(heading_node.content).toEqual(heading_open.content) 24 | expect(heading_node.markup).toEqual(heading_open.markup) 25 | expect(heading_node.info).toEqual(heading_open.info) 26 | expect(heading_node.meta).toEqual(heading_open.meta) 27 | expect(heading_node.block).toEqual(heading_open.block) 28 | expect(heading_node.hidden).toEqual(heading_open.hidden) 29 | }) 30 | test("walk", () => { 31 | const tokens = MarkdownIt().parse(EXAMPLE_MARKDOWN, {}) 32 | const nodes = [...new SyntaxTreeNode(tokens).walk()] 33 | expect(nodes.map(node => node.type)).toEqual([ 34 | "root", 35 | "heading", 36 | "inline", 37 | "text", 38 | "paragraph", 39 | "inline", 40 | "text", 41 | "strong", 42 | "text", 43 | "text" 44 | ]) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | // module is overridden from the build:esm/build:cjs scripts 5 | "module": "es2015", 6 | "esModuleInterop": true, 7 | "noImplicitAny": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "sourceMap": true, 11 | // outDir is overridden from the build:esm/build:cjs scripts 12 | "outDir": "dist/types", 13 | "baseUrl": "src", 14 | "paths": { 15 | "*": ["node_modules/*"] 16 | }, 17 | // Type roots allows it to be included in a yarn workspace 18 | "typeRoots": ["./node_modules/@types", "./types", "../../node_modules/@types"], 19 | "resolveJsonModule": true, 20 | // Ignore node_modules, etc. 21 | "skipLibCheck": true, 22 | "forceConsistentCasingInFileNames": true 23 | }, 24 | "include": ["src/**/*"], 25 | "exclude": [] 26 | } 27 | --------------------------------------------------------------------------------