├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── unit.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── sample-code-block.png ├── src ├── all.js ├── common.js ├── generator.js └── index.js ├── test.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier"], 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "env": { 11 | "node": true, 12 | "es6": true, 13 | "jest": true 14 | }, 15 | "plugins": ["node"], 16 | "rules": { 17 | "no-unused-vars": ["warn"], 18 | "node/no-missing-require": ["error"] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: timlrx -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '16.x' 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Run unit tests 19 | run: npm run test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | coverage 5 | dist/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Timothy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rehype-prism-plus 2 | 3 | ![sample-code-block-output](sample-code-block.png) 4 | 5 | [rehype] plugin to highlight code blocks in HTML with [Prism] (via [refractor]) with additional line highlighting and line numbers functionalities. 6 | 7 | Inspired by and uses a compatible API as [@mapbox/rehype-prism](https://github.com/mapbox/rehype-prism) with additional support for line-highlighting, line numbers and diff code blocks. 8 | 9 | Tested to work with [xdm] and mdx v2 libraries such as [mdx-bundler]. If you are using mdx v1 libraries such as [next-mdx-remote], you will need to patch it with the `fixMetaPlugin` discussed in this [issue](https://github.com/timlrx/rehype-prism-plus/issues/20), before `rehype-prism-plus`. 10 | 11 | An [appropriate stylesheet](#styling) should be loaded to style the language tokens, format line numbers and highlight lines. You can specify language for diff code blocks by using diff-[language] to enable syntax highlighting in diffs. 12 | 13 | ## Installation 14 | 15 | This package is [ESM only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c): 16 | Node 12+ is needed to use it and it must be `import`ed instead of `require`d. 17 | 18 | ``` 19 | npm install rehype-prism-plus 20 | ``` 21 | 22 | ## Usage 23 | 24 | The following import paths are supported: 25 | 26 | - `rehype-prism-plus/generator`, generator function. Can be used to generate a rehype prism plugin that works on your desired languages. 27 | - `rehype-prism-plus/common`, [rehype plugin]. Supports the languages in `refractor/lib/common.js`. 28 | - `rehype-prism-plus/all`, [rehype plugin]. Works with all [language supported by refractor]. 29 | - `rehype-prism-plus`, re-exports the above 3 packages with `rehype-prism-plus/all` as the default export. 30 | 31 | Some examples of how you might use the rehype plugin: 32 | 33 | ```js 34 | import rehype from 'rehype' 35 | import rehypePrism from 'rehype-prism-plus' 36 | 37 | rehype().use(rehypePrism).process(/* some html */) 38 | ``` 39 | 40 | Here's an example of syntax highlighting in Markdown, with [xdm] 41 | 42 | ```js 43 | import { compile } from 'xdm' 44 | import rehypePrism from 'rehype-prism-plus' 45 | 46 | async function main(code) { 47 | console.log(String(await compile(code, { rehypePlugins: [rehypePrism] }))) 48 | } 49 | 50 | main(`~~~js 51 | console.log(1) 52 | ~~~`) 53 | ``` 54 | 55 | ## Sample markdown to HTML output 56 | 57 | Input: 58 | 59 | ````md 60 | ```js {1,3-4} showLineNumbers 61 | function fancyAlert(arg) { 62 | if (arg) { 63 | $.facebox({ div: '#foo' }) 64 | } 65 | } 66 | ``` 67 | ```` 68 | 69 | HTML Output: 70 | 71 | ```html 72 | 73 |
74 | function 75 | fancyAlert(arg) 77 | { 78 |
79 |
80 | if 81 | (arg) 82 | { 83 |
84 |
85 | $.facebox({ div: 87 | '#foo' 88 | }) 89 |
90 |
91 | } 92 |
93 |
94 | } 95 |
97 | ``` 98 | 99 | ## Generating 100 | 101 | To customise the languages for your own prism plugin: 102 | 103 | ```js 104 | import { refractor } from 'refractor/lib/core.js' 105 | import markdown from 'refractor/lang/markdown.js' 106 | import rehypePrismGenerator from 'rehype-prism-plus/generator' 107 | 108 | refractor.register(markdown) 109 | const myPrismPlugin = rehypePrismGenerator(refractor) 110 | ``` 111 | 112 | ## Styling 113 | 114 | To style the language tokens, you can just copy them from any prismjs compatible ones. Here's a list of [themes](https://github.com/PrismJS/prism-themes). 115 | 116 | In addition, the following styles should be added for line highlighting and line numbers to work correctly: 117 | 118 | ```css 119 | pre { 120 | overflow-x: auto; 121 | } 122 | 123 | /** 124 | * Inspired by gatsby remark prism - https://www.gatsbyjs.com/plugins/gatsby-remark-prismjs/ 125 | * 1. Make the element just wide enough to fit its content. 126 | * 2. Always fill the visible space in .code-highlight. 127 | */ 128 | .code-highlight { 129 | float: left; /* 1 */ 130 | min-width: 100%; /* 2 */ 131 | } 132 | 133 | .code-line { 134 | display: block; 135 | padding-left: 16px; 136 | padding-right: 16px; 137 | margin-left: -16px; 138 | margin-right: -16px; 139 | border-left: 4px solid rgba(0, 0, 0, 0); /* Set placeholder for highlight accent border color to transparent */ 140 | } 141 | 142 | .code-line.inserted { 143 | background-color: rgba(16, 185, 129, 0.2); /* Set inserted line (+) color */ 144 | } 145 | 146 | .code-line.deleted { 147 | background-color: rgba(239, 68, 68, 0.2); /* Set deleted line (-) color */ 148 | } 149 | 150 | .highlight-line { 151 | margin-left: -16px; 152 | margin-right: -16px; 153 | background-color: rgba(55, 65, 81, 0.5); /* Set highlight bg color */ 154 | border-left: 4px solid rgb(59, 130, 246); /* Set highlight accent border color */ 155 | } 156 | 157 | .line-number::before { 158 | display: inline-block; 159 | width: 1rem; 160 | text-align: right; 161 | margin-right: 16px; 162 | margin-left: -8px; 163 | color: rgb(156, 163, 175); /* Line number color */ 164 | content: attr(line); 165 | } 166 | ``` 167 | 168 | Here's the styled output using the prism-night-owl theme: 169 | 170 | ![sample-code-block-output](sample-code-block.png) 171 | 172 | For more information on styling of language tokens, consult [refractor] and [Prism]. 173 | 174 | ## API 175 | 176 | `rehype().use(rehypePrism, [options])` 177 | 178 | Syntax highlights `pre > code`. 179 | Under the hood, it uses [refractor], which is a virtual version of [Prism]. 180 | 181 | The code language is configured by setting a `language-{name}` class on the `` element. 182 | You can use any [language supported by refractor]. 183 | 184 | If no `language-{name}` class is found on a `` element, it will be skipped. 185 | 186 | ### options 187 | 188 | #### options.ignoreMissing 189 | 190 | Type: `boolean`. 191 | Default: `false`. 192 | 193 | By default, if `{name}` does not correspond to a [language supported by refractor] an error will be thrown. 194 | 195 | If you would like to silently skip `` elements with invalid languages or support line numbers and line highlighting for code blocks without a specified language, set this option to `true`. 196 | 197 | #### options.defaultLanguage 198 | 199 | Type: `string`. 200 | Default: ``. 201 | 202 | Uses the specified language as the default if none is specified. Takes precedence over `ignoreMissing`. 203 | 204 | Note: The language must be first registered with [refractor]. 205 | 206 | #### options.showLineNumbers 207 | 208 | Type: `boolean | string[]` 209 | Default: `false` 210 | 211 | By default, line numbers will only be displayed for code block cells with a meta property that includes 'showLineNumbers'. To control the starting line number, use `showLineNumbers=X`, where `X` is the starting line number as a meta property for the code block. 212 | 213 | If you would like to show line numbers for all code blocks without specifying the meta property, set this to `true`. 214 | 215 | Alternatively, you can specify an array of languages for which the line numbers should be shown. For example, setting the option as `showLineNumbers: ['typescript']` will display line numbers only for code blocks with the language `typescript`, while other languages (e.g., `text`) will not display line numbers. 216 | 217 | **Note**: This will wrongly assign a language class and the class might appear as `language-{1,3}` or `language-showLineNumbers`, but allow the language highlighting and line number function to work. An possible approach would be to add a placeholder like `unknown` so the `div` will have `class="language-unknown"` 218 | 219 | [rehype]: https://github.com/wooorm/rehype 220 | [prism]: http://prismjs.com/ 221 | [refractor]: https://github.com/wooorm/refractor 222 | [rehype plugin]: https://github.com/rehypejs/rehype/blob/master/doc/plugins.md#using-plugins 223 | [xdm]: https://github.com/wooorm/xdm 224 | [mdx-bundler]: https://github.com/kentcdodds/mdx-bundler 225 | [next-mdx-remote]: https://github.com/hashicorp/next-mdx-remote 226 | [language supported by refractor]: https://github.com/wooorm/refractor#syntaxes 227 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rehype-prism-plus", 3 | "version": "2.0.1", 4 | "description": "rehype plugin to highlight code blocks in HTML with Prism (via refractor) with line highlighting and line numbers", 5 | "source": "index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "main": "./dist/index.es.js", 10 | "module": "./dist/index.es.js", 11 | "types": "./dist/index.d.ts", 12 | "type": "module", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/index.d.ts", 16 | "default": "./dist/index.es.js" 17 | }, 18 | "./common": { 19 | "types": "./dist/common.d.ts", 20 | "default": "./dist/common.es.js" 21 | }, 22 | "./all": { 23 | "types": "./dist/all.d.ts", 24 | "default": "./dist/all.es.js" 25 | }, 26 | "./generator": { 27 | "types": "./dist/generator.d.ts", 28 | "default": "./dist/generator.es.js" 29 | } 30 | }, 31 | "typesVersions": { 32 | "*": { 33 | ".": [ 34 | "./dist/index" 35 | ], 36 | "common": [ 37 | "./dist/common" 38 | ], 39 | "all": [ 40 | "./dist/all" 41 | ], 42 | "generator": [ 43 | "./dist/generator" 44 | ] 45 | } 46 | }, 47 | "scripts": { 48 | "build": "tsc -b && microbundle src/index.js src/common.js src/all.js src/generator.js --format esm", 49 | "tsc": "tsc --watch", 50 | "lint": "eslint .", 51 | "prettier": "prettier --write '*.js'", 52 | "test": "uvu" 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "git+https://github.com/timlrx/rehype-prism-plus.git" 57 | }, 58 | "keywords": [ 59 | "rehype", 60 | "rehype-plugin", 61 | "syntax-highlighting", 62 | "prism", 63 | "mdx", 64 | "jsx" 65 | ], 66 | "author": "Timothy Lin (https://timlrx.com)", 67 | "license": "MIT", 68 | "bugs": { 69 | "url": "https://github.com/timlrx/rehype-prism-plus/issues" 70 | }, 71 | "homepage": "https://github.com/timlrx/rehype-prism-plus#readme", 72 | "dependencies": { 73 | "hast-util-to-string": "^3.0.0", 74 | "parse-numeric-range": "^1.3.0", 75 | "refractor": "^4.8.0", 76 | "rehype-parse": "^9.0.0", 77 | "unist-util-filter": "^5.0.0", 78 | "unist-util-visit": "^5.0.0" 79 | }, 80 | "devDependencies": { 81 | "dedent": "^0.7.0", 82 | "eslint": "^8.43.0", 83 | "eslint-config-prettier": "^8.3.0", 84 | "eslint-plugin-node": "^11.1.0", 85 | "husky": "^8.0.0", 86 | "lint-staged": "^11.1.2", 87 | "microbundle": "^0.15.1", 88 | "prettier": "^2.8.8", 89 | "rehype": "^13.0.1", 90 | "remark": "^15.0.1", 91 | "remark-rehype": "^11.0.0", 92 | "typescript": "5.1.3", 93 | "unified": "^11.0.4", 94 | "uvu": "^0.5.1" 95 | }, 96 | "prettier": { 97 | "printWidth": 100, 98 | "tabWidth": 2, 99 | "useTabs": false, 100 | "singleQuote": true, 101 | "bracketSpacing": true, 102 | "semi": false, 103 | "trailingComma": "es5" 104 | }, 105 | "lint-staged": { 106 | "*.+(js|jsx|ts|tsx)": [ 107 | "eslint --fix" 108 | ], 109 | "*.+(js|jsx|ts|tsx|json|css|md|mdx)": [ 110 | "prettier --write" 111 | ] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /sample-code-block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timlrx/rehype-prism-plus/d4715b1eac2c8ed52d0fd793b38d4aafa26ea76c/sample-code-block.png -------------------------------------------------------------------------------- /src/all.js: -------------------------------------------------------------------------------- 1 | import { refractor as refractorAll } from 'refractor/lib/all.js' 2 | import rehypePrismGenerator from './generator.js' 3 | 4 | /** 5 | * Rehype prism plugin that highlights code blocks with refractor (prismjs) 6 | * This supports all the languages and should be used on the server side. 7 | * 8 | * Consider using rehypePrismCommon or rehypePrismGenerator to generate a plugin 9 | * that supports your required languages. 10 | */ 11 | const rehypePrismAll = rehypePrismGenerator(refractorAll) 12 | 13 | export default rehypePrismAll 14 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | import { refractor as refractorCommon } from 'refractor/lib/common.js' 2 | import rehypePrismGenerator from './generator.js' 3 | 4 | /** 5 | * Rehype prism plugin that highlights code blocks with refractor (prismjs) 6 | * Supported languages: https://github.com/wooorm/refractor#data 7 | * 8 | * Consider using rehypePrismGenerator to generate a plugin 9 | * that supports your required languages. 10 | */ 11 | const rehypePrismCommon = rehypePrismGenerator(refractorCommon) 12 | 13 | export default rehypePrismCommon 14 | -------------------------------------------------------------------------------- /src/generator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('hast').Element} Element 3 | * @typedef {import('hast').Root} Root 4 | * @typedef Options options 5 | * Configuration. 6 | * @property {boolean|string[]} [showLineNumbers] 7 | * Set `showLineNumbers` to `true` to always display line number 8 | * @property {boolean} [ignoreMissing] 9 | * Set `ignoreMissing` to `true` to ignore unsupported languages and line highlighting when no language is specified 10 | * @property {string} [defaultLanguage] 11 | * Uses the specified language as the default if none is specified. Takes precedence over `ignoreMissing`. 12 | * Note: The language must be registered with refractor. 13 | */ 14 | 15 | import { visit } from 'unist-util-visit' 16 | import { toString } from 'hast-util-to-string' 17 | import { filter } from 'unist-util-filter' 18 | import rangeParser from 'parse-numeric-range' 19 | 20 | const getLanguage = (node) => { 21 | const className = node.properties.className 22 | //@ts-ignore 23 | for (const classListItem of className) { 24 | if (classListItem.slice(0, 9) === 'language-') { 25 | return classListItem.slice(9).toLowerCase() 26 | } 27 | } 28 | return null 29 | } 30 | 31 | /** 32 | * @param {import('refractor/lib/core').Refractor} refractor 33 | * @param {string} defaultLanguage 34 | * @return {void} 35 | */ 36 | const checkIfLanguageIsRegistered = (refractor, defaultLanguage) => { 37 | if (defaultLanguage && !refractor.registered(defaultLanguage)) { 38 | throw new Error(`The default language "${defaultLanguage}" is not registered with refractor.`) 39 | } 40 | } 41 | 42 | /** 43 | * Create a closure that determines if we have to highlight the given index 44 | * 45 | * @param {string} meta 46 | * @return { (index:number) => boolean } 47 | */ 48 | const calculateLinesToHighlight = (meta) => { 49 | const RE = /{([\d,-]+)}/ 50 | // Remove space between {} e.g. {1, 3} 51 | const parsedMeta = meta 52 | .split(',') 53 | .map((str) => str.trim()) 54 | .join() 55 | if (RE.test(parsedMeta)) { 56 | const strlineNumbers = RE.exec(parsedMeta)[1] 57 | const lineNumbers = rangeParser(strlineNumbers) 58 | return (index) => lineNumbers.includes(index + 1) 59 | } else { 60 | return () => false 61 | } 62 | } 63 | 64 | /** 65 | * Check if we want to start the line numbering from a given number or 1 66 | * showLineNumbers=5, will start the numbering from 5 67 | * @param {string} meta 68 | * @returns {number} 69 | */ 70 | const calculateStartingLine = (meta) => { 71 | const RE = /showLineNumbers=(?\d+)/i 72 | // pick the line number after = using a named capturing group 73 | if (RE.test(meta)) { 74 | const { 75 | groups: { lines }, 76 | } = RE.exec(meta) 77 | return Number(lines) 78 | } 79 | return 1 80 | } 81 | 82 | /** 83 | * Create container AST for node lines 84 | * 85 | * @param {number} number 86 | * @return {Element[]} 87 | */ 88 | const createLineNodes = (number) => { 89 | const a = new Array(number) 90 | for (let i = 0; i < number; i++) { 91 | a[i] = { 92 | type: 'element', 93 | tagName: 'span', 94 | properties: { className: [] }, 95 | children: [], 96 | } 97 | } 98 | return a 99 | } 100 | 101 | /** 102 | * Split multiline text nodes into individual nodes with positioning 103 | * Add a node start and end line position information for each text node 104 | * 105 | * @return { (ast:Element['children']) => Element['children'] } 106 | * 107 | */ 108 | const addNodePositionClosure = () => { 109 | let startLineNum = 1 110 | /** 111 | * @param {Element['children']} ast 112 | * @return {Element['children']} 113 | */ 114 | const addNodePosition = (ast) => { 115 | return ast.reduce((result, node) => { 116 | if (node.type === 'text') { 117 | const value = /** @type {string} */ (node.value) 118 | const numLines = (value.match(/\n/g) || '').length 119 | if (numLines === 0) { 120 | node.position = { 121 | // column: 1 is needed to avoid error with @next/mdx 122 | // https://github.com/timlrx/rehype-prism-plus/issues/44 123 | start: { line: startLineNum, column: 1 }, 124 | end: { line: startLineNum, column: 1 }, 125 | } 126 | result.push(node) 127 | } else { 128 | const lines = value.split('\n') 129 | for (const [i, line] of lines.entries()) { 130 | result.push({ 131 | type: 'text', 132 | value: i === lines.length - 1 ? line : line + '\n', 133 | position: { 134 | start: { line: startLineNum + i, column: 1 }, 135 | end: { line: startLineNum + i, column: 1 }, 136 | }, 137 | }) 138 | } 139 | } 140 | startLineNum = startLineNum + numLines 141 | 142 | return result 143 | } 144 | 145 | if (Object.prototype.hasOwnProperty.call(node, 'children')) { 146 | const initialLineNum = startLineNum 147 | // @ts-ignore 148 | node.children = addNodePosition(node.children, startLineNum) 149 | result.push(node) 150 | node.position = { 151 | start: { line: initialLineNum, column: 1 }, 152 | end: { line: startLineNum, column: 1 }, 153 | } 154 | return result 155 | } 156 | 157 | result.push(node) 158 | return result 159 | }, []) 160 | } 161 | return addNodePosition 162 | } 163 | 164 | /** 165 | * Rehype prism plugin generator that highlights code blocks with refractor (prismjs) 166 | * 167 | * Pass in your own refractor object with the required languages registered: 168 | * https://github.com/wooorm/refractor#refractorregistersyntax 169 | * 170 | * @param {import('refractor/lib/core').Refractor} refractor 171 | * @return {import('unified').Plugin<[Options?], Root>} 172 | */ 173 | const rehypePrismGenerator = (refractor) => { 174 | return (options = {}) => { 175 | checkIfLanguageIsRegistered(refractor, options.defaultLanguage) 176 | return (tree) => { 177 | visit(tree, 'element', visitor) 178 | } 179 | 180 | /** 181 | * @param {Element} node 182 | * @param {number} index 183 | * @param {Element} parent 184 | */ 185 | function visitor(node, index, parent) { 186 | if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') { 187 | return 188 | } 189 | 190 | // @ts-ignore meta is a custom code block property 191 | let meta = /** @type {string} */ (node?.data?.meta || node?.properties?.metastring || '') 192 | // Coerce className to array 193 | if (node.properties.className) { 194 | if (typeof node.properties.className === 'boolean') { 195 | node.properties.className = [] 196 | } else if (!Array.isArray(node.properties.className)) { 197 | node.properties.className = [node.properties.className] 198 | } 199 | } else { 200 | node.properties.className = [] 201 | } 202 | 203 | let lang = getLanguage(node) 204 | // If no language is set on the code block, use defaultLanguage if specified 205 | if (!lang && options.defaultLanguage) { 206 | lang = options.defaultLanguage 207 | node.properties.className.push(`language-${lang}`) 208 | } 209 | node.properties.className.push('code-highlight') 210 | 211 | /** @type {Element} */ 212 | let refractorRoot 213 | 214 | // Syntax highlight 215 | if (lang) { 216 | try { 217 | let rootLang 218 | if (lang?.includes('diff-')) { 219 | rootLang = lang.split('-')[1] 220 | } else { 221 | rootLang = lang 222 | } 223 | // @ts-ignore 224 | refractorRoot = refractor.highlight(toString(node), rootLang) 225 | // @ts-ignore className is already an array 226 | parent.properties.className = (parent.properties.className || []).concat( 227 | 'language-' + rootLang 228 | ) 229 | } catch (err) { 230 | if (options.ignoreMissing && /Unknown language/.test(err.message)) { 231 | refractorRoot = node 232 | } else { 233 | throw err 234 | } 235 | } 236 | } else { 237 | refractorRoot = node 238 | } 239 | 240 | refractorRoot.children = addNodePositionClosure()(refractorRoot.children) 241 | 242 | // Add position info to root 243 | if (refractorRoot.children.length > 0) { 244 | refractorRoot.position = { 245 | start: { line: refractorRoot.children[0].position.start.line, column: 0 }, 246 | end: { 247 | line: refractorRoot.children[refractorRoot.children.length - 1].position.end.line, 248 | column: 0, 249 | }, 250 | } 251 | } else { 252 | refractorRoot.position = { 253 | start: { line: 0, column: 0 }, 254 | end: { line: 0, column: 0 }, 255 | } 256 | } 257 | 258 | const shouldHighlightLine = calculateLinesToHighlight(meta) 259 | const startingLineNumber = calculateStartingLine(meta) 260 | const codeLineArray = createLineNodes(refractorRoot.position.end.line) 261 | 262 | const falseShowLineNumbersStr = [ 263 | 'showlinenumbers=false', 264 | 'showlinenumbers="false"', 265 | 'showlinenumbers={false}', 266 | ] 267 | for (const [i, line] of codeLineArray.entries()) { 268 | // Default class name for each line 269 | line.properties.className = ['code-line'] 270 | 271 | // Syntax highlight 272 | const treeExtract = filter( 273 | refractorRoot, 274 | (node) => node.position.start.line <= i + 1 && node.position.end.line >= i + 1 275 | ) 276 | line.children = treeExtract.children 277 | 278 | // Line number 279 | const isShowNumbers = 280 | (meta.toLowerCase().includes('showLineNumbers'.toLowerCase()) || 281 | options.showLineNumbers === true || 282 | (typeof options.showLineNumbers === 'object' && 283 | options.showLineNumbers.includes(lang))) && 284 | !falseShowLineNumbersStr.some((str) => meta.toLowerCase().includes(str)) 285 | 286 | if (isShowNumbers) { 287 | line.properties.line = [(i + startingLineNumber).toString()] 288 | line.properties.className.push('line-number') 289 | } 290 | 291 | // Line highlight 292 | if (shouldHighlightLine(i)) { 293 | line.properties.className.push('highlight-line') 294 | } 295 | 296 | // Diff classes 297 | if ( 298 | (lang === 'diff' || lang?.includes('diff-')) && 299 | toString(line).substring(0, 1) === '-' 300 | ) { 301 | line.properties.className.push('deleted') 302 | } else if ( 303 | (lang === 'diff' || lang?.includes('diff-')) && 304 | toString(line).substring(0, 1) === '+' 305 | ) { 306 | line.properties.className.push('inserted') 307 | } 308 | } 309 | 310 | // Remove possible trailing line when splitting by \n which results in empty array 311 | if ( 312 | codeLineArray.length > 0 && 313 | toString(codeLineArray[codeLineArray.length - 1]).trim() === '' 314 | ) { 315 | codeLineArray.pop() 316 | } 317 | 318 | node.children = codeLineArray 319 | } 320 | } 321 | } 322 | 323 | export default rehypePrismGenerator 324 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import rehypePrismGenerator from './generator.js' 2 | import rehypePrismCommon from './common.js' 3 | import rehypePrism from './all.js' 4 | 5 | export { rehypePrismGenerator, rehypePrismCommon } 6 | export default rehypePrism 7 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { visit } from 'unist-util-visit' 4 | import { rehype } from 'rehype' 5 | import { unified } from 'unified' 6 | import remarkParse from 'remark-parse' 7 | import remarkRehype from 'remark-rehype' 8 | import rehypeStringify from 'rehype-stringify' 9 | import dedent from 'dedent' 10 | import rehypePrism from './src/index.js' 11 | 12 | /** 13 | * Mock meta in code block 14 | */ 15 | const addMeta = (metastring) => { 16 | if (!metastring) return 17 | return (tree) => { 18 | visit(tree, 'element', (node, index, parent) => { 19 | if (node.tagName === 'code') { 20 | node.data = { meta: metastring } 21 | } 22 | }) 23 | } 24 | } 25 | 26 | const processHtml = (html, options, metastring) => { 27 | return rehype() 28 | .data('settings', { fragment: true }) 29 | .use(addMeta, metastring) 30 | .use(rehypePrism, options) 31 | .processSync(html) 32 | .toString() 33 | } 34 | 35 | const processHtmlUnified = (html, options, metastring) => { 36 | return unified() 37 | .use(remarkParse) 38 | .use(remarkRehype, {}) 39 | .use(addMeta, metastring) 40 | .use(rehypePrism, options) 41 | .use(rehypeStringify) 42 | .processSync(html) 43 | .toString() 44 | } 45 | 46 | test('adds a code-highlight class to the code and pre tag', () => { 47 | const result = processHtml(dedent` 48 |
49 | `) 50 | const expected = dedent`
` 51 | assert.is(result, expected) 52 | }) 53 | 54 | test('add span with class code line for each line', () => { 55 | const result = processHtml( 56 | dedent` 57 |
x = 6
58 | ` 59 | ) 60 | const expected = dedent`
x = 6
` 61 | assert.is(result, expected) 62 | }) 63 | 64 | test('finds code and highlights', () => { 65 | const result = processHtml(dedent` 66 |
67 |

