├── .prettierignore ├── .npmrc ├── .gitignore ├── index.js ├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── tsconfig.json ├── license ├── package.json ├── lib └── index.js ├── test.js └── readme.md /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | *.log 3 | *.map 4 | *.tsbuildinfo 5 | .DS_Store 6 | coverage/ 7 | node_modules/ 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./lib/index.js').Options} Options 3 | */ 4 | 5 | export {default} from './lib/index.js' 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: unifiedjs/beep-boop-beta@main 6 | with: 7 | repo-token: ${{secrets.GITHUB_TOKEN}} 8 | name: bb 9 | on: 10 | issues: 11 | types: [closed, edited, labeled, opened, reopened, unlabeled] 12 | pull_request_target: 13 | types: [closed, edited, labeled, opened, reopened, unlabeled] 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declarationMap": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "lib": ["es2022"], 10 | "module": "node16", 11 | "strict": true, 12 | "target": "es2022" 13 | }, 14 | "exclude": ["coverage/", "node_modules/"], 15 | "include": ["**/*.js"] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | name: ${{matrix.node}} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-node@v4 8 | with: 9 | node-version: ${{matrix.node}} 10 | - run: npm install 11 | - run: npm test 12 | - uses: codecov/codecov-action@v5 13 | strategy: 14 | matrix: 15 | node: 16 | - lts/hydrogen 17 | - node 18 | name: main 19 | on: 20 | - pull_request 21 | - push 22 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Titus Wormer (https://wooorm.com)", 3 | "bugs": "https://github.com/rehypejs/rehype-highlight/issues", 4 | "contributors": [ 5 | "Titus Wormer (https://wooorm.com)" 6 | ], 7 | "dependencies": { 8 | "@types/hast": "^3.0.0", 9 | "hast-util-to-text": "^4.0.0", 10 | "lowlight": "^3.0.0", 11 | "unist-util-visit": "^5.0.0", 12 | "vfile": "^6.0.0" 13 | }, 14 | "description": "rehype plugin to highlight code blocks with lowlight (highlight.js)", 15 | "devDependencies": { 16 | "@types/node": "^22.0.0", 17 | "c8": "^10.0.0", 18 | "prettier": "^3.0.0", 19 | "rehype-parse": "^9.0.0", 20 | "rehype-stringify": "^10.0.0", 21 | "remark-cli": "^12.0.0", 22 | "remark-preset-wooorm": "^11.0.0", 23 | "type-coverage": "^2.0.0", 24 | "typescript": "^5.0.0", 25 | "unified": "^11.0.0", 26 | "xo": "^0.60.0" 27 | }, 28 | "exports": "./index.js", 29 | "files": [ 30 | "index.d.ts.map", 31 | "index.d.ts", 32 | "index.js", 33 | "lib/" 34 | ], 35 | "funding": { 36 | "type": "opencollective", 37 | "url": "https://opencollective.com/unified" 38 | }, 39 | "keywords": [ 40 | "hast", 41 | "highlighting", 42 | "highlight", 43 | "html", 44 | "plugin", 45 | "rehype-plugin", 46 | "rehype", 47 | "syntax", 48 | "unified" 49 | ], 50 | "license": "MIT", 51 | "name": "rehype-highlight", 52 | "prettier": { 53 | "bracketSpacing": false, 54 | "singleQuote": true, 55 | "semi": false, 56 | "tabWidth": 2, 57 | "trailingComma": "none", 58 | "useTabs": false 59 | }, 60 | "remarkConfig": { 61 | "plugins": [ 62 | "remark-preset-wooorm" 63 | ] 64 | }, 65 | "repository": "rehypejs/rehype-highlight", 66 | "scripts": { 67 | "build": "tsc --build --clean && tsc --build && type-coverage", 68 | "format": "remark --frail --output --quiet -- . && prettier --log-level warn --write -- . && xo --fix", 69 | "test-api": "node --conditions development test.js", 70 | "test-coverage": "c8 --100 --reporter lcov -- npm run test-api", 71 | "test": "npm run build && npm run format && npm run test-coverage" 72 | }, 73 | "sideEffects": false, 74 | "typeCoverage": { 75 | "atLeast": 100, 76 | "strict": true 77 | }, 78 | "type": "module", 79 | "version": "7.0.2", 80 | "xo": { 81 | "prettier": true, 82 | "rules": { 83 | "complexity": "off" 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {ElementContent, Element, Root} from 'hast' 3 | * @import {LanguageFn} from 'lowlight' 4 | * @import {VFile} from 'vfile' 5 | */ 6 | 7 | /** 8 | * @typedef Options 9 | * Configuration (optional). 10 | * @property {Readonly | string>> | null | undefined} [aliases={}] 11 | * Register more aliases (optional); 12 | * passed to `lowlight.registerAlias`. 13 | * @property {boolean | null | undefined} [detect=false] 14 | * Highlight code without language classes by guessing its programming 15 | * language (default: `false`). 16 | * @property {Readonly> | null | undefined} [languages] 17 | * Register languages (default: `common`); 18 | * passed to `lowlight.register`. 19 | * @property {ReadonlyArray | null | undefined} [plainText=[]] 20 | * List of language names to not highlight (optional); 21 | * note you can also add `no-highlight` classes. 22 | * @property {string | null | undefined} [prefix='hljs-'] 23 | * Class prefix (default: `'hljs-'`). 24 | * @property {ReadonlyArray | null | undefined} [subset] 25 | * Names of languages to check when detecting (default: all registered 26 | * languages). 27 | */ 28 | 29 | import {toText} from 'hast-util-to-text' 30 | import {common, createLowlight} from 'lowlight' 31 | import {visit} from 'unist-util-visit' 32 | 33 | /** @type {Options} */ 34 | const emptyOptions = {} 35 | 36 | /** 37 | * Apply syntax highlighting. 38 | * 39 | * @param {Readonly | null | undefined} [options] 40 | * Configuration (optional). 41 | * @returns 42 | * Transform. 43 | */ 44 | export default function rehypeHighlight(options) { 45 | const settings = options || emptyOptions 46 | const aliases = settings.aliases 47 | const detect = settings.detect || false 48 | const languages = settings.languages || common 49 | const plainText = settings.plainText 50 | const prefix = settings.prefix 51 | const subset = settings.subset 52 | let name = 'hljs' 53 | 54 | const lowlight = createLowlight(languages) 55 | 56 | if (aliases) { 57 | lowlight.registerAlias(aliases) 58 | } 59 | 60 | if (prefix) { 61 | const pos = prefix.indexOf('-') 62 | name = pos === -1 ? prefix : prefix.slice(0, pos) 63 | } 64 | 65 | /** 66 | * Transform. 67 | * 68 | * @param {Root} tree 69 | * Tree. 70 | * @param {VFile} file 71 | * File. 72 | * @returns {undefined} 73 | * Nothing. 74 | */ 75 | return function (tree, file) { 76 | visit(tree, 'element', function (node, _, parent) { 77 | if ( 78 | node.tagName !== 'code' || 79 | !parent || 80 | parent.type !== 'element' || 81 | parent.tagName !== 'pre' 82 | ) { 83 | return 84 | } 85 | 86 | const lang = language(node) 87 | 88 | if ( 89 | lang === false || 90 | (!lang && !detect) || 91 | (lang && plainText && plainText.includes(lang)) 92 | ) { 93 | return 94 | } 95 | 96 | if (!Array.isArray(node.properties.className)) { 97 | node.properties.className = [] 98 | } 99 | 100 | if (!node.properties.className.includes(name)) { 101 | node.properties.className.unshift(name) 102 | } 103 | 104 | const text = toText(node, {whitespace: 'pre'}) 105 | /** @type {Root} */ 106 | let result 107 | 108 | try { 109 | result = lang 110 | ? lowlight.highlight(lang, text, {prefix}) 111 | : lowlight.highlightAuto(text, {prefix, subset}) 112 | } catch (error) { 113 | const cause = /** @type {Error} */ (error) 114 | 115 | if (lang && /Unknown language/.test(cause.message)) { 116 | file.message( 117 | 'Cannot highlight as `' + lang + '`, it’s not registered', 118 | { 119 | ancestors: [parent, node], 120 | cause, 121 | place: node.position, 122 | ruleId: 'missing-language', 123 | source: 'rehype-highlight' 124 | } 125 | ) 126 | 127 | /* c8 ignore next 5 -- throw arbitrary hljs errors */ 128 | return 129 | } 130 | 131 | throw cause 132 | } 133 | 134 | if (!lang && result.data && result.data.language) { 135 | node.properties.className.push('language-' + result.data.language) 136 | } 137 | 138 | if (result.children.length > 0) { 139 | node.children = /** @type {Array} */ (result.children) 140 | } 141 | }) 142 | } 143 | } 144 | 145 | /** 146 | * Get the programming language of `node`. 147 | * 148 | * @param {Element} node 149 | * Node. 150 | * @returns {false | string | undefined} 151 | * Language or `undefined`, or `false` when an explikcit `no-highlight` class 152 | * is used. 153 | */ 154 | function language(node) { 155 | const list = node.properties.className 156 | let index = -1 157 | 158 | if (!Array.isArray(list)) { 159 | return 160 | } 161 | 162 | /** @type {string | undefined} */ 163 | let name 164 | 165 | while (++index < list.length) { 166 | const value = String(list[index]) 167 | 168 | if (value === 'no-highlight' || value === 'nohighlight') { 169 | return false 170 | } 171 | 172 | if (!name && value.slice(0, 5) === 'lang-') { 173 | name = value.slice(5) 174 | } 175 | 176 | if (!name && value.slice(0, 9) === 'language-') { 177 | name = value.slice(9) 178 | } 179 | } 180 | 181 | return name 182 | } 183 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import test from 'node:test' 3 | import rehypeHighlight from 'rehype-highlight' 4 | import rehypeParse from 'rehype-parse' 5 | import rehypeStringify from 'rehype-stringify' 6 | import {unified} from 'unified' 7 | 8 | test('rehypeHighlight', async function (t) { 9 | await t.test('should expose the public api', async function () { 10 | assert.deepEqual(Object.keys(await import('rehype-highlight')).sort(), [ 11 | 'default' 12 | ]) 13 | }) 14 | 15 | await t.test('should work on empty code', async function () { 16 | const file = await unified() 17 | .use(rehypeParse, {fragment: true}) 18 | .use(rehypeHighlight, {detect: true}) 19 | .use(rehypeStringify) 20 | .process( 21 | ['

