├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── source ├── index.test.ts └── index.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: leafac 2 | custom: 3 | [ 4 | "https://paypal.me/LeandroFacchinettiEU", 5 | "https://btc.com/34KJBgtaFYMtDqpSgMayw9qiKWg2GQXA9M", 6 | ] 7 | github: leafac 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | jobs: 3 | test: 4 | strategy: 5 | matrix: 6 | os: [windows, macos, ubuntu] 7 | node-version: [14, 16, 18, 19] 8 | runs-on: ${{ matrix.os }}-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: ${{ matrix.node-version }} 14 | - run: npm install-ci-test 15 | 16 | npm-publish: 17 | if: startsWith(github.ref, 'refs/tags/v') 18 | needs: test 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: 19 25 | registry-url: https://registry.npmjs.org/ 26 | - run: npm ci && npm publish 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /distribution/ 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leafac/rehype-shiki/acb172e09d4304e2e88eb79470d48ec91f22892b/.npmignore -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Leandro Facchinetti (https://leafac.com) 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 |

@leafac/rehype-shiki

2 |

Rehype plugin to highlight code blocks with Shiki

3 |

4 | Source 5 | Package 6 | Continuous Integration 7 |

8 | 9 | ### Installation 10 | 11 | ```console 12 | $ npm install @leafac/rehype-shiki shiki 13 | ``` 14 | 15 | ### Format 16 | 17 | Code blocks must have the following format: 18 | 19 | ```html 20 |
21 | 
22 | return unified();
23 | 
24 | 
25 | ``` 26 | 27 | This is the format produced by [remark-parse](https://github.com/remarkjs/remark/tree/main/packages/remark-parse) & [remark-rehype](https://github.com/remarkjs/remark-rehype) from the following Markdown: 28 | 29 | ````markdown 30 | ```javascript 31 | return unified(); 32 | ``` 33 | ```` 34 | 35 | ### Usage 36 | 37 | See [`source/index.test.ts`](source/index.test.ts) for examples. 38 | 39 | #### Options 40 | 41 | - `highlighter` (required): An instance of the Shiki highlighter, or an object whose keys are identifiers and values are Shiki highlighters, in which case @leafac/rehype-shiki combines the outputs of all the highlighters. 42 | - `throwOnUnsupportedLanguage` (default: `false`): A boolean indicating whether to throw an exception if a code block refers to an unsupported language. 43 | 44 | ### Security 45 | 46 | @leafac/rehype-shiki doesn’t open you up to [cross-site scripting (XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting) attacks as long as Shiki doesn’t (which it doesn’t). 47 | 48 | ### How Is This Different from [rehype-shiki](https://github.com/rsclarke/rehype-shiki)? 49 | 50 | rehype-shiki is great! That’s how I learned about Shiki and I fell in love with it. The following are the ways in which @leafac/rehype-shiki is different: 51 | 52 | 1. TypeScript support. 53 | 2. Shiki is declared as a [`peerDependency`](https://docs.npmjs.com/cli/v6/configuring-npm/package-json#peerdependencies), so @leafac/rehype-shiki doesn’t have to be updated when new versions of Shiki are released (as long as Shiki’s API remain compatible). See https://github.com/rsclarke/rehype-shiki/pull/48 https://github.com/rsclarke/rehype-shiki/pull/46 https://github.com/rsclarke/rehype-shiki/issues/47 https://github.com/rsclarke/rehype-shiki/issues/2. 54 | 3. You must pass in an instance of the Shiki highlighter, @leafac/rehype-shiki won’t create one for you. This means that: 55 | 1. The Shiki highlighter instance is reused on every invocation of the processor, [instead of being recreated every time you call the processor](https://github.com/rsclarke/rehype-shiki/blob/3ebaeab3297d1cbe9ac75e2294ab636bbe250541/index.js#L38-L43). 56 | 2. The transformer is synchronous, so you may use it with `.processSync()`. 57 | 4. Instead of [looking at the tokens produced by Shiki and generating hast](https://github.com/rsclarke/rehype-shiki/blob/3ebaeab3297d1cbe9ac75e2294ab636bbe250541/index.js#L69-L97), [@leafac/rehype-shiki lets Shiki produce HTML and parses the result](https://github.com/leafac/rehype-shiki/blob/a745b01d98608fb934c1bdbe9a1399e8b9dec1ed/src/index.ts#L32-L39). The advantage is that [when Shiki improves the output with things like italics](https://github.com/shikijs/shiki/pull/23) @leafac/rehype-shiki will pick the changes up with no extra work. The disadvantage is that we’re producing HTML as a string and then parsing it right back; this is slower, but in most cases it won’t matter and I think the previous advantages outweighs this disadvantage. (Also, the `language-*` class will be removed from the produced HTML, so you may need to adapt your CSS.) 58 | 5. Support for multiple highlighters. 59 | 60 | That said, [I contacted the maintainers of rehype-shiki and try to merge the code bases](https://github.com/rsclarke/rehype-shiki/issues/49). We’ll see… 61 | 62 | ### Changelog 63 | 64 | ### 2.2.0 65 | 66 | - Updated the peer dependency to `shiki@0.11.1`. 67 | 68 | #### 2.1.0 69 | 70 | - Added a feature that preserves the `position` of the top `element` node. Useful for products that need to map the HTML back to the Markdown that generated it (see tests). 71 | 72 | #### 2.0.0 73 | 74 | - [ESM only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c). 75 | - Compatible with unified 10. 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@leafac/rehype-shiki", 3 | "version": "2.2.1", 4 | "description": "Rehype plugin to highlight code blocks with Shiki", 5 | "exports": "./distribution/index.js", 6 | "types": "./distribution/index.d.ts", 7 | "repository": "leafac/rehype-shiki", 8 | "keywords": [ 9 | "shiki", 10 | "syntax", 11 | "highlight", 12 | "rehype" 13 | ], 14 | "author": "Leandro Facchinetti (https://leafac.com)", 15 | "license": "MIT", 16 | "bugs": "https://github.com/leafac/rehype-shiki/issues", 17 | "homepage": "https://github.com/leafac/rehype-shiki", 18 | "funding": [ 19 | "https://patreon.com/leafac", 20 | "https://paypal.me/LeandroFacchinettiEU", 21 | "https://github.com/sponsors/leafac", 22 | "https://btc.com/34KJBgtaFYMtDqpSgMayw9qiKWg2GQXA9M" 23 | ], 24 | "publishConfig": { 25 | "access": "public" 26 | }, 27 | "scripts": { 28 | "test": "prettier --check \"./source/**/*\" --end-of-line auto && npm run prepare && cross-env NODE_OPTIONS=--experimental-vm-modules jest", 29 | "prepare": "tsc" 30 | }, 31 | "dependencies": { 32 | "@leafac/html": "^3.0.0", 33 | "hast-util-to-text": "^3.1.1", 34 | "rehype-parse": "^8.0.3", 35 | "unified": "^10.1.0", 36 | "unist-util-modify-children": "^3.0.0" 37 | }, 38 | "peerDependencies": { 39 | "shiki": "^0.11.1" 40 | }, 41 | "devDependencies": { 42 | "@types/jest": "^27.0.2", 43 | "@types/node": "^16.11.6", 44 | "cross-env": "^7.0.3", 45 | "jest": "^27.3.1", 46 | "prettier": "^2.4.1", 47 | "rehype-stringify": "^9.0.2", 48 | "remark-parse": "^10.0.0", 49 | "remark-rehype": "^10.0.0", 50 | "shiki": "^0.10.0", 51 | "typescript": "^4.4.4", 52 | "unist-util-visit": "^4.1.0" 53 | }, 54 | "jest": { 55 | "rootDir": "./distribution/" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /source/index.test.ts: -------------------------------------------------------------------------------- 1 | import unifiedTypes, { unified } from "unified"; 2 | import remarkParse from "remark-parse"; 3 | import remarkRehype, { HastRoot } from "remark-rehype"; 4 | import rehypeShiki from "."; 5 | import rehypeStringify from "rehype-stringify"; 6 | import * as shiki from "shiki"; 7 | import { visit as unistUtilVisit } from "unist-util-visit"; 8 | 9 | test("supported language", async () => { 10 | expect( 11 | unified() 12 | .use(remarkParse) 13 | .use(remarkRehype) 14 | .use(rehypeShiki, { 15 | highlighter: await shiki.getHighlighter({ theme: "light-plus" }), 16 | }) 17 | .use(rehypeStringify) 18 | .processSync( 19 | ` 20 | \`\`\`javascript 21 | return unified() 22 | \`\`\` 23 | ` 24 | ) 25 | .toString() 26 | ).toMatchInlineSnapshot( 27 | `"
return unified()
"` 28 | ); 29 | }); 30 | 31 | test("text", async () => { 32 | expect( 33 | unified() 34 | .use(remarkParse) 35 | .use(remarkRehype) 36 | .use(rehypeShiki, { 37 | highlighter: await shiki.getHighlighter({ theme: "light-plus" }), 38 | }) 39 | .use(rehypeStringify) 40 | .processSync( 41 | ` 42 | \`\`\`text 43 | Leandro Facchinetti 44 | \`\`\` 45 | ` 46 | ) 47 | .toString() 48 | ).toMatchInlineSnapshot( 49 | `"
Leandro Facchinetti
"` 50 | ); 51 | }); 52 | 53 | test("unsupported language", async () => { 54 | expect( 55 | unified() 56 | .use(remarkParse) 57 | .use(remarkRehype) 58 | .use(rehypeShiki, { 59 | highlighter: await shiki.getHighlighter({ theme: "light-plus" }), 60 | }) 61 | .use(rehypeStringify) 62 | .processSync( 63 | ` 64 | \`\`\`not-a-language 65 | Leandro Facchinetti 66 | \`\`\` 67 | ` 68 | ) 69 | .toString() 70 | ).toMatchInlineSnapshot(` 71 | "
Leandro Facchinetti
 72 |     
" 73 | `); 74 | }); 75 | 76 | test("throw on unsupported language", async () => { 77 | await expect(async () => { 78 | unified() 79 | .use(remarkParse) 80 | .use(remarkRehype) 81 | .use(rehypeShiki, { 82 | highlighter: await shiki.getHighlighter({ theme: "light-plus" }), 83 | throwOnUnsupportedLanguage: true, 84 | }) 85 | .use(rehypeStringify) 86 | .processSync( 87 | ` 88 | \`\`\`not-a-language 89 | Leandro Facchinetti 90 | \`\`\` 91 | ` 92 | ) 93 | .toString(); 94 | }).rejects.toThrowErrorMatchingInlineSnapshot( 95 | `"No language registration for not-a-language"` 96 | ); 97 | }); 98 | 99 | test("multiple highlighters", async () => { 100 | expect( 101 | unified() 102 | .use(remarkParse) 103 | .use(remarkRehype) 104 | .use(rehypeShiki, { 105 | highlighter: { 106 | light: await shiki.getHighlighter({ theme: "light-plus" }), 107 | dark: await shiki.getHighlighter({ theme: "dark-plus" }), 108 | }, 109 | }) 110 | .use(rehypeStringify) 111 | .processSync( 112 | ` 113 | \`\`\`javascript 114 | return unified() 115 | \`\`\` 116 | ` 117 | ) 118 | .toString() 119 | ).toMatchInlineSnapshot(` 120 | " 121 |
122 | 123 |
124 |
return unified()
125 |
126 | 127 |
128 |
return unified()
129 |
130 | 131 |
132 | " 133 | `); 134 | }); 135 | 136 | test("preserve position", async () => { 137 | const positionSaver: unifiedTypes.Plugin = () => (tree) => { 138 | unistUtilVisit(tree, (node) => { 139 | if ((node as any).properties !== undefined && node.position !== undefined) 140 | (node as any).properties.dataPosition = JSON.stringify(node.position); 141 | }); 142 | }; 143 | expect( 144 | unified() 145 | .use(remarkParse) 146 | .use(remarkRehype) 147 | .use(rehypeShiki, { 148 | highlighter: await shiki.getHighlighter({ theme: "light-plus" }), 149 | }) 150 | .use(positionSaver) 151 | .use(rehypeStringify) 152 | .processSync( 153 | ` 154 | Some 155 | 156 | text 157 | 158 | before 159 | 160 | \`\`\`javascript 161 | return unified() 162 | \`\`\` 163 | 164 | and 165 | 166 | some 167 | 168 | after 169 | ` 170 | ) 171 | .toString() 172 | ).toMatchInlineSnapshot(` 173 | "

Some

174 |

text

175 |

before

176 |
return unified()
177 |

and

178 |

some

179 |

after

" 180 | `); 181 | 182 | expect( 183 | unified() 184 | .use(remarkParse) 185 | .use(remarkRehype) 186 | .use(rehypeShiki, { 187 | highlighter: { 188 | light: await shiki.getHighlighter({ theme: "light-plus" }), 189 | dark: await shiki.getHighlighter({ theme: "dark-plus" }), 190 | }, 191 | }) 192 | .use(positionSaver) 193 | .use(rehypeStringify) 194 | .processSync( 195 | ` 196 | Some 197 | 198 | text 199 | 200 | before 201 | 202 | \`\`\`javascript 203 | return unified() 204 | \`\`\` 205 | 206 | and 207 | 208 | some 209 | 210 | after 211 | ` 212 | ) 213 | .toString() 214 | ).toMatchInlineSnapshot(` 215 | "

Some

216 |

text

217 |

before

218 | 219 |
220 | 221 |
222 |
return unified()
223 |
224 | 225 |
226 |
return unified()
227 |
228 | 229 |
230 | 231 |

and

232 |

some

233 |

after

" 234 | `); 235 | }); 236 | 237 | test("only one root for separate transforms", async () => { 238 | const ast = unified() 239 | .use(remarkParse) 240 | .use(remarkRehype) 241 | .use(function () { 242 | this.Compiler = (html) => html; 243 | }).processSync(` 244 | \`\`\`js 245 | console.log(value == null) 246 | \`\`\` 247 | 248 | `).result as HastRoot; 249 | const transformedAst = unified() 250 | .use(rehypeShiki, { 251 | highlighter: await shiki.getHighlighter({ theme: "light-plus" }), 252 | }) 253 | .runSync(ast); 254 | 255 | let rootCount = 0; 256 | unistUtilVisit(transformedAst, "root", () => rootCount++); 257 | expect(rootCount).toBe(1); 258 | }); 259 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | import unifiedTypes, { unified } from "unified"; 2 | import { modifyChildren as unistUtilModifyChildren } from "unist-util-modify-children"; 3 | import { toText as hastUtilToText } from "hast-util-to-text"; 4 | import * as Shiki from "shiki"; 5 | import rehypeParse from "rehype-parse"; 6 | import { html } from "@leafac/html"; 7 | 8 | const attacher: unifiedTypes.Plugin< 9 | [ 10 | { 11 | highlighter: Shiki.Highlighter | { [key: string]: Shiki.Highlighter }; 12 | throwOnUnsupportedLanguage?: boolean; 13 | } 14 | ] 15 | > = 16 | ({ highlighter, throwOnUnsupportedLanguage = false }) => 17 | (tree) => { 18 | unistUtilModifyChildren((node: any, index, parent) => { 19 | if ( 20 | node.tagName === "pre" && 21 | Array.isArray(node.children) && 22 | node.children.length === 1 && 23 | node.children[0].tagName === "code" && 24 | typeof node.children[0].properties === "object" && 25 | node.children[0].properties !== null && 26 | Array.isArray(node.children[0].properties.className) && 27 | typeof node.children[0].properties.className[0] === "string" && 28 | node.children[0].properties.className[0].startsWith("language-") 29 | ) { 30 | const code = hastUtilToText(node).slice(0, -1); 31 | const language = node.children[0].properties.className[0].slice( 32 | "language-".length 33 | ); 34 | let output: string; 35 | try { 36 | if (typeof highlighter.codeToHtml === "function") 37 | output = highlighter.codeToHtml(code, language); 38 | else 39 | output = html` 40 |
41 | $${Object.entries(highlighter).map( 42 | ([name, highlighter]: [string, Shiki.Highlighter]) => 43 | html` 44 |
45 | $${highlighter.codeToHtml(code, language)} 46 |
47 | ` 48 | )} 49 |
50 | `; 51 | } catch (error) { 52 | if (throwOnUnsupportedLanguage) throw error; 53 | else return; 54 | } 55 | const parsedOutput = hastParser.parse(output); 56 | parsedOutput.children.find( 57 | (child) => child.type === "element" 58 | )!.position = node.position; 59 | parent.children.splice(index, 1, ...parsedOutput.children); 60 | return index + parsedOutput.children.length; 61 | } 62 | })(tree as any); 63 | }; 64 | 65 | const hastParser = unified().use(rehypeParse, { fragment: true }); 66 | 67 | export default attacher; 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./source/", 4 | "outDir": "./distribution/", 5 | 6 | "module": "ESNext", 7 | "moduleResolution": "NodeNext", 8 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 9 | "skipLibCheck": true, 10 | "allowSyntheticDefaultImports": true, 11 | "target": "ESNext", 12 | 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": true, 16 | 17 | "strict": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------