├── .github ├── FUNDING.yml ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── playground ├── index.html ├── lowlight.ts ├── main.ts ├── refractor.ts ├── schema.ts ├── setup.ts ├── shiki-lazy.ts ├── shiki.ts └── sugar-high.ts ├── pnpm-lock.yaml ├── src ├── cache.ts ├── hast.ts ├── index.ts ├── lowlight.ts ├── plugin.ts ├── refractor.ts ├── shiki.ts ├── sugar-high.ts └── types.ts ├── test ├── helpers.ts └── plugin.spec.ts ├── tsconfig.json ├── tsup.config.ts └── vite.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ocavue] 2 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: Setup the environment 3 | 4 | inputs: 5 | node-version: 6 | description: The version of node.js 7 | required: false 8 | default: '18' 9 | 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v2 15 | with: 16 | run_install: false 17 | 18 | - name: Setup node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ inputs.node-version }} 22 | cache: pnpm 23 | registry-url: 'https://registry.npmjs.org' 24 | 25 | - name: Install 26 | run: pnpm install 27 | shell: bash 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: ./.github/actions/setup 19 | 20 | - name: Lint 21 | run: pnpm run lint 22 | 23 | typecheck: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: ./.github/actions/setup 29 | 30 | - name: Typecheck 31 | run: pnpm run typecheck 32 | 33 | test: 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | matrix: 38 | node: [18.x, 20.x, 22.x] 39 | fail-fast: false 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - uses: ./.github/actions/setup 45 | with: 46 | node-version: ${{ matrix.node }} 47 | 48 | - name: Build 49 | run: pnpm run build 50 | 51 | - name: Test 52 | run: pnpm run test 53 | 54 | build: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - uses: ./.github/actions/setup 60 | 61 | - name: Build 62 | run: pnpm run build 63 | 64 | - name: Publish snapshot packages 65 | if: ${{ github.event_name == 'pull_request' }} 66 | run: ./node_modules/.bin/pkg-pr-new publish --pnpm 67 | 68 | deploy: 69 | runs-on: ubuntu-latest 70 | permissions: 71 | contents: read 72 | deployments: write 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | 77 | - uses: ./.github/actions/setup 78 | with: 79 | node-version: ${{ matrix.node }} 80 | 81 | - name: Build 82 | run: pnpm run build:playground 83 | 84 | - name: Publish to Cloudflare Pages 85 | uses: cloudflare/pages-action@v1 86 | with: 87 | apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} 88 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 89 | projectName: prosemirror-highlight 90 | gitHubToken: ${{ secrets.GITHUB_TOKEN }} 91 | workingDirectory: playground 92 | directory: dist 93 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | version: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: googleapis/release-please-action@v4 13 | id: release-please 14 | with: 15 | release-type: node 16 | outputs: 17 | release_created: ${{ steps.release-please.outputs.release_created }} 18 | 19 | publish: 20 | runs-on: ubuntu-latest 21 | needs: [version] 22 | if: ${{ needs.version.outputs.release_created }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - uses: ./.github/actions/setup 27 | 28 | - name: Build 29 | run: pnpm run build 30 | 31 | - name: Publish to NPM 32 | run: pnpm publish 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | pnpm-lock.yaml 3 | package-lock.json 4 | CHANGELOG.md 5 | 6 | .next 7 | .cache 8 | .DS_Store 9 | .idea 10 | *.log 11 | *.tgz 12 | coverage 13 | dist 14 | lib-cov 15 | logs 16 | node_modules 17 | temp 18 | dist-types 19 | *.tsbuildinfo 20 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.13.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.12.2...v0.13.0) (2025-03-14) 4 | 5 | 6 | ### Features 7 | 8 | * support shiki v3 and shiki-codegen ([#80](https://github.com/ocavue/prosemirror-highlight/issues/80)) ([5690b56](https://github.com/ocavue/prosemirror-highlight/commit/5690b566ec7a2b7291ea6b03e7392d5de01f2d39)) 9 | * update refractor to v5 ([#88](https://github.com/ocavue/prosemirror-highlight/issues/88)) ([8b51bc2](https://github.com/ocavue/prosemirror-highlight/commit/8b51bc262ce1195b9f6ab016a8192acf51bd3839)) 10 | 11 | ## [0.12.2](https://github.com/ocavue/prosemirror-highlight/compare/v0.12.1...v0.12.2) (2025-02-06) 12 | 13 | 14 | ### Bug Fixes 15 | 16 | * support sugar-high v9 ([#75](https://github.com/ocavue/prosemirror-highlight/issues/75)) ([69c8e86](https://github.com/ocavue/prosemirror-highlight/commit/69c8e86245b59ff6ea8e496982d646a6b4eb4daf)) 17 | 18 | ## [0.12.1](https://github.com/ocavue/prosemirror-highlight/compare/v0.12.0...v0.12.1) (2025-02-02) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * correct css variable names ([#72](https://github.com/ocavue/prosemirror-highlight/issues/72)) ([eaf73fd](https://github.com/ocavue/prosemirror-highlight/commit/eaf73fdfd3470759b78c762488dd36811c8e37dc)) 24 | 25 | ## [0.12.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.11.1...v0.12.0) (2025-02-02) 26 | 27 | 28 | ### Features 29 | 30 | * support shiki background color ([#70](https://github.com/ocavue/prosemirror-highlight/issues/70)) ([be0e879](https://github.com/ocavue/prosemirror-highlight/commit/be0e8797e1af7859d83d90c06f5338817769435d)) 31 | 32 | ## [0.11.1](https://github.com/ocavue/prosemirror-highlight/compare/v0.11.0...v0.11.1) (2025-01-22) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * support shiki v2 and sugar-high v0.8 ([#67](https://github.com/ocavue/prosemirror-highlight/issues/67)) ([b7fff1d](https://github.com/ocavue/prosemirror-highlight/commit/b7fff1d609173c37c439e1355e47c252054efe13)) 38 | 39 | ## [0.11.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.10.0...v0.11.0) (2024-12-03) 40 | 41 | 42 | ### Features 43 | 44 | * add `codeBlock` as one of the default node type names ([#61](https://github.com/ocavue/prosemirror-highlight/issues/61)) ([e96ec9e](https://github.com/ocavue/prosemirror-highlight/commit/e96ec9ea6b0ed0ca2ba373f3c9fb0cee2ac1badc)) 45 | 46 | ## [0.10.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.9.0...v0.10.0) (2024-10-11) 47 | 48 | 49 | ### Features 50 | 51 | * support shiki's object `token.htmlStyle` ([#55](https://github.com/ocavue/prosemirror-highlight/issues/55)) ([70fc971](https://github.com/ocavue/prosemirror-highlight/commit/70fc971754597c8fd03c9b72b8442d103404c201)) 52 | 53 | ## [0.9.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.8.0...v0.9.0) (2024-09-04) 54 | 55 | 56 | ### Features 57 | 58 | * support Shiki dual themes ([#50](https://github.com/ocavue/prosemirror-highlight/issues/50)) ([4f48c49](https://github.com/ocavue/prosemirror-highlight/commit/4f48c49f80f87336a07f0a03881f1b3a5b29d649)) 59 | 60 | ## [0.8.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.7.0...v0.8.0) (2024-06-21) 61 | 62 | 63 | ### Features 64 | 65 | * accept shiki theme option ([#40](https://github.com/ocavue/prosemirror-highlight/issues/40)) ([fbfbfc9](https://github.com/ocavue/prosemirror-highlight/commit/fbfbfc9df48ac1bc8bdca2831f468ad77d658619)) 66 | 67 | ## [0.7.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.6.0...v0.7.0) (2024-06-21) 68 | 69 | 70 | ### ⚠ BREAKING CHANGES 71 | 72 | * remove shikiji support ([#36](https://github.com/ocavue/prosemirror-highlight/issues/36)) 73 | 74 | ### Miscellaneous Chores 75 | 76 | * update shiki ([#38](https://github.com/ocavue/prosemirror-highlight/issues/38)) ([9df0d59](https://github.com/ocavue/prosemirror-highlight/commit/9df0d5934616cd82e3d26ea26ac7f77923fff347)) 77 | 78 | 79 | ### Code Refactoring 80 | 81 | * remove shikiji support ([#36](https://github.com/ocavue/prosemirror-highlight/issues/36)) ([383e0d3](https://github.com/ocavue/prosemirror-highlight/commit/383e0d3e8f182c1ae988e545f40c23c5969e5b6c)) 82 | 83 | ## [0.6.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.5.0...v0.6.0) (2024-05-21) 84 | 85 | 86 | ### Features 87 | 88 | * support sugar-high ([#29](https://github.com/ocavue/prosemirror-highlight/issues/29)) ([6d367fe](https://github.com/ocavue/prosemirror-highlight/commit/6d367fe350fdf0b9a0e342276a7caa8fcede9f61)) 89 | 90 | ## [0.5.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.4.1...v0.5.0) (2024-02-07) 91 | 92 | 93 | ### Features 94 | 95 | * update shiki to v1.0.0 ([#24](https://github.com/ocavue/prosemirror-highlight/issues/24)) ([3dab37a](https://github.com/ocavue/prosemirror-highlight/commit/3dab37a41feb1e07be639cd348f5606561de63fe)) 96 | 97 | ## [0.4.1](https://github.com/ocavue/prosemirror-highlight/compare/v0.4.0...v0.4.1) (2024-01-23) 98 | 99 | 100 | ### Bug Fixes 101 | 102 | * support shikiji v0.10 ([#22](https://github.com/ocavue/prosemirror-highlight/issues/22)) ([ff5df2e](https://github.com/ocavue/prosemirror-highlight/commit/ff5df2e6b3033e2928e68ac3e822d908a62f801c)) 103 | 104 | ## [0.4.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.3...v0.4.0) (2024-01-01) 105 | 106 | 107 | ### Features 108 | 109 | * allow the parser to return a promise ([#16](https://github.com/ocavue/prosemirror-highlight/issues/16)) ([83751b3](https://github.com/ocavue/prosemirror-highlight/commit/83751b33c35db0ce78ea95299048ef389a9c9324)) 110 | 111 | ## [0.3.3](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.2...v0.3.3) (2023-12-16) 112 | 113 | 114 | ### Bug Fixes 115 | 116 | * export type Parser ([#13](https://github.com/ocavue/prosemirror-highlight/issues/13)) ([350e37e](https://github.com/ocavue/prosemirror-highlight/commit/350e37eb0db49dcc1f75704553500823facdebf4)) 117 | 118 | ## [0.3.2](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.1...v0.3.2) (2023-12-14) 119 | 120 | 121 | ### Bug Fixes 122 | 123 | * support shikiji v0.9.0 ([7b8f1ce](https://github.com/ocavue/prosemirror-highlight/commit/7b8f1ce1dca760e3657b6e7fc9eba4df172aed47)) 124 | 125 | ## [0.3.1](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.0...v0.3.1) (2023-12-12) 126 | 127 | 128 | ### Bug Fixes 129 | 130 | * fix publish script ([3814c85](https://github.com/ocavue/prosemirror-highlight/commit/3814c8503f73de91a78e9577142b827e493f3b56)) 131 | 132 | ## [0.3.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.2.0...v0.3.0) (2023-12-10) 133 | 134 | 135 | ### Features 136 | 137 | * support refractor ([#4](https://github.com/ocavue/prosemirror-highlight/issues/4)) ([ffee694](https://github.com/ocavue/prosemirror-highlight/commit/ffee694e0113bfe14a6f1dc05d0cbc5fcf679b9d)) 138 | 139 | ## [0.2.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.1.0...v0.2.0) (2023-12-10) 140 | 141 | 142 | ### Features 143 | 144 | * support shikiji ([#2](https://github.com/ocavue/prosemirror-highlight/issues/2)) ([028728c](https://github.com/ocavue/prosemirror-highlight/commit/028728c70835adcd18b36e6e43fe4e736d8b3fcd)) 145 | 146 | ## 0.1.0 (2023-12-10) 147 | 148 | 149 | ### Features 150 | 151 | * support lowlight ([5bab86d](https://github.com/ocavue/prosemirror-highlight/commit/5bab86d6589fb879e94f4419e7ac813fe44589b1)) 152 | * support shiki ([5adab02](https://github.com/ocavue/prosemirror-highlight/commit/5adab02178134a1e32d6860554e2913bacc615f8)) 153 | 154 | 155 | ### Documentation 156 | 157 | * update readme ([cedbc68](https://github.com/ocavue/prosemirror-highlight/commit/cedbc68e1e090a53693aecb21d9c3145cf9dbd73)) 158 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ocavue 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 | # prosemirror-highlight 2 | 3 | [![NPM version](https://img.shields.io/npm/v/prosemirror-highlight?color=a1b858&label=)](https://www.npmjs.com/package/prosemirror-highlight) 4 | 5 | Highlight your [ProseMirror] code blocks with any syntax highlighter you like! 6 | 7 | ## Usage 8 | 9 | ### With [Shiki] 10 | 11 |
12 | Static loading of a fixed set of languages 13 | 14 | ```ts 15 | import { getSingletonHighlighter } from 'shiki' 16 | 17 | import { createHighlightPlugin } from 'prosemirror-highlight' 18 | import { createParser } from 'prosemirror-highlight/shiki' 19 | 20 | const highlighter = await getSingletonHighlighter({ 21 | themes: ['github-light'], 22 | langs: ['javascript', 'typescript', 'python'], 23 | }) 24 | const parser = createParser(highlighter) 25 | export const shikiPlugin = createHighlightPlugin({ parser }) 26 | ``` 27 | 28 |
29 | 30 |
31 | Dynamic loading of arbitrary languages 32 | 33 | ```ts 34 | import { 35 | getSingletonHighlighter, 36 | type BuiltinLanguage, 37 | type Highlighter, 38 | } from 'shiki' 39 | 40 | import { createHighlightPlugin } from 'prosemirror-highlight' 41 | import { createParser, type Parser } from 'prosemirror-highlight/shiki' 42 | 43 | let highlighter: Highlighter | undefined 44 | let parser: Parser | undefined 45 | 46 | /** 47 | * Lazy load highlighter and highlighter languages. 48 | * 49 | * When the highlighter or the required language is not loaded, it returns a 50 | * promise that resolves when the highlighter or the language is loaded. 51 | * Otherwise, it returns an array of decorations. 52 | */ 53 | const lazyParser: Parser = (options) => { 54 | if (!highlighter) { 55 | return getSingletonHighlighter({ 56 | themes: ['github-light'], 57 | langs: [], 58 | }).then((h) => { 59 | highlighter = h 60 | }) 61 | } 62 | 63 | const language = options.language as BuiltinLanguage 64 | if (language && !highlighter.getLoadedLanguages().includes(language)) { 65 | return highlighter.loadLanguage(language) 66 | } 67 | 68 | if (!parser) { 69 | parser = createParser(highlighter) 70 | } 71 | 72 | return parser(options) 73 | } 74 | 75 | export const shikiLazyPlugin = createHighlightPlugin({ parser: lazyParser }) 76 | ``` 77 | 78 |
79 | 80 |
81 | Set code block background color based on theme 82 | 83 | When using Shiki, two CSS variables are set automatically to the `
` element:
 84 | 
 85 | - `--prosemirror-highlight`: The text color of the code block
 86 | - `--prosemirror-highlight-bg`: The background color of the code block
 87 | 
 88 | You can use these variables to set the background color and text color of the code block.
 89 | 
 90 | ```css
 91 | .ProseMirror pre {
 92 |   color: var(--prosemirror-highlight, inherit);
 93 |   background-color: var(--prosemirror-highlight-bg, inherit);
 94 | }
 95 | ```
 96 | 
 97 | 