Hello World!

', '', '
'].join('\n') 22 | ) 23 | 24 | assert.equal( 25 | String(file), 26 | [ 27 | '

Hello World!

', 28 | '', 29 | '
' 30 | ].join('\n') 31 | ) 32 | }) 33 | 34 | await t.test('should not highlight (no class)', async function () { 35 | const file = await unified() 36 | .use(rehypeParse, {fragment: true}) 37 | .use(rehypeHighlight) 38 | .use(rehypeStringify) 39 | .process( 40 | [ 41 | '

Hello World!

', 42 | '', 43 | '
"use strict";
' 44 | ].join('\n') 45 | ) 46 | 47 | assert.equal( 48 | String(file), 49 | [ 50 | '

Hello World!

', 51 | '', 52 | '
"use strict";
' 53 | ].join('\n') 54 | ) 55 | }) 56 | 57 | await t.test('should highlight (`detect`, no class)', async function () { 58 | const file = await unified() 59 | .use(rehypeParse, {fragment: true}) 60 | .use(rehypeHighlight, {detect: true}) 61 | .use(rehypeStringify) 62 | .process( 63 | [ 64 | '

Hello World!

', 65 | '', 66 | '
"use strict";
' 67 | ].join('\n') 68 | ) 69 | 70 | assert.equal( 71 | String(file), 72 | [ 73 | '