foo

68 |
x = 6
69 |
70 | `).trim() 71 | const expected = dedent` 72 |
73 |

foo

74 |
x = 6
75 |
76 | ` 77 | assert.is(result, expected) 78 | }) 79 | 80 | test('respects line spacing', () => { 81 | const result = processHtml(dedent` 82 |
83 |
x
 84 | 
 85 | y
 86 | 
87 |
88 | `).trim() 89 | const expected = dedent` 90 |
91 |
x
 92 |   
 93 |   y
 94 |   
95 |
96 | ` 97 | assert.is(result, expected) 98 | }) 99 | 100 | test('handles uppercase correctly', () => { 101 | const result = processHtml(dedent` 102 |
103 |

foo

104 |
x = 6
105 |
106 | `).trim() 107 | const expected = dedent` 108 |
109 |

foo

110 |
x = 6
111 |
112 | ` 113 | assert.is(result, expected) 114 | }) 115 | 116 | test('each line of code should be a separate div', async () => { 117 | const result = processHtml(dedent` 118 |
119 |

foo

120 |
121 |       x = 6
122 |       y = 7
123 |       
124 |       
125 |
126 | `).trim() 127 | const codeLineCount = (result.match(//g) || []).length 128 | assert.is(codeLineCount, 2) 129 | }) 130 | 131 | test('should highlight line', async () => { 132 | const meta = '{1}' 133 | const result = processHtml( 134 | dedent` 135 |
136 |
137 |       x = 6
138 |       y = 7
139 |       
140 |       
141 |
142 | `, 143 | {}, 144 | meta 145 | ).trim() 146 | const codeHighlightCount = (result.match(//g) || []).length 147 | assert.is(codeHighlightCount, 1) 148 | }) 149 | 150 | test('should highlight comma separated lines', async () => { 151 | const meta = '{1,3}' 152 | const result = processHtml( 153 | dedent` 154 |
155 |
156 |       x = 6
157 |       y = 7
158 |       z = 10
159 |       
160 |       
161 |
162 | `, 163 | {}, 164 | meta 165 | ).trim() 166 | const codeHighlightCount = (result.match(//g) || []).length 167 | assert.is(codeHighlightCount, 2) 168 | }) 169 | 170 | test('should should parse ranges with a space in between', async () => { 171 | const meta = '{1, 3}' 172 | const result = processHtml( 173 | dedent` 174 |
175 |
176 |       x = 6
177 |       y = 7
178 |       z = 10
179 |       
180 |       
181 |
182 | `, 183 | {}, 184 | meta 185 | ).trim() 186 | const codeHighlightCount = (result.match(//g) || []).length 187 | assert.is(codeHighlightCount, 2) 188 | }) 189 | 190 | test('should highlight range separated lines', async () => { 191 | const meta = '{1-3}' 192 | const result = processHtml( 193 | dedent` 194 |
195 |
196 |       x = 6
197 |       y = 7
198 |       z = 10
199 |       
200 |       
201 |
202 | `, 203 | {}, 204 | meta 205 | ).trim() 206 | const codeHighlightCount = (result.match(//g) || []).length 207 | assert.is(codeHighlightCount, 3) 208 | }) 209 | 210 | test('showLineNumbers option add line numbers', async () => { 211 | const result = processHtml( 212 | dedent` 213 |
214 |
215 |       x = 6
216 |       y = 7
217 |       
218 |       
219 |
220 | `, 221 | { showLineNumbers: true } 222 | ).trim() 223 | assert.ok(result.match(/line="1"/g)) 224 | assert.ok(result.match(/line="2"/g)) 225 | assert.not(result.match(/line="3"/g)) 226 | }) 227 | 228 | test('not show line number when showLineNumbers=false', async () => { 229 | const meta = 'showLineNumbers=false' 230 | const result = processHtml( 231 | dedent` 232 |
233 |
234 |       x = 6
235 |       y = 7
236 |       
237 |       
238 |
239 | `, 240 | { showLineNumbers: true }, 241 | meta 242 | ).trim() 243 | assert.not(result.match(/line="1"/g)) 244 | assert.not(result.match(/line="2"/g)) 245 | }) 246 | 247 | test('show line numbers when showLineNumbers=string[] includes target language', async () => { 248 | const result = processHtml( 249 | dedent` 250 |
251 |
252 |       x = 6
253 |       y = 7
254 |       
255 |       
256 |
257 | `, 258 | { showLineNumbers: ['typescript', 'py'] } 259 | ).trim() 260 | assert.ok(result.match(/line="1"/g)) 261 | assert.ok(result.match(/line="2"/g)) 262 | }) 263 | 264 | test('not show line numbers when showLineNumbers=string[] does not include target language', async () => { 265 | const result = processHtml( 266 | dedent` 267 |
268 |
269 |       x = 6
270 |       y = 7
271 |       
272 |       
273 |
274 | `, 275 | { showLineNumbers: ['typescript', 'py'] } 276 | ).trim() 277 | assert.not(result.match(/line="1"/g)) 278 | assert.not(result.match(/line="2"/g)) 279 | }) 280 | 281 | test('not show line number when showLineNumbers={false}', async () => { 282 | const meta = 'showLineNumbers={false}' 283 | const result = processHtml( 284 | dedent` 285 |
286 |
287 |       x = 6
288 |       y = 7
289 |       
290 |       
291 |
292 | `, 293 | { showLineNumbers: true }, 294 | meta 295 | ).trim() 296 | assert.not(result.match(/line="1"/g)) 297 | assert.not(result.match(/line="2"/g)) 298 | }) 299 | 300 | test('showLineNumbers property works in meta field', async () => { 301 | const meta = 'showLineNumbers' 302 | const result = processHtml( 303 | dedent` 304 |
305 |
306 |       x = 6
307 |       y = 7
308 |       
309 |       
310 |
311 | `, 312 | {}, 313 | meta 314 | ).trim() 315 | assert.ok(result.match(/line="1"/g)) 316 | assert.ok(result.match(/line="2"/g)) 317 | assert.not(result.match(/line="3"/g)) 318 | }) 319 | 320 | test('showLineNumbers property with custom index works in meta field', async () => { 321 | const meta = 'showLineNumbers=5' 322 | const result = processHtml( 323 | dedent` 324 |
325 |
326 |       x = 6
327 |       y = 7
328 |       
329 |       
330 |
331 | `, 332 | {}, 333 | meta 334 | ).trim() 335 | assert.ok(result.match(/line="5"/g)) 336 | assert.ok(result.match(/line="6"/g)) 337 | assert.not(result.match(/line="7"/g)) 338 | }) 339 | 340 | test('should support both highlighting and add line number', async () => { 341 | const meta = '{1} showLineNumbers' 342 | const result = processHtml( 343 | dedent` 344 |
345 |
346 |       x = 6
347 |       y = 7
348 |       z = 10
349 |       
350 |       
351 |
352 | `, 353 | {}, 354 | meta 355 | ).trim() 356 | const codeHighlightCount = (result.match(/highlight-line/g) || []).length 357 | assert.is(codeHighlightCount, 1) 358 | assert.ok(result.match(/line="1"/g)) 359 | assert.ok(result.match(/line="2"/g)) 360 | }) 361 | 362 | test('throw error with fake language- class', () => { 363 | assert.throws( 364 | () => 365 | processHtml(dedent` 366 |
x = 6
367 | `), 368 | /Unknown language/ 369 | ) 370 | }) 371 | 372 | test('with options.ignoreMissing, does nothing to code block with fake language- class', () => { 373 | const result = processHtml( 374 | dedent` 375 |
x = 6
376 | `, 377 | { ignoreMissing: true } 378 | ) 379 | const expected = dedent`
x = 6
` 380 | assert.is(result, expected) 381 | }) 382 | 383 | test('with options.defaultLanguage, it adds the correct language class tag', () => { 384 | const result = processHtml( 385 | dedent` 386 |
x = 6
387 | `, 388 | { defaultLanguage: 'py' } 389 | ) 390 | const expected = dedent`
x = 6
` 391 | assert.is(result, expected) 392 | }) 393 | 394 | test('defaultLanguage should produce the same syntax tree as if manually specified', () => { 395 | const resultDefaultLanguage = processHtml( 396 | dedent` 397 |
x = 6
398 | `, 399 | { defaultLanguage: 'py' } 400 | ) 401 | const resultManuallySpecified = processHtml( 402 | dedent` 403 |
x = 6
404 | ` 405 | ) 406 | assert.is(resultDefaultLanguage, resultManuallySpecified) 407 | }) 408 | 409 | test('throws error if options.defaultLanguage is not registered with refractor', () => { 410 | assert.throws( 411 | () => 412 | processHtml( 413 | dedent` 414 |
x = 6
415 | `, 416 | { defaultLanguage: 'pyzqt' } 417 | ), 418 | /"pyzqt" is not registered with refractor/ 419 | ) 420 | }) 421 | 422 | test('should work with multiline code / comments', () => { 423 | const result = processHtml( 424 | dedent` 425 |