98 | 99 | ### With [lowlight] (based on [Highlight.js]) 100 | 101 |
102 | Static loading of all languages 103 | 104 | ```ts 105 | import 'highlight.js/styles/default.css' 106 | 107 | import { common, createLowlight } from 'lowlight' 108 | 109 | import { createHighlightPlugin } from 'prosemirror-highlight' 110 | import { createParser } from 'prosemirror-highlight/lowlight' 111 | 112 | const lowlight = createLowlight(common) 113 | const parser = createParser(lowlight) 114 | export const lowlightPlugin = createHighlightPlugin({ parser }) 115 | ``` 116 | 117 |
118 | 119 | ### With [refractor] (based on [Prism]) 120 | 121 |
122 | Static loading of all languages 123 | 124 | ```ts 125 | import { refractor } from 'refractor/all' 126 | 127 | import { createHighlightPlugin } from 'prosemirror-highlight' 128 | import { createParser } from 'prosemirror-highlight/refractor' 129 | 130 | const parser = createParser(refractor) 131 | export const refractorPlugin = createHighlightPlugin({ parser }) 132 | ``` 133 | 134 |
135 | 136 | ### With [Sugar high] 137 | 138 |
139 | Highlight with CSS 140 | 141 | ```ts 142 | import { createHighlightPlugin } from 'prosemirror-highlight' 143 | import { createParser } from 'prosemirror-highlight/sugar-high' 144 | 145 | const parser = createParser() 146 | export const sugarHighPlugin = createHighlightPlugin({ parser }) 147 | ``` 148 | 149 | ```css 150 | :root { 151 | --sh-class: #2d5e9d; 152 | --sh-identifier: #354150; 153 | --sh-sign: #8996a3; 154 | --sh-property: #0550ae; 155 | --sh-entity: #249a97; 156 | --sh-jsxliterals: #6266d1; 157 | --sh-string: #00a99a; 158 | --sh-keyword: #f47067; 159 | --sh-comment: #a19595; 160 | } 161 | ``` 162 | 163 |
164 | 165 | ## Online demo 166 | 167 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/ocavue/prosemirror-highlight?file=playground%2Fmain.ts) 168 | 169 | ## Credits 170 | 171 | - [prosemirror-highlightjs] - Highlight.js syntax highlighting for ProseMirror 172 | 173 | ## License 174 | 175 | MIT 176 | 177 | [ProseMirror]: https://prosemirror.net 178 | [prosemirror-highlightjs]: https://github.com/b-kelly/prosemirror-highlightjs 179 | [lowlight]: https://github.com/wooorm/lowlight 180 | [Highlight.js]: https://github.com/highlightjs/highlight.js 181 | [Shiki]: https://github.com/shikijs/shiki 182 | [refractor]: https://github.com/wooorm/refractor 183 | [Prism]: https://github.com/PrismJS/prism 184 | [Sugar high]: https://github.com/huozhi/sugar-high 185 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { basic, markdown } from '@ocavue/eslint-config' 2 | 3 | export default [...basic(), ...markdown()] 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-highlight", 3 | "type": "module", 4 | "version": "0.13.0", 5 | "packageManager": "pnpm@9.15.9", 6 | "description": "A ProseMirror plugin to highlight code blocks", 7 | "author": "ocavue ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/ocavue", 10 | "homepage": "https://github.com/ocavue/prosemirror-highlight#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/ocavue/prosemirror-highlight.git" 14 | }, 15 | "bugs": "https://github.com/ocavue/prosemirror-highlight/issues", 16 | "keywords": [ 17 | "prosemirror", 18 | "editor", 19 | "highlight.js", 20 | "shiki", 21 | "refractor", 22 | "lowlight", 23 | "prism" 24 | ], 25 | "sideEffects": false, 26 | "main": "./src/index.ts", 27 | "module": "./src/index.ts", 28 | "types": "./src/index.ts", 29 | "exports": { 30 | ".": { 31 | "default": "./src/index.ts" 32 | }, 33 | "./lowlight": { 34 | "default": "./src/lowlight.ts" 35 | }, 36 | "./refractor": { 37 | "default": "./src/refractor.ts" 38 | }, 39 | "./shiki": { 40 | "default": "./src/shiki.ts" 41 | }, 42 | "./sugar-high": { 43 | "default": "./src/sugar-high.ts" 44 | } 45 | }, 46 | "files": [ 47 | "dist" 48 | ], 49 | "scripts": { 50 | "dev": "vite", 51 | "build": "tsup", 52 | "build:playground": "vite build", 53 | "lint": "eslint .", 54 | "fix": "eslint --fix . && prettier --write .", 55 | "prepublishOnly": "nr build", 56 | "start": "esno src/index.ts", 57 | "test": "vitest", 58 | "typecheck": "tsc --noEmit" 59 | }, 60 | "peerDependencies": { 61 | "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0", 62 | "@types/hast": "^3.0.0", 63 | "highlight.js": "^11.9.0", 64 | "lowlight": "^3.1.0", 65 | "prosemirror-model": "^1.19.3", 66 | "prosemirror-state": "^1.4.3", 67 | "prosemirror-transform": "^1.8.0", 68 | "prosemirror-view": "^1.32.4", 69 | "refractor": "^5.0.0", 70 | "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0" 71 | }, 72 | "peerDependenciesMeta": { 73 | "@types/hast": { 74 | "optional": true 75 | }, 76 | "highlight.js": { 77 | "optional": true 78 | }, 79 | "lowlight": { 80 | "optional": true 81 | }, 82 | "prosemirror-model": { 83 | "optional": true 84 | }, 85 | "prosemirror-state": { 86 | "optional": true 87 | }, 88 | "prosemirror-transform": { 89 | "optional": true 90 | }, 91 | "prosemirror-view": { 92 | "optional": true 93 | }, 94 | "refractor": { 95 | "optional": true 96 | }, 97 | "@shikijs/types": { 98 | "optional": true 99 | }, 100 | "sugar-high": { 101 | "optional": true 102 | } 103 | }, 104 | "devDependencies": { 105 | "@antfu/ni": "^23.3.1", 106 | "@ocavue/eslint-config": "^2.12.5", 107 | "@types/hast": "^3.0.4", 108 | "@types/node": "^20.17.5", 109 | "eslint": "^9.19.0", 110 | "highlight.js": "^11.11.1", 111 | "jsdom": "^25.0.1", 112 | "lowlight": "^3.3.0", 113 | "pkg-pr-new": "^0.0.41", 114 | "prettier": "^3.5.2", 115 | "prosemirror-example-setup": "^1.2.3", 116 | "prosemirror-model": "^1.24.1", 117 | "prosemirror-schema-basic": "^1.2.3", 118 | "prosemirror-state": "^1.4.3", 119 | "prosemirror-transform": "^1.10.2", 120 | "prosemirror-view": "^1.38.0", 121 | "refractor": "^5.0.0", 122 | "shiki": "^3.2.1", 123 | "sugar-high": "^0.9.3", 124 | "tsup": "^8.4.0", 125 | "typescript": "^5.7.3", 126 | "vite": "^6.2.0", 127 | "vitest": "^3.0.5" 128 | }, 129 | "publishConfig": { 130 | "main": "./dist/index.js", 131 | "module": "./dist/index.js", 132 | "types": "./dist/index.d.ts", 133 | "exports": { 134 | ".": { 135 | "types": "./dist/index.d.ts", 136 | "default": "./dist/index.js" 137 | }, 138 | "./lowlight": { 139 | "types": "./dist/lowlight.d.ts", 140 | "default": "./dist/lowlight.js" 141 | }, 142 | "./refractor": { 143 | "types": "./dist/refractor.d.ts", 144 | "default": "./dist/refractor.js" 145 | }, 146 | "./shiki": { 147 | "types": "./dist/shiki.d.ts", 148 | "default": "./dist/shiki.js" 149 | }, 150 | "./sugar-high": { 151 | "types": "./dist/sugar-high.d.ts", 152 | "default": "./dist/sugar-high.js" 153 | } 154 | }, 155 | "typesVersions": { 156 | "*": { 157 | "*": [ 158 | "./dist/*", 159 | "./dist/index.d.ts" 160 | ] 161 | } 162 | } 163 | }, 164 | "renovate": { 165 | "dependencyDashboard": true, 166 | "extends": [ 167 | "github>ocavue/config-renovate" 168 | ] 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ProseMirror Highlight 8 | 9 | 13 | 17 | 18 | 71 | 72 | 73 | 74 |