Hello World!

', 74 | '', 75 | '
"use strict";
' 76 | ].join('\n') 77 | ) 78 | }) 79 | 80 | await t.test( 81 | 'should highlight (detect, no class, subset)', 82 | async function () { 83 | const file = await unified() 84 | .use(rehypeParse, {fragment: true}) 85 | .use(rehypeHighlight, {detect: true, subset: ['arduino']}) 86 | .use(rehypeStringify) 87 | .process( 88 | [ 89 | '

Hello World!

', 90 | '', 91 | '
"use strict";
' 92 | ].join('\n') 93 | ) 94 | 95 | assert.equal( 96 | String(file), 97 | [ 98 | '

Hello World!

', 99 | '', 100 | '
"use strict";
' 101 | ].join('\n') 102 | ) 103 | } 104 | ) 105 | 106 | await t.test( 107 | 'should not highlight (`detect: false`, no class)', 108 | async function () { 109 | const file = await unified() 110 | .use(rehypeParse, {fragment: true}) 111 | .use(rehypeHighlight, {detect: false}) 112 | .use(rehypeStringify) 113 | .process( 114 | [ 115 | '

Hello World!

', 116 | '', 117 | '
"use strict";
' 118 | ].join('\n') 119 | ) 120 | 121 | assert.equal( 122 | String(file), 123 | [ 124 | '

Hello World!

', 125 | '', 126 | '
"use strict";
' 127 | ].join('\n') 128 | ) 129 | } 130 | ) 131 | 132 | await t.test('should highlight (prefix without dash)', async function () { 133 | const file = await unified() 134 | .use(rehypeParse, {fragment: true}) 135 | .use(rehypeHighlight, {detect: true, prefix: 'foo'}) 136 | .use(rehypeStringify) 137 | .process( 138 | [ 139 | '

Hello World!

', 140 | '', 141 | '
"use strict";
' 142 | ].join('\n') 143 | ) 144 | 145 | assert.equal( 146 | String(file), 147 | [ 148 | '

Hello World!

', 149 | '', 150 | '
"use strict";
' 151 | ].join('\n') 152 | ) 153 | }) 154 | 155 | await t.test('should highlight (prefix with dash)', async function () { 156 | const file = await unified() 157 | .use(rehypeParse, {fragment: true}) 158 | .use(rehypeHighlight, {detect: true, prefix: 'foo-'}) 159 | .use(rehypeStringify) 160 | .process( 161 | [ 162 | '

Hello World!

', 163 | '', 164 | '
"use strict";
' 165 | ].join('\n') 166 | ) 167 | 168 | assert.equal( 169 | String(file), 170 | [ 171 | '

Hello World!

', 172 | '', 173 | '
"use strict";
' 174 | ].join('\n') 175 | ) 176 | }) 177 | 178 | await t.test('should highlight (lang class)', async function () { 179 | const file = await unified() 180 | .use(rehypeParse, {fragment: true}) 181 | .use(rehypeHighlight) 182 | .use(rehypeStringify) 183 | .process( 184 | [ 185 | '

Hello World!

', 186 | '', 187 | '
var name = "World";',
188 |           'console.log("Hello, " + name + "!")
' 189 | ].join('\n') 190 | ) 191 | 192 | assert.equal( 193 | String(file), 194 | [ 195 | '

Hello World!

', 196 | '', 197 | '
var name = "World";',
198 |         'console.log("Hello, " + name + "!")
' 199 | ].join('\n') 200 | ) 201 | }) 202 | 203 | await t.test('should highlight (language class)', async function () { 204 | const file = await unified() 205 | .use(rehypeParse, {fragment: true}) 206 | .use(rehypeHighlight) 207 | .use(rehypeStringify) 208 | .process( 209 | [ 210 | '

Hello World!

', 211 | '', 212 | '
var name = "World";',
213 |           'console.log("Hello, " + name + "!")
' 214 | ].join('\n') 215 | ) 216 | 217 | assert.equal( 218 | String(file), 219 | [ 220 | '

Hello World!

', 221 | '', 222 | '
var name = "World";',
223 |         'console.log("Hello, " + name + "!")
' 224 | ].join('\n') 225 | ) 226 | }) 227 | 228 | await t.test('should highlight (long name)', async function () { 229 | const file = await unified() 230 | .use(rehypeParse, {fragment: true}) 231 | .use(rehypeHighlight) 232 | .use(rehypeStringify) 233 | .process( 234 | [ 235 | '

Hello World!

', 236 | '', 237 | '
var name = "World";',
238 |           'console.log("Hello, " + name + "!")
' 239 | ].join('\n') 240 | ) 241 | 242 | assert.equal( 243 | String(file), 244 | [ 245 | '

Hello World!

', 246 | '', 247 | '
var name = "World";',
248 |         'console.log("Hello, " + name + "!")
' 249 | ].join('\n') 250 | ) 251 | }) 252 | 253 | await t.test('should not highlight (`no-highlight`)', async function () { 254 | const file = await unified() 255 | .use(rehypeParse, {fragment: true}) 256 | .use(rehypeHighlight) 257 | .use(rehypeStringify) 258 | .process( 259 | [ 260 | '

Hello World!

', 261 | '', 262 | '
var name = "World";',
263 |           'console.log("Hello, " + name + "!")
' 264 | ].join('\n') 265 | ) 266 | 267 | assert.equal( 268 | String(file), 269 | [ 270 | '

Hello World!

', 271 | '', 272 | '
var name = "World";',
273 |         'console.log("Hello, " + name + "!")
' 274 | ].join('\n') 275 | ) 276 | }) 277 | 278 | await t.test( 279 | 'should prefer `no-highlight` over a `language-*` class', 280 | async function () { 281 | const file = await unified() 282 | .use(rehypeParse, {fragment: true}) 283 | .use(rehypeHighlight) 284 | .use(rehypeStringify) 285 | .process( 286 | '

Hello World!

\n
alert(1)
' 287 | ) 288 | 289 | assert.equal( 290 | String(file), 291 | '

Hello World!

\n
alert(1)
' 292 | ) 293 | } 294 | ) 295 | 296 | await t.test('should not highlight (`nohighlight`)', async function () { 297 | const file = await unified() 298 | .use(rehypeParse, {fragment: true}) 299 | .use(rehypeHighlight) 300 | .use(rehypeStringify) 301 | .process( 302 | [ 303 | '

Hello World!

', 304 | '', 305 | '
var name = "World";',
306 |           'console.log("Hello, " + name + "!")
' 307 | ].join('\n') 308 | ) 309 | 310 | assert.equal( 311 | String(file), 312 | [ 313 | '

Hello World!

', 314 | '', 315 | '
var name = "World";',
316 |         'console.log("Hello, " + name + "!")
' 317 | ].join('\n') 318 | ) 319 | }) 320 | 321 | await t.test('should warn on missing languages', async function () { 322 | const file = await unified() 323 | .use(rehypeParse, {fragment: true}) 324 | .use(rehypeHighlight) 325 | .use(rehypeStringify) 326 | .process( 327 | [ 328 | '

Hello World!

', 329 | '', 330 | '
var name = "World";',
331 |           'console.log("Hello, " + name + "!")
' 332 | ].join('\n') 333 | ) 334 | 335 | assert.deepEqual(file.messages.map(String), [ 336 | '3:6-4:43: Cannot highlight as `foobar`, it’s not registered' 337 | ]) 338 | }) 339 | 340 | await t.test( 341 | 'should not highlight plainText-ed languages', 342 | async function () { 343 | const file = await unified() 344 | .use(rehypeParse, {fragment: true}) 345 | .use(rehypeHighlight, {plainText: ['js']}) 346 | .use(rehypeStringify) 347 | .process( 348 | [ 349 | '

Hello World!

', 350 | '', 351 | '
var name = "World";',
352 |             'console.log("Hello, " + name + "!")
' 353 | ].join('\n') 354 | ) 355 | 356 | assert.equal( 357 | String(file), 358 | [ 359 | '

Hello World!