426 |     /**
427 |      * My comment
428 |      */
429 |     
430 | `, 431 | { ignoreMissing: true } 432 | ) 433 | const expected = dedent`

434 |         /**
435 |          * My comment
436 |          */
437 |         
` 438 | assert.is(result, expected) 439 | }) 440 | 441 | test('adds inserted or deleted to code-line if lang=diff', async () => { 442 | const result = processHtml( 443 | dedent` 444 |
445 |
446 |       + x = 6
447 | - y = 7
448 | z = 10
449 |       
450 |       
451 |
452 | ` 453 | ).trim() 454 | assert.ok(result.includes(``)) 455 | assert.ok(result.includes(``)) 456 | assert.ok(result.includes(``)) 457 | }) 458 | 459 | test('works as a remarkjs / unifiedjs plugin', () => { 460 | const result = processHtmlUnified( 461 | dedent` 462 | ~~~jsx 463 | 464 | ~~~ 465 | `, 466 | { ignoreMissing: true } 467 | ) 468 | const expected = dedent`
<Component/>
469 |   
` 470 | assert.is(result, expected) 471 | }) 472 | 473 | test('diff and code highlighting should work together', () => { 474 | const result = processHtml( 475 | dedent` 476 |

477 |     .hello{
478 |     - background:url('./urel.png');
479 |     + background-image:url('./urel.png');
480 |     }
481 |     
482 | `, 483 | { ignoreMissing: true } 484 | ) 485 | assert.ok(result.includes(`
`))
486 |   assert.ok(result.includes(``))
487 |   assert.ok(result.includes(``))
488 |   assert.ok(result.includes(``))
489 | })
490 | 
491 | test.run()
492 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "include": ["src/*"],
 3 |   "compilerOptions": {
 4 |     "target": "ES2020",
 5 |     "lib": ["ES2020"],
 6 |     "module": "ES2020",
 7 |     "moduleResolution": "node",
 8 |     "outDir": "dist",
 9 |     "allowJs": true,
10 |     "checkJs": true,
11 |     "declaration": true,
12 |     "emitDeclarationOnly": true,
13 |     "allowSyntheticDefaultImports": true,
14 |     "skipLibCheck": true
15 |   }
16 | }
17 | 


--------------------------------------------------------------------------------