75 | 76 | ProseMirror Highlight 77 | 78 |

79 |

80 | Highlight your ProseMirror code 81 | blocks with any syntax highlighter you like! 82 |

83 |
84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /playground/lowlight.ts: -------------------------------------------------------------------------------- 1 | import 'highlight.js/styles/default.css' 2 | 3 | import { common, createLowlight } from 'lowlight' 4 | 5 | import { createHighlightPlugin } from 'prosemirror-highlight' 6 | import { createParser } from 'prosemirror-highlight/lowlight' 7 | 8 | const lowlight = createLowlight(common) 9 | const parser = createParser(lowlight) 10 | export const lowlightPlugin = createHighlightPlugin({ parser }) 11 | -------------------------------------------------------------------------------- /playground/main.ts: -------------------------------------------------------------------------------- 1 | import lowlightCode from './lowlight?raw' 2 | import refractorCode from './refractor?raw' 3 | import { setupView } from './setup' 4 | import shikiLazyCode from './shiki-lazy?raw' 5 | import shikiCode from './shiki?raw' 6 | import sugarHighCode from './sugar-high?raw' 7 | 8 | function getOrCreateElement(id: string): HTMLElement { 9 | const container = document.getElementById('container') 10 | if (!container) { 11 | throw new Error('Container not found') 12 | } 13 | 14 | let element = document.getElementById(id) 15 | if (!element) { 16 | element = document.createElement('div') 17 | element.id = id 18 | element.classList.add('editor') 19 | element.setAttribute('spellcheck', 'false') 20 | container.appendChild(element) 21 | } 22 | return element 23 | } 24 | 25 | function main() { 26 | void setupView({ 27 | mount: getOrCreateElement('editor-shiki'), 28 | plugin: () => import('./shiki').then((mod) => mod.shikiPlugin), 29 | title: 'Shiki', 30 | code: shikiCode, 31 | }) 32 | 33 | void setupView({ 34 | mount: getOrCreateElement('editor-lowlight'), 35 | plugin: () => import('./lowlight').then((mod) => mod.lowlightPlugin), 36 | title: 'Lowlight', 37 | code: lowlightCode, 38 | }) 39 | 40 | void setupView({ 41 | mount: getOrCreateElement('editor-refractor'), 42 | plugin: () => import('./refractor').then((mod) => mod.refractorPlugin), 43 | title: 'Refractor', 44 | code: refractorCode, 45 | }) 46 | 47 | void setupView({ 48 | mount: getOrCreateElement('editor-sugar-high'), 49 | plugin: () => import('./sugar-high').then((mod) => mod.sugarHighPlugin), 50 | title: 'Sugar High', 51 | code: sugarHighCode, 52 | }) 53 | 54 | void setupView({ 55 | mount: getOrCreateElement('editor-shiki-lazy'), 56 | plugin: () => import('./shiki-lazy').then((mod) => mod.shikiLazyPlugin), 57 | title: 'Shiki (Lazy language loading)', 58 | code: shikiLazyCode, 59 | }) 60 | } 61 | 62 | main() 63 | -------------------------------------------------------------------------------- /playground/refractor.ts: -------------------------------------------------------------------------------- 1 | import { refractor } from 'refractor/all' 2 | 3 | import { createHighlightPlugin } from 'prosemirror-highlight' 4 | import { createParser } from 'prosemirror-highlight/refractor' 5 | 6 | const parser = createParser(refractor) 7 | export const refractorPlugin = createHighlightPlugin({ parser }) 8 | -------------------------------------------------------------------------------- /playground/schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'prosemirror-model' 2 | import { schema as basicSchema } from 'prosemirror-schema-basic' 3 | 4 | export const schema = new Schema({ 5 | nodes: basicSchema.spec.nodes.update('code_block', { 6 | content: 'text*', 7 | group: 'block', 8 | code: true, 9 | defining: true, 10 | marks: '', 11 | attrs: { 12 | language: { default: '' }, 13 | }, 14 | parseDOM: [ 15 | { 16 | tag: 'pre', 17 | preserveWhitespace: 'full', 18 | getAttrs: (node) => ({ 19 | language: (node as Element)?.getAttribute('data-language') || '', 20 | }), 21 | }, 22 | ], 23 | toDOM(node) { 24 | return [ 25 | 'pre', 26 | { 'data-language': node.attrs.language as string }, 27 | ['code', 0], 28 | ] 29 | }, 30 | }), 31 | marks: basicSchema.spec.marks, 32 | }) 33 | -------------------------------------------------------------------------------- /playground/setup.ts: -------------------------------------------------------------------------------- 1 | import { exampleSetup } from 'prosemirror-example-setup' 2 | import { DOMParser } from 'prosemirror-model' 3 | import { EditorState, type Plugin } from 'prosemirror-state' 4 | import { EditorView } from 'prosemirror-view' 5 | 6 | import { schema } from './schema' 7 | 8 | export async function setupView({ 9 | mount, 10 | plugin, 11 | title, 12 | code, 13 | }: { 14 | mount: HTMLElement 15 | plugin: () => Promise 16 | title: string 17 | code: string 18 | }) { 19 | const div = document.createElement('div') 20 | div.innerHTML = `

With ${title}

${code.trim()}
` 21 | 22 | return new EditorView(mount, { 23 | state: EditorState.create({ 24 | doc: DOMParser.fromSchema(schema).parse(div), 25 | plugins: [...exampleSetup({ schema, menuBar: false }), await plugin()], 26 | }), 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /playground/shiki-lazy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createHighlighter, 3 | type BuiltinLanguage, 4 | type Highlighter, 5 | } from 'shiki' 6 | 7 | import { createHighlightPlugin } from 'prosemirror-highlight' 8 | import { createParser, type Parser } from 'prosemirror-highlight/shiki' 9 | 10 | let highlighter: Highlighter | undefined 11 | let parser: Parser | undefined 12 | 13 | /** 14 | * Lazy load highlighter and highlighter languages. 15 | * 16 | * When the highlighter or the required language is not loaded, it returns a 17 | * promise that resolves when the highlighter or the language is loaded. 18 | * Otherwise, it returns an array of decorations. 19 | */ 20 | const lazyParser: Parser = (options) => { 21 | if (!highlighter) { 22 | return createHighlighter({ 23 | themes: ['github-light', 'github-dark', 'github-dark-dimmed'], 24 | langs: [], 25 | }).then((h) => { 26 | highlighter = h 27 | }) 28 | } 29 | 30 | const language = options.language as BuiltinLanguage 31 | if (language && !highlighter.getLoadedLanguages().includes(language)) { 32 | return highlighter.loadLanguage(language) 33 | } 34 | 35 | if (!parser) { 36 | parser = createParser(highlighter, { 37 | themes: { 38 | light: 'github-light', 39 | dark: 'github-dark', 40 | dim: 'github-dark-dimmed', 41 | }, 42 | defaultColor: 'dim', 43 | }) 44 | } 45 | 46 | return parser(options) 47 | } 48 | 49 | export const shikiLazyPlugin = createHighlightPlugin({ parser: lazyParser }) 50 | -------------------------------------------------------------------------------- /playground/shiki.ts: -------------------------------------------------------------------------------- 1 | import { getSingletonHighlighter } from 'shiki' 2 | 3 | import { createHighlightPlugin } from 'prosemirror-highlight' 4 | import { createParser } from 'prosemirror-highlight/shiki' 5 | 6 | const highlighter = await getSingletonHighlighter({ 7 | themes: ['github-light'], 8 | langs: ['javascript', 'typescript', 'python'], 9 | }) 10 | const parser = createParser(highlighter) 11 | export const shikiPlugin = createHighlightPlugin({ parser }) 12 | -------------------------------------------------------------------------------- /playground/sugar-high.ts: -------------------------------------------------------------------------------- 1 | import { createHighlightPlugin } from 'prosemirror-highlight' 2 | import { createParser } from 'prosemirror-highlight/sugar-high' 3 | 4 | const parser = createParser() 5 | export const sugarHighPlugin = createHighlightPlugin({ parser }) 6 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { Node as ProseMirrorNode } from 'prosemirror-model' 2 | import type { Transaction } from 'prosemirror-state' 3 | import type { Decoration } from 'prosemirror-view' 4 | 5 | /** 6 | * Represents a cache of doc positions to the node and decorations at that position 7 | */ 8 | export class DecorationCache { 9 | private cache: Map 10 | 11 | constructor( 12 | cache?: Map, 13 | ) { 14 | this.cache = new Map(cache) 15 | } 16 | 17 | /** 18 | * Gets the cache entry at the given doc position, or null if it doesn't exist 19 | * @param pos The doc position of the node you want the cache for 20 | */ 21 | get(pos: number) { 22 | return this.cache.get(pos) 23 | } 24 | 25 | /** 26 | * Sets the cache entry at the given position with the give node/decoration 27 | * values 28 | * @param pos The doc position of the node to set the cache for 29 | * @param node The node to place in cache 30 | * @param decorations The decorations to place in cache 31 | */ 32 | set(pos: number, node: ProseMirrorNode, decorations: Decoration[]): void { 33 | if (pos < 0) { 34 | return 35 | } 36 | 37 | this.cache.set(pos, [node, decorations]) 38 | } 39 | 40 | /** 41 | * Removes the value at the oldPos (if it exists) and sets the new position to 42 | * the given values 43 | * @param oldPos The old node position to overwrite 44 | * @param newPos The new node position to set the cache for 45 | * @param node The new node to place in cache 46 | * @param decorations The new decorations to place in cache 47 | */ 48 | private replace( 49 | oldPos: number, 50 | newPos: number, 51 | node: ProseMirrorNode, 52 | decorations: Decoration[], 53 | ): void { 54 | this.remove(oldPos) 55 | this.set(newPos, node, decorations) 56 | } 57 | 58 | /** 59 | * Removes the cache entry at the given position 60 | * @param pos The doc position to remove from cache 61 | */ 62 | remove(pos: number): void { 63 | this.cache.delete(pos) 64 | } 65 | 66 | /** 67 | * Invalidates the cache by removing all decoration entries on nodes that have 68 | * changed, updating the positions of the nodes that haven't and removing all 69 | * the entries that have been deleted; NOTE: this does not affect the current 70 | * cache, but returns an entirely new one 71 | * @param tr A transaction to map the current cache to 72 | */ 73 | invalidate(tr: Transaction): DecorationCache { 74 | const returnCache = new DecorationCache(this.cache) 75 | const mapping = tr.mapping 76 | 77 | this.cache.forEach(([node, decorations], pos) => { 78 | if (pos < 0) { 79 | return 80 | } 81 | 82 | const result = mapping.mapResult(pos) 83 | const mappedNode = tr.doc.nodeAt(result.pos) 84 | 85 | if (result.deleted || !mappedNode?.eq(node)) { 86 | returnCache.remove(pos) 87 | } else if (pos !== result.pos) { 88 | // update the decorations' from/to values to match the new node position 89 | const updatedDecorations = decorations 90 | .map((d): Decoration | null => { 91 | // @ts-expect-error: internal api 92 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 93 | return d.map(mapping, 0, 0) as Decoration | null 94 | }) 95 | .filter((d): d is Decoration => d != null) 96 | returnCache.replace(pos, result.pos, mappedNode, updatedDecorations) 97 | } 98 | }) 99 | 100 | return returnCache 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/hast.ts: -------------------------------------------------------------------------------- 1 | import type { Element, ElementContent, Root, RootContent } from 'hast' 2 | import { Decoration } from 'prosemirror-view' 3 | 4 | export function fillFromRoot( 5 | decorations: Decoration[], 6 | node: Root, 7 | from: number, 8 | ) { 9 | for (const child of node.children) { 10 | from = fillFromRootContent(decorations, child, from) 11 | } 12 | } 13 | 14 | function fillFromRootContent( 15 | decorations: Decoration[], 16 | node: RootContent, 17 | from: number, 18 | ): number { 19 | if (node.type === 'element') { 20 | const to = from + getElementSize(node) 21 | const { className, ...rest } = node.properties || {} 22 | decorations.push( 23 | Decoration.inline(from, to, { 24 | class: className 25 | ? Array.isArray(className) 26 | ? className.join(' ') 27 | : String(className) 28 | : undefined, 29 | ...rest, 30 | nodeName: node.tagName, 31 | }), 32 | ) 33 | return to 34 | } else if (node.type === 'text') { 35 | return from + node.value.length 36 | } else { 37 | return from 38 | } 39 | } 40 | 41 | function getElementSize(node: Element): number { 42 | let size = 0 43 | 44 | for (const child of node.children) { 45 | size += getElementContentSize(child) 46 | } 47 | 48 | return size 49 | } 50 | 51 | function getElementContentSize(node: ElementContent): number { 52 | switch (node.type) { 53 | case 'element': 54 | return getElementSize(node) 55 | case 'text': 56 | return node.value.length 57 | default: 58 | return 0 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { DecorationCache } from './cache' 2 | export { createHighlightPlugin, type HighlightPluginState } from './plugin' 3 | export type { LanguageExtractor, Parser } from './types' 4 | -------------------------------------------------------------------------------- /src/lowlight.ts: -------------------------------------------------------------------------------- 1 | import type { Root } from 'hast' 2 | import type { Decoration } from 'prosemirror-view' 3 | 4 | import { fillFromRoot } from './hast' 5 | import type { Parser } from './types' 6 | 7 | export type { Parser } 8 | 9 | export type Lowlight = { 10 | highlight: (language: string, value: string) => Root 11 | highlightAuto: (value: string) => Root 12 | } 13 | 14 | export function createParser(lowlight: Lowlight): Parser { 15 | return function highlighter({ content, language, pos }) { 16 | const root = language 17 | ? lowlight.highlight(language, content) 18 | : lowlight.highlightAuto(content) 19 | 20 | const decorations: Decoration[] = [] 21 | const from = pos + 1 22 | fillFromRoot(decorations, root, from) 23 | return decorations 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Node as ProseMirrorNode } from 'prosemirror-model' 2 | import { Plugin, PluginKey } from 'prosemirror-state' 3 | import { type Decoration, DecorationSet } from 'prosemirror-view' 4 | 5 | import { DecorationCache } from './cache' 6 | import type { LanguageExtractor, Parser } from './types' 7 | 8 | /** 9 | * Describes the current state of the highlightPlugin 10 | */ 11 | export interface HighlightPluginState { 12 | cache: DecorationCache 13 | decorations: DecorationSet 14 | promises: Promise[] 15 | } 16 | 17 | /** 18 | * Creates a plugin that highlights the contents of all nodes (via Decorations) 19 | * with a type passed in blockTypes 20 | */ 21 | export function createHighlightPlugin({ 22 | parser, 23 | nodeTypes = ['code_block', 'codeBlock'], 24 | languageExtractor = (node) => node.attrs.language as string | undefined, 25 | }: { 26 | /** 27 | * A function that returns an array of decorations for the given node text 28 | * content, language, and position. 29 | */ 30 | parser: Parser 31 | 32 | /** 33 | * An array containing all the node type name to target for highlighting. 34 | * 35 | * @default ['code_block', 'codeBlock'] 36 | */ 37 | nodeTypes?: string[] 38 | 39 | /** 40 | * A function that returns the language string to use when highlighting that 41 | * node. By default, it returns `node.attrs.language`. 42 | */ 43 | languageExtractor?: LanguageExtractor 44 | }): Plugin { 45 | const key = new PluginKey('prosemirror-highlight') 46 | 47 | return new Plugin({ 48 | key, 49 | state: { 50 | init(_, instance) { 51 | const cache = new DecorationCache() 52 | const [decorations, promises] = calculateDecoration( 53 | instance.doc, 54 | parser, 55 | nodeTypes, 56 | languageExtractor, 57 | cache, 58 | ) 59 | 60 | return { cache, decorations, promises } 61 | }, 62 | apply: (tr, data) => { 63 | const cache = data.cache.invalidate(tr) 64 | const refresh = !!tr.getMeta('prosemirror-highlight-refresh') 65 | 66 | if (!tr.docChanged && !refresh) { 67 | const decorations = data.decorations.map(tr.mapping, tr.doc) 68 | const promises = data.promises 69 | return { cache, decorations, promises } 70 | } 71 | 72 | const [decorations, promises] = calculateDecoration( 73 | tr.doc, 74 | parser, 75 | nodeTypes, 76 | languageExtractor, 77 | cache, 78 | ) 79 | return { cache, decorations, promises } 80 | }, 81 | }, 82 | view: (view) => { 83 | const promises = new Set>() 84 | 85 | // Refresh the decorations when all promises resolve 86 | const refresh = () => { 87 | if (promises.size > 0) { 88 | return 89 | } 90 | const tr = view.state.tr.setMeta('prosemirror-highlight-refresh', true) 91 | view.dispatch(tr) 92 | } 93 | 94 | const check = () => { 95 | const state = key.getState(view.state) 96 | 97 | for (const promise of state?.promises ?? []) { 98 | promises.add(promise) 99 | promise 100 | .then(() => { 101 | promises.delete(promise) 102 | refresh() 103 | }) 104 | .catch(() => { 105 | promises.delete(promise) 106 | }) 107 | } 108 | } 109 | 110 | check() 111 | 112 | return { 113 | update: () => { 114 | check() 115 | }, 116 | } 117 | }, 118 | props: { 119 | decorations(this, state) { 120 | return this.getState(state)?.decorations 121 | }, 122 | }, 123 | }) 124 | } 125 | 126 | function calculateDecoration( 127 | doc: ProseMirrorNode, 128 | parser: Parser, 129 | nodeTypes: string[], 130 | languageExtractor: LanguageExtractor, 131 | cache: DecorationCache, 132 | ) { 133 | const result: Decoration[] = [] 134 | const promises: Promise[] = [] 135 | 136 | doc.descendants((node, pos) => { 137 | if (!node.type.isTextblock) { 138 | return true 139 | } 140 | 141 | if (nodeTypes.includes(node.type.name)) { 142 | const language = languageExtractor(node) 143 | const cached = cache.get(pos) 144 | 145 | if (cached) { 146 | const [_, decorations] = cached 147 | result.push(...decorations) 148 | } else { 149 | const decorations = parser({ 150 | content: node.textContent, 151 | language: language || undefined, 152 | pos, 153 | size: node.nodeSize, 154 | }) 155 | 156 | if (decorations && Array.isArray(decorations)) { 157 | cache.set(pos, node, decorations) 158 | result.push(...decorations) 159 | } else if (decorations instanceof Promise) { 160 | cache.remove(pos) 161 | promises.push(decorations) 162 | } 163 | } 164 | } 165 | return false 166 | }) 167 | 168 | return [DecorationSet.create(doc, result), promises] as const 169 | } 170 | -------------------------------------------------------------------------------- /src/refractor.ts: -------------------------------------------------------------------------------- 1 | import type { Root } from 'hast' 2 | import type { Decoration } from 'prosemirror-view' 3 | import type { Refractor } from 'refractor/core' 4 | 5 | import { fillFromRoot } from './hast' 6 | import type { Parser } from './types' 7 | 8 | export type { Parser } 9 | 10 | export function createParser(refractor: Refractor): Parser { 11 | return function highlighter({ content, language, pos }) { 12 | const root: Root = refractor.highlight(content, language || '') 13 | 14 | const decorations: Decoration[] = [] 15 | const from = pos + 1 16 | 17 | fillFromRoot(decorations, root, from) 18 | return decorations 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/shiki.ts: -------------------------------------------------------------------------------- 1 | import type { CodeToTokensOptions, HighlighterGeneric } from '@shikijs/types' 2 | import { Decoration } from 'prosemirror-view' 3 | 4 | import type { Parser } from './types' 5 | 6 | export type { Parser } 7 | 8 | export function createParser< 9 | Language extends string = string, 10 | Theme extends string = string, 11 | >( 12 | highlighter: HighlighterGeneric, 13 | options?: CodeToTokensOptions, 14 | ): Parser { 15 | return function parser({ content, language, pos, size }) { 16 | const decorations: Decoration[] = [] 17 | 18 | const { tokens, fg, bg, rootStyle } = highlighter.codeToTokens(content, { 19 | lang: language as Language | undefined, 20 | 21 | // Use provided options for themes or just use first loaded theme 22 | ...(options ?? { 23 | theme: highlighter.getLoadedThemes()[0], 24 | }), 25 | }) 26 | 27 | const style = 28 | rootStyle || 29 | (fg && bg 30 | ? `--prosemirror-highlight:${fg};--prosemirror-highlight-bg:${bg}` 31 | : '') 32 | 33 | if (style) { 34 | const decoration = Decoration.node(pos, pos + size, { style }) 35 | decorations.push(decoration) 36 | } 37 | 38 | let from = pos + 1 39 | 40 | for (const line of tokens) { 41 | for (const token of line) { 42 | const to = from + token.content.length 43 | 44 | const decoration = Decoration.inline(from, to, { 45 | // When using `options.themes` the `htmlStyle` field will be set, otherwise `color` will be set 46 | style: stringifyTokenStyle( 47 | token.htmlStyle ?? `color: ${token.color}`, 48 | ), 49 | class: 'shiki', 50 | }) 51 | 52 | decorations.push(decoration) 53 | 54 | from = to 55 | } 56 | 57 | from += 1 58 | } 59 | 60 | return decorations 61 | } 62 | } 63 | 64 | /** 65 | * Copied from https://github.com/shikijs/shiki/blob/f76a371dbc2752cba341023df00ebfe9b66cb3f6/packages/core/src/utils.ts#L213 66 | * 67 | * Copy instead of import it from `shiki` to avoid importing the `shiki` package in this file. 68 | */ 69 | function stringifyTokenStyle(token: string | Record): string { 70 | if (typeof token === 'string') return token 71 | return Object.entries(token) 72 | .map(([key, value]) => `${key}:${value}`) 73 | .join(';') 74 | } 75 | -------------------------------------------------------------------------------- /src/sugar-high.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from 'prosemirror-view' 2 | import { tokenize, SugarHigh } from 'sugar-high' 3 | 4 | import type { Parser } from './types' 5 | 6 | export type { Parser } 7 | 8 | const types = SugarHigh.TokenTypes 9 | 10 | export function createParser(): Parser { 11 | return function parser({ content, pos }) { 12 | const decorations: Decoration[] = [] 13 | 14 | const tokens = tokenize(content) 15 | 16 | let from = pos + 1 17 | 18 | for (const [type, content] of tokens) { 19 | const to = from + content.length 20 | 21 | const decoration = Decoration.inline(from, to, { 22 | class: `sh__token--${types[type]}`, 23 | style: `color: var(--sh-${types[type]})`, 24 | }) 25 | 26 | decorations.push(decoration) 27 | 28 | from = to 29 | } 30 | 31 | return decorations 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Node as ProseMirrorNode } from 'prosemirror-model' 2 | import type { Decoration } from 'prosemirror-view' 3 | 4 | /** 5 | * A function that parses the text content of a code block node and returns an 6 | * array of ProseMirror decorations. If the underlying syntax highlighter is 7 | * still loading, you can return a promise that will be resolved when the 8 | * highlighter is ready. 9 | */ 10 | export type Parser = (options: { 11 | /** 12 | * The text content of the code block node. 13 | */ 14 | content: string 15 | 16 | /** 17 | * The start position of the code block node. 18 | */ 19 | pos: number 20 | 21 | /** 22 | * The language of the code block node. 23 | */ 24 | language?: string 25 | 26 | /** 27 | * The size of the code block node. 28 | */ 29 | size: number 30 | }) => Decoration[] | Promise 31 | 32 | /** 33 | * A function that extracts the language of a code block node. 34 | */ 35 | export type LanguageExtractor = (node: ProseMirrorNode) => string | undefined 36 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import Prettier from 'prettier' 2 | import type { Schema, Node as ProseMirrorNode } from 'prosemirror-model' 3 | 4 | export async function formatHtml(htmlString: string) { 5 | return await Prettier.format(htmlString, { 6 | parser: 'typescript', 7 | }) 8 | } 9 | 10 | export function setupNodes(schema: Schema) { 11 | const doc = (nodes: ProseMirrorNode[]) => { 12 | return schema.nodes.doc.createChecked({}, nodes) 13 | } 14 | const codeBlock = (language: string, text: string) => { 15 | return schema.nodes.code_block.createChecked( 16 | { language }, 17 | schema.text(text), 18 | ) 19 | } 20 | 21 | return { doc, codeBlock } 22 | } 23 | -------------------------------------------------------------------------------- /test/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from 'prosemirror-state' 2 | import { EditorView } from 'prosemirror-view' 3 | import { describe, expect, it } from 'vitest' 4 | 5 | import { schema } from '../playground/schema' 6 | import { createHighlightPlugin } from '../src/plugin' 7 | 8 | import { formatHtml, setupNodes } from './helpers' 9 | 10 | describe('createHighlightPlugin', () => { 11 | const nodes = setupNodes(schema) 12 | 13 | const doc = nodes.doc([ 14 | nodes.codeBlock('typescript', 'console.log(123+"456");'), 15 | nodes.codeBlock('python', 'print("1+1","=",2)'), 16 | ]) 17 | 18 | it('can highlight code blocks with lowlight', async () => { 19 | const { createParser } = await import('../src/lowlight') 20 | const { common, createLowlight } = await import('lowlight') 21 | 22 | const lowlight = createLowlight(common) 23 | const parser = createParser(lowlight) 24 | const plugin = createHighlightPlugin({ parser }) 25 | 26 | const state = EditorState.create({ doc, plugins: [plugin] }) 27 | const view = new EditorView(document.createElement('div'), { state }) 28 | 29 | const html = await formatHtml(view.dom.outerHTML) 30 | expect(html).toMatchInlineSnapshot(` 31 | "
32 |
 33 |           
 34 |             console.
 35 |             log(
 36 |             123+
 37 |             "456");
 38 |           
 39 |         
40 |
 41 |           
 42 |             print(
 43 |             "1+1",
 44 |             "=",2)
 45 |           
 46 |         
47 |
; 48 | " 49 | `) 50 | }) 51 | 52 | it('can highlight code blocks with refractor', async () => { 53 | const { createParser } = await import('../src/refractor') 54 | const { refractor } = await import('refractor/all') 55 | 56 | const parser = createParser(refractor) 57 | const plugin = createHighlightPlugin({ parser }) 58 | 59 | const state = EditorState.create({ doc, plugins: [plugin] }) 60 | const view = new EditorView(document.createElement('div'), { state }) 61 | 62 | const html = await formatHtml(view.dom.outerHTML) 63 | expect(html).toMatchInlineSnapshot(` 64 | "
65 |
 66 |           
 67 |             console
 68 |             .
 69 |             log
 70 |             (
 71 |             123
 72 |             +
 73 |             "456"
 74 |             )
 75 |             ;
 76 |           
 77 |         
78 |
 79 |           
 80 |             print
 81 |             (
 82 |             "1+1"
 83 |             ,
 84 |             "="
 85 |             ,
 86 |             2
 87 |             )
 88 |           
 89 |         
90 |
; 91 | " 92 | `) 93 | }) 94 | 95 | it('can highlight code blocks with sugar-high', async () => { 96 | const { createParser } = await import('../src/sugar-high') 97 | 98 | const parser = createParser() 99 | const plugin = createHighlightPlugin({ parser }) 100 | 101 | const state = EditorState.create({ doc, plugins: [plugin] }) 102 | const view = new EditorView(document.createElement('div'), { state }) 103 | 104 | const html = await formatHtml(view.dom.outerHTML) 105 | expect(html).toMatchInlineSnapshot( 106 | ` 107 | "
108 |
109 |           
110 |             
111 |               console
112 |             
113 |             
114 |               .
115 |             
116 |             
117 |               log
118 |             
119 |             
120 |               (
121 |             
122 |             
123 |               123
124 |             
125 |             
126 |               +
127 |             
128 |             
129 |               "
130 |             
131 |             
132 |               456
133 |             
134 |             
135 |               "
136 |             
137 |             
138 |               )
139 |             
140 |             
141 |               ;
142 |             
143 |           
144 |         
145 |
146 |           
147 |             
148 |               print
149 |             
150 |             
151 |               (
152 |             
153 |             
154 |               "
155 |             
156 |             
157 |               1+1
158 |             
159 |             
160 |               "
161 |             
162 |             
163 |               ,
164 |             
165 |             
166 |               "
167 |             
168 |             
169 |               =
170 |             
171 |             
172 |               "
173 |             
174 |             
175 |               ,
176 |             
177 |             
178 |               2
179 |             
180 |             
181 |               )
182 |             
183 |           
184 |         
185 |
; 186 | " 187 | `, 188 | ) 189 | }) 190 | 191 | it('can highlight code blocks with shiki', async () => { 192 | const { createParser } = await import('../src/shiki') 193 | const { createHighlighter } = await import('shiki') 194 | 195 | const highlighter = await createHighlighter({ 196 | themes: ['github-dark'], 197 | langs: ['typescript', 'python'], 198 | }) 199 | 200 | const parser = createParser(highlighter, { 201 | theme: 'github-dark', 202 | defaultColor: false, 203 | }) 204 | const plugin = createHighlightPlugin({ parser }) 205 | 206 | const state = EditorState.create({ doc, plugins: [plugin] }) 207 | const view = new EditorView(document.createElement('div'), { state }) 208 | 209 | const html = await formatHtml(view.dom.outerHTML) 210 | expect(html).toMatchInlineSnapshot(` 211 | "
212 |
216 |           
217 |             
218 |               console.
219 |             
220 |             
221 |               log
222 |             
223 |             
224 |               (
225 |             
226 |             
227 |               123
228 |             
229 |             
230 |               +
231 |             
232 |             
233 |               "456"
234 |             
235 |             
236 |               );
237 |             
238 |           
239 |         
240 |
244 |           
245 |             
246 |               print
247 |             
248 |             
249 |               (
250 |             
251 |             
252 |               "1+1"
253 |             
254 |             
255 |               ,
256 |             
257 |             
258 |               "="
259 |             
260 |             
261 |               ,
262 |             
263 |             
264 |               2
265 |             
266 |             
267 |               )
268 |             
269 |           
270 |         
271 |
; 272 | " 273 | `) 274 | }) 275 | 276 | it('can highlight code blocks with shiki and multiple themes', async () => { 277 | const { createParser } = await import('../src/shiki') 278 | const { createHighlighter } = await import('shiki') 279 | 280 | const highlighter = await createHighlighter({ 281 | themes: ['github-light', 'github-dark', 'github-dark-dimmed'], 282 | langs: ['typescript', 'python'], 283 | }) 284 | 285 | const parser = createParser(highlighter, { 286 | themes: { 287 | light: 'github-light', 288 | dark: 'github-dark', 289 | dim: 'github-dark-dimmed', 290 | }, 291 | defaultColor: false, 292 | cssVariablePrefix: '--custom-prefix-', 293 | }) 294 | const plugin = createHighlightPlugin({ parser }) 295 | 296 | const state = EditorState.create({ doc, plugins: [plugin] }) 297 | const view = new EditorView(document.createElement('div'), { state }) 298 | 299 | const html = await formatHtml(view.dom.outerHTML) 300 | expect(html).toMatchInlineSnapshot(` 301 | "
302 |
306 |           
307 |             
311 |               console.
312 |             
313 |             
317 |               log
318 |             
319 |             
323 |               (
324 |             
325 |             
329 |               123
330 |             
331 |             
335 |               +
336 |             
337 |             
341 |               "456"
342 |             
343 |             
347 |               );
348 |             
349 |           
350 |         
351 |
355 |           
356 |             
360 |               print
361 |             
362 |             
366 |               (
367 |             
368 |             
372 |               "1+1"
373 |             
374 |             
378 |               ,
379 |             
380 |             
384 |               "="
385 |             
386 |             
390 |               ,
391 |             
392 |             
396 |               2
397 |             
398 |             
402 |               )
403 |             
404 |           
405 |         
406 |
; 407 | " 408 | `) 409 | }) 410 | }) 411 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "esnext", 5 | "lib": ["esnext", "dom"], 6 | "types": ["vite/client"], 7 | "allowJs": true, 8 | "moduleResolution": "Bundler", 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "strictNullChecks": true, 12 | "verbatimModuleSyntax": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "baseUrl": "./", 18 | "paths": { 19 | "prosemirror-highlight": ["./src/"] 20 | } 21 | }, 22 | "include": ["."] 23 | } 24 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | 'src/index.ts', 6 | 'src/lowlight.ts', 7 | 'src/refractor.ts', 8 | 'src/shiki.ts', 9 | 'src/sugar-high.ts', 10 | ], 11 | format: ['esm'], 12 | clean: true, 13 | dts: true, 14 | }) 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | root: './playground', 5 | 6 | resolve: { 7 | alias: { 8 | 'prosemirror-highlight': '../src', 9 | }, 10 | }, 11 | 12 | test: { 13 | root: './', 14 | environment: 'jsdom', 15 | }, 16 | 17 | build: { 18 | target: ['chrome100', 'safari15', 'firefox100'], 19 | }, 20 | }) 21 | --------------------------------------------------------------------------------