', 360 | '', 361 | '
var name = "World";',
362 |           'console.log("Hello, " + name + "!")
' 363 | ].join('\n') 364 | ) 365 | } 366 | ) 367 | 368 | await t.test('should not remove contents', async function () { 369 | // For some reason this isn’t detected as c++. 370 | const file = await unified() 371 | .use(rehypeParse, {fragment: true}) 372 | .use(rehypeHighlight, {detect: true, subset: ['cpp']}) 373 | .use(rehypeStringify) 374 | .process(`
def add(a, b):\n  return a + b
`) 375 | 376 | assert.equal( 377 | String(file), 378 | '
def add(a, b):\n  return a + b
' 379 | ) 380 | }) 381 | 382 | await t.test('should support multiple `code`s in a `pre`', async function () { 383 | const file = await unified() 384 | .use(rehypeParse, {fragment: true}) 385 | .use(rehypeHighlight) 386 | .use(rehypeStringify).process(`
387 |   const a = 1;
388 |   printf("x")
389 | 
`) 390 | 391 | assert.equal( 392 | String(file), 393 | '
  const a = 1;\n  printf("x")\n
' 394 | ) 395 | }) 396 | 397 | await t.test('should parse custom language', async function () { 398 | const file = await unified() 399 | .use(rehypeParse, {fragment: true}) 400 | .use(rehypeHighlight, {aliases: {javascript: ['funkyscript']}}) 401 | .use(rehypeStringify) 402 | .process( 403 | '
console.log(1)
' 404 | ) 405 | 406 | assert.equal( 407 | String(file), 408 | '
console.log(1)
' 409 | ) 410 | }) 411 | 412 | await t.test('should reprocess exact', async function () { 413 | const expected = [ 414 | '

Hello World!

', 415 | '', 416 | '
var name = "World";',
417 |       'console.log("Hello, " + name + "!")
' 418 | ].join('\n') 419 | 420 | const file = await unified() 421 | .use(rehypeParse, {fragment: true}) 422 | .use(rehypeHighlight) 423 | .use(rehypeStringify) 424 | .process(expected) 425 | 426 | assert.equal(String(file), expected) 427 | }) 428 | 429 | await t.test('should ignore comments', async function () { 430 | const file = await unified() 431 | .use(rehypeParse, {fragment: true}) 432 | .use(rehypeHighlight, {detect: true}) 433 | .use(rehypeStringify) 434 | .process( 435 | [ 436 | '

Hello World!

', 437 | '', 438 | '
"use strict";
' 439 | ].join('\n') 440 | ) 441 | 442 | assert.equal( 443 | String(file), 444 | [ 445 | '

Hello World!

', 446 | '', 447 | '
"use strict";
' 448 | ].join('\n') 449 | ) 450 | }) 451 | 452 | await t.test('should support `
` elements', async function () { 453 | const file = await unified() 454 | .use(rehypeParse, {fragment: true}) 455 | .use(rehypeHighlight) 456 | .use(rehypeStringify) 457 | .process( 458 | [ 459 | '

Hello World!

', 460 | '', 461 | '
"use strict";
console.log("very strict")
' 462 | ].join('\n') 463 | ) 464 | 465 | assert.equal( 466 | String(file), 467 | [ 468 | '

Hello World!

', 469 | '', 470 | '
"use strict";',
471 |         'console.log("very strict")
' 472 | ].join('\n') 473 | ) 474 | }) 475 | 476 | await t.test('should register languages', async function () { 477 | const file = await unified() 478 | .use(rehypeParse, {fragment: true}) 479 | .use(rehypeHighlight, { 480 | languages: { 481 | test() { 482 | return { 483 | aliases: [], 484 | contains: [], 485 | keywords: {keyword: 'bravo'} 486 | } 487 | } 488 | } 489 | }) 490 | .use(rehypeStringify) 491 | .process( 492 | [ 493 | '

Hello World!

', 494 | '', 495 | '
alpha bravo charlie
' 496 | ].join('\n') 497 | ) 498 | 499 | assert.equal( 500 | String(file), 501 | [ 502 | '

Hello World!

', 503 | '', 504 | '
alpha bravo charlie
' 505 | ].join('\n') 506 | ) 507 | }) 508 | }) 509 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # rehype-highlight 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Size][size-badge]][size] 7 | [![Sponsors][sponsors-badge]][collective] 8 | [![Backers][backers-badge]][collective] 9 | [![Chat][chat-badge]][chat] 10 | 11 | **[rehype][]** plugin to apply syntax highlighting to code with 12 | [`lowlight`][lowlight]. 13 | 14 | ## Contents 15 | 16 | * [What is this?](#what-is-this) 17 | * [When should I use this?](#when-should-i-use-this) 18 | * [Install](#install) 19 | * [Use](#use) 20 | * [API](#api) 21 | * [`unified().use(rehypeHighlight[, options])`](#unifieduserehypehighlight-options) 22 | * [`Options`](#options) 23 | * [Example](#example) 24 | * [Example: ignoring](#example-ignoring) 25 | * [Example: registering](#example-registering) 26 | * [Example: aliases](#example-aliases) 27 | * [Example: sanitation](#example-sanitation) 28 | * [Example: line numbering and highlighting](#example-line-numbering-and-highlighting) 29 | * [Types](#types) 30 | * [HTML](#html) 31 | * [CSS](#css) 32 | * [Compatibility](#compatibility) 33 | * [Security](#security) 34 | * [Related](#related) 35 | * [Contribute](#contribute) 36 | * [License](#license) 37 | 38 | ## What is this? 39 | 40 | This package is a [unified][] ([rehype][]) plugin to perform syntax 41 | highlighting. 42 | It uses `highlight.js` through `lowlight`, which is pretty fast, relatively 43 | small, and quite good. 44 | This package bundles 37 [common languages][lowlight-common] by default and you 45 | can register more (190 with [`all`][lowlight-all]). 46 | 47 | It looks for `` elements (when directly in `
` elements) and changes
 48 | them.
 49 | You can specify the code language (such as Python) with a `language-*` or
 50 | `lang-*` class, where the `*` can be for example `js` (so `language-js`), `md`,
 51 | `css`, etc.
 52 | By default, code without such a language class is not highlighted.
 53 | Pass `detect: true` to detect their programming language and highlight the code
 54 | anyway.
 55 | You can prevent specific blocks from being highlighted with a `no-highlight` or
 56 | `nohighlight` class on the ``.
 57 | 
 58 | **unified** is a project that transforms content with abstract syntax trees
 59 | (ASTs).
 60 | **rehype** adds support for HTML to unified.
 61 | **hast** is the HTML AST that rehype uses.
 62 | This is a rehype plugin that applies syntax highlighting to the AST.
 63 | 
 64 | ## When should I use this?
 65 | 
 66 | This project is useful when you want to perform syntax highlighting in rehype.
 67 | One reason to do that is that it typically means the highlighting happens once
 68 | at build time instead of every time at run time.
 69 | 
 70 | When you want a high quality highlighter that can support tons of grammars and
 71 | approaches how GitHub renders code,
 72 | you can use [`rehype-starry-night`][rehype-starry-night].
 73 | 
 74 | This plugin is built on [`lowlight`][lowlight], which is a virtual version of
 75 | highlight.js.
 76 | You can make a plugin based on this one with lowlight when you want to do things
 77 | differently.
 78 | 
 79 | ## Install
 80 | 
 81 | This package is [ESM only][esm].
 82 | In Node.js (version 16+), install with [npm][]:
 83 | 
 84 | ```sh
 85 | npm install rehype-highlight
 86 | ```
 87 | 
 88 | In Deno with [`esm.sh`][esmsh]:
 89 | 
 90 | ```js
 91 | import rehypeHighlight from 'https://esm.sh/rehype-highlight@6'
 92 | ```
 93 | 
 94 | In browsers with [`esm.sh`][esmsh]:
 95 | 
 96 | ```html
 97 | 
100 | ```
101 | 
102 | ## Use
103 | 
104 | Say we have the following file `example.html`:
105 | 
106 | ```html
107 | 

Hello World!

108 | 109 |
var name = "World";
110 | console.warn("Hello, " + name + "!")
111 | ``` 112 | 113 | …and our module `example.js` contains: 114 | 115 | ```js 116 | import rehypeHighlight from 'rehype-highlight' 117 | import rehypeParse from 'rehype-parse' 118 | import rehypeStringify from 'rehype-stringify' 119 | import {read} from 'to-vfile' 120 | import {unified} from 'unified' 121 | 122 | const file = await read('example.html') 123 | 124 | await unified() 125 | .use(rehypeParse, {fragment: true}) 126 | .use(rehypeHighlight) 127 | .use(rehypeStringify) 128 | .process(file) 129 | 130 | console.log(String(file)) 131 | ``` 132 | 133 | …then running `node example.js` yields: 134 | 135 | ```html 136 |

Hello World!

137 | 138 |
var name = "World";
139 | console.warn("Hello, " + name + "!")
140 | ``` 141 | 142 | ## API 143 | 144 | This package exports no identifiers. 145 | The default export is [`rehypeHighlight`][api-rehype-highlight]. 146 | 147 | ### `unified().use(rehypeHighlight[, options])` 148 | 149 | Apply syntax highlighting. 150 | 151 | ###### Parameters 152 | 153 | * `options` ([`Options`][api-options], optional) 154 | — configuration 155 | 156 | ###### Returns 157 | 158 | Transform ([`Transformer`][unified-transformer]). 159 | 160 | ### `Options` 161 | 162 | Configuration (TypeScript type). 163 | 164 | ###### Fields 165 | 166 | * `aliases` (`Record | string>`, optional) 167 | — register more aliases; 168 | passed to [`lowlight.registerAlias`][lowlight-register-alias] 169 | * `detect` (`boolean`, default: `false`) 170 | — highlight code without language classes by guessing its programming 171 | language 172 | * `languages` (`Record`, default: 173 | [`common`][lowlight-common]) 174 | — register languages; passed to [`lowlight.register`][lowlight-register] 175 | * `plainText` (`Array`, optional) 176 | — list of language names to not highlight; 177 | note you can also add `no-highlight` classes 178 | * `prefix` (`string`, default: `'hljs-'`) 179 | — class prefix 180 | * `subset` (`Array`, default: default: [all][lowlight-all] registered 181 | languages) 182 | — names of languages to check when detecting 183 | 184 | ## Example 185 | 186 | ### Example: ignoring 187 | 188 | There are three ways to not apply syntax highlighting to code blocks. 189 | They can be ignored with an explicit class of `no-highlight` (or `nohighlight`), 190 | an explicit language name that’s listed in `options.plainText`, or by setting 191 | `options.detect` to `false` (default), which prevents `` without a class 192 | from being automatically detected. 193 | 194 | For example, with `example.html`: 195 | 196 | ```html 197 |
this won’t be highlighted due to `detect: false` (default)
198 | 199 |
this won’t be highlighted due to its class
200 | 201 |
this won’t be highlighted due to `plainText: ['txt']`
202 | ``` 203 | 204 | …and `example.js`: 205 | 206 | ```js 207 | import {rehype} from 'rehype' 208 | import rehypeHighlight from 'rehype-highlight' 209 | import {read} from 'to-vfile' 210 | 211 | const file = await rehype() 212 | .data('settings', {fragment: true}) 213 | .use(rehypeHighlight, {plainText: ['txt', 'text']}) 214 | .process(await read('example.html')) 215 | 216 | console.log(String(file)) 217 | ``` 218 | 219 | …then running that yields the same as `example.html`: none of them are 220 | highlighted. 221 | 222 | ### Example: registering 223 | 224 | `rehype-highlight` supports 37 commonly used languages by default. 225 | This makes it small to load in browsers and Node.js, while supporting enough 226 | default cases. 227 | You can add more languages. 228 | 229 | For example, with `example.html`: 230 | 231 | ```html 232 |
a ::= 'a' | 'A'
233 | ``` 234 | 235 | …and `example.js`: 236 | 237 | ```js 238 | import bnf from 'highlight.js/lib/languages/bnf' 239 | import {common} from 'lowlight' 240 | import {rehype} from 'rehype' 241 | import rehypeHighlight from 'rehype-highlight' 242 | import {read} from 'to-vfile' 243 | 244 | const file = await rehype() 245 | .data('settings', {fragment: true}) 246 | .use(rehypeHighlight, {languages: {...common, bnf}}) 247 | .process(await read('example.html')) 248 | 249 | console.log(String(file)) 250 | ``` 251 | 252 | …then running that yields: 253 | 254 | ```html 255 |
a ::= 'a' | 'A'
256 | ``` 257 | 258 | ### Example: aliases 259 | 260 | You can map your own language flags to `highlight.js` languages. 261 | 262 | For example, with `example.html`: 263 | 264 | ```html 265 |
console.log(1)
266 | ``` 267 | 268 | …and `example.js`: 269 | 270 | ```js 271 | import {rehype} from 'rehype' 272 | import rehypeHighlight from 'rehype-highlight' 273 | import {read} from 'to-vfile' 274 | 275 | const file = await rehype() 276 | .data('settings', {fragment: true}) 277 | // 👉 **Note**: the keys are language names, values are the aliases that you 278 | // want to also allow as `x` in `language-x` classes. 279 | .use(rehypeHighlight, {aliases: {'javascript': 'custom-script'}}) 280 | .process(await read('example.html')) 281 | 282 | console.log(String(file)) 283 | ``` 284 | 285 | …then running that yields: 286 | 287 | ```html 288 |
console.log(1)
289 | ``` 290 | 291 | ### Example: sanitation 292 | 293 | Applying syntax highlighting in rehype operates on `` elements with 294 | certain classes and it injects many `` elements with classes. 295 | Allowing arbitrary classes is an opening for security vulnerabilities. 296 | 297 | To make HTML safe in rehype, use [`rehype-sanitize`][rehype-sanitize]. 298 | It specifically allows `/^language-./` class names on `` elements. 299 | Which we also use. 300 | So you can use `rehype-highlight` after `rehype-sanitize`: 301 | 302 | ```js 303 | import {unified} from 'unified' 304 | import rehypeHighlight from './index.js' 305 | import rehypeParse from 'rehype-parse' 306 | import rehypeSanitize, {defaultSchema} from 'rehype-sanitize' 307 | import rehypeStringify from 'rehype-stringify' 308 | 309 | const file = await unified() 310 | .use(rehypeParse, {fragment: true}) 311 | .use(rehypeSanitize) 312 | .use(rehypeHighlight) 313 | .use(rehypeStringify) 314 | .process('
console.log(1)
') 315 | 316 | console.log(String(file)) 317 | ``` 318 | 319 | …yields: 320 | 321 | ```html 322 |
console.log(1)
323 | ``` 324 | 325 | Using plugins *after* `rehype-sanitize`, like we just did, is *safe* assuming 326 | you trust those plugins. 327 | If you do not trust `rehype-highlight`, you can use it before. 328 | But then you need to configure `rehype-sanitize` to keep the classes you allow: 329 | 330 | ```js 331 | import {unified} from 'unified' 332 | import rehypeHighlight from './index.js' 333 | import rehypeParse from 'rehype-parse' 334 | import rehypeSanitize, {defaultSchema} from 'rehype-sanitize' 335 | import rehypeStringify from 'rehype-stringify' 336 | 337 | const file = await unified() 338 | .use(rehypeParse, {fragment: true}) 339 | .use(rehypeHighlight) 340 | .use(rehypeSanitize, { 341 | ...defaultSchema, 342 | attributes: { 343 | ...defaultSchema.attributes, 344 | span: [ 345 | ...(defaultSchema.attributes?.span || []), 346 | // Allow all class names starting with `hljs-`. 347 | ['className', /^hljs-./] 348 | // Alternatively, to allow only certain class names: 349 | // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] 350 | ] 351 | }, 352 | tagNames: [...(defaultSchema.tagNames || []), 'span'] 353 | }) 354 | .use(rehypeStringify) 355 | .process('
console.log(1)
') 356 | 357 | console.log(String(file)) 358 | ``` 359 | 360 | ### Example: line numbering and highlighting 361 | 362 | You can add support for line numbers and line highlighting with a separate 363 | plugin, [`rehype-highlight-code-lines`][rehype-highlight-code-lines]. 364 | 365 | `rehype-highlight-code-lines` runs on `` elements with directives 366 | like `showLineNumbers` and range number in curly braces like `{2-4, 8}`. 367 | That directives can be passed as a word in markdown 368 | (` ```ts showLineNumbers {2-4,8} `) or as a class and attribute in HTML 369 | (``). 370 | 371 | For example, with `example.html`: 372 | 373 | ```html 374 |
console.log("Hi!")
375 | ``` 376 | 377 | …and `example.js`: 378 | 379 | ```js 380 | import {rehype} from 'rehype' 381 | import rehypeHighlight from 'rehype-highlight' 382 | import rehypeHighlightCodeLines from 'rehype-highlight-code-lines' 383 | import {read} from 'to-vfile' 384 | 385 | const file = await rehype() 386 | .data('settings', {fragment: true}) 387 | .use(rehypeHighlight) 388 | .use(rehypeHighlightCodeLines) 389 | .process(await read('example.html')) 390 | 391 | console.log(String(file)) 392 | ``` 393 | 394 | …then running that yields: 395 | 396 | ```html 397 |
console.log("Hi!")
398 | ``` 399 | 400 | ## Types 401 | 402 | This package is fully typed with [TypeScript][]. 403 | It exports the additional type [`Options`][api-options]. 404 | 405 | ## HTML 406 | 407 | On the input side, 408 | this plugin looks for code blocks with a `language-*` class. 409 | 410 | On the output side, 411 | this plugin generates `span` elements with classes that can be enhanced with 412 | CSS. 413 | 414 | ## CSS 415 | 416 | See [“CSS” in `lowlight`][github-lowlight-css] for more info. 417 | 418 | ## Compatibility 419 | 420 | Projects maintained by the unified collective are compatible with maintained 421 | versions of Node.js. 422 | 423 | When we cut a new major release, we drop support for unmaintained versions of 424 | Node. 425 | This means we try to keep the current release line, `rehype-highlight@^7`, 426 | compatible with Node.js 16. 427 | 428 | This plugin works with `rehype-parse` version 1+, `rehype-stringify` version 1+, 429 | `rehype` version 1+, and `unified` version 4+. 430 | 431 | ## Security 432 | 433 | Use of `rehype-highlight` *should* be safe to use as `highlight.js` and 434 | `lowlight` *should* be safe to use. 435 | When in doubt, use [`rehype-sanitize`][rehype-sanitize]. 436 | 437 | ## Related 438 | 439 | * [`rehype-starry-night`][rehype-starry-night] 440 | — apply syntax highlighting with `starry-night` 441 | * [`rehype-meta`](https://github.com/rehypejs/rehype-meta) 442 | — add metadata to the head of a document 443 | * [`rehype-document`](https://github.com/rehypejs/rehype-document) 444 | — wrap a fragment in a document 445 | * [`rehype-highlight-code-lines`][rehype-highlight-code-lines] 446 | — add line numbers and highlight lines 447 | 448 | ## Contribute 449 | 450 | See [`contributing.md`][contributing] in [`rehypejs/.github`][health] for ways 451 | to get started. 452 | See [`support.md`][support] for ways to get help. 453 | 454 | This project has a [code of conduct][coc]. 455 | By interacting with this repository, organization, or community you agree to 456 | abide by its terms. 457 | 458 | ## License 459 | 460 | [MIT][license] © [Titus Wormer][author] 461 | 462 | 463 | 464 | [api-options]: #options 465 | 466 | [api-rehype-highlight]: #unifieduserehypehighlight-options 467 | 468 | [author]: https://wooorm.com 469 | 470 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 471 | 472 | [build]: https://github.com/rehypejs/rehype-highlight/actions 473 | 474 | [build-badge]: https://github.com/rehypejs/rehype-highlight/workflows/main/badge.svg 475 | 476 | [chat]: https://github.com/rehypejs/rehype/discussions 477 | 478 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 479 | 480 | [coc]: https://github.com/rehypejs/.github/blob/HEAD/code-of-conduct.md 481 | 482 | [collective]: https://opencollective.com/unified 483 | 484 | [contributing]: https://github.com/rehypejs/.github/blob/HEAD/contributing.md 485 | 486 | [coverage]: https://codecov.io/github/rehypejs/rehype-highlight 487 | 488 | [coverage-badge]: https://img.shields.io/codecov/c/github/rehypejs/rehype-highlight.svg 489 | 490 | [downloads]: https://www.npmjs.com/package/rehype-highlight 491 | 492 | [downloads-badge]: https://img.shields.io/npm/dm/rehype-highlight.svg 493 | 494 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 495 | 496 | [esmsh]: https://esm.sh 497 | 498 | [github-lowlight-css]: https://github.com/wooorm/lowlight#css 499 | 500 | [health]: https://github.com/rehypejs/.github 501 | 502 | [license]: license 503 | 504 | [lowlight]: https://github.com/wooorm/lowlight 505 | 506 | [lowlight-all]: https://github.com/wooorm/lowlight#all 507 | 508 | [lowlight-common]: https://github.com/wooorm/lowlight#common 509 | 510 | [lowlight-register]: https://github.com/wooorm/lowlight#lowlightregistergrammars 511 | 512 | [lowlight-register-alias]: https://github.com/wooorm/lowlight#lowlightregisteraliasaliases 513 | 514 | [npm]: https://docs.npmjs.com/cli/install 515 | 516 | [rehype]: https://github.com/rehypejs/rehype 517 | 518 | [rehype-highlight-code-lines]: https://github.com/ipikuka/rehype-highlight-code-lines 519 | 520 | [rehype-sanitize]: https://github.com/rehypejs/rehype-sanitize 521 | 522 | [rehype-starry-night]: https://github.com/rehypejs/rehype-starry-night 523 | 524 | [size]: https://bundlejs.com/?q=rehype-highlight 525 | 526 | [size-badge]: https://img.shields.io/bundlejs/size/rehype-highlight 527 | 528 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 529 | 530 | [support]: https://github.com/rehypejs/.github/blob/HEAD/support.md 531 | 532 | [typescript]: https://www.typescriptlang.org 533 | 534 | [unified]: https://github.com/unifiedjs/unified 535 | 536 | [unified-transformer]: https://github.com/unifiedjs/unified#transformer 537 | --------------------------------------------------------------------------------