├── .github ├── FUNDING.yml ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── release.yml │ └── ci.yml ├── .prettierrc.json ├── eslint.config.js ├── .gitignore ├── src ├── index.ts ├── refractor.ts ├── lowlight.ts ├── types.ts ├── shiki.ts ├── sugar-high.ts ├── hast.ts ├── cache.ts └── plugin.ts ├── playground ├── sugar-high.ts ├── refractor.ts ├── lowlight.ts ├── shiki.ts ├── schema.ts ├── setup.ts ├── shiki-lazy.ts ├── main.ts └── index.html ├── .prettierignore ├── tsup.config.ts ├── vite.config.ts ├── tsconfig.json ├── test ├── helpers.ts └── plugin.spec.ts ├── LICENSE ├── CHANGELOG.md ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ocavue] 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { basic, markdown } from '@ocavue/eslint-config' 2 | 3 | export default [...basic(), ...markdown()] 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { DecorationCache } from './cache' 2 | export { createHighlightPlugin, type HighlightPluginState } from './plugin' 3 | export type { LanguageExtractor, Parser } from './types' 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /playground/refractor.ts: -------------------------------------------------------------------------------- 1 | import { createHighlightPlugin } from 'prosemirror-highlight' 2 | import { createParser } from 'prosemirror-highlight/refractor' 3 | import { refractor } from 'refractor' 4 | 5 | const parser = createParser(refractor) 6 | export const refractorPlugin = createHighlightPlugin({ parser }) 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /playground/lowlight.ts: -------------------------------------------------------------------------------- 1 | import 'highlight.js/styles/default.css' 2 | 3 | import { common, createLowlight } from 'lowlight' 4 | import { createHighlightPlugin } from 'prosemirror-highlight' 5 | import { createParser } from 'prosemirror-highlight/lowlight' 6 | 7 | const lowlight = createLowlight(common) 8 | const parser = createParser(lowlight) 9 | export const lowlightPlugin = createHighlightPlugin({ parser }) 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /playground/shiki.ts: -------------------------------------------------------------------------------- 1 | import { createHighlightPlugin } from 'prosemirror-highlight' 2 | import { createParser } from 'prosemirror-highlight/shiki' 3 | import { getSingletonHighlighter } from 'shiki' 4 | 5 | const highlighter = await getSingletonHighlighter({ 6 | themes: ['github-light'], 7 | langs: ['javascript', 'typescript', 'python'], 8 | }) 9 | const parser = createParser(highlighter) 10 | export const shikiPlugin = createHighlightPlugin({ parser }) 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/refractor.ts: -------------------------------------------------------------------------------- 1 | import type { Root } from 'hast' 2 | import type { Decoration } from 'prosemirror-view' 3 | import type { Refractor } from 'refractor/lib/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 = refractor.highlight(content, language || '') 13 | 14 | const decorations: Decoration[] = [] 15 | const from = pos + 1 16 | 17 | // @ts-expect-error: the return value of `highlight` is not exactly a `hast.Root` 18 | const hastRoot: Root = root 19 | 20 | fillFromRoot(decorations, hastRoot, from) 21 | return decorations 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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: google-github-actions/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 decorations. If the underlying syntax highlighter is still loading, 7 | * you can return a promise that will be resolved when the highlighter is ready. 8 | */ 9 | export type Parser = (options: { 10 | /** 11 | * The text content of the code block node. 12 | */ 13 | content: string 14 | 15 | /** 16 | * The start position of the code block node. 17 | */ 18 | pos: number 19 | 20 | /** 21 | * The language of the code block node. 22 | */ 23 | language?: string 24 | }) => Decoration[] | Promise 25 | 26 | /** 27 | * A function that extracts the language of a code block node. 28 | */ 29 | export type LanguageExtractor = (node: ProseMirrorNode) => string | undefined 30 | -------------------------------------------------------------------------------- /src/shiki.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from 'prosemirror-view' 2 | import type { BundledLanguage, BundledTheme, Highlighter } from 'shiki' 3 | 4 | import type { Parser } from './types' 5 | 6 | export type { Parser } 7 | 8 | export function createParser( 9 | highlighter: Highlighter, 10 | options?: { theme?: BundledTheme }, 11 | ): Parser { 12 | return function parser({ content, language, pos }) { 13 | const decorations: Decoration[] = [] 14 | 15 | const tokens = highlighter.codeToTokensBase(content, { 16 | lang: language as BundledLanguage, 17 | theme: options?.theme, 18 | }) 19 | 20 | let from = pos + 1 21 | 22 | for (const line of tokens) { 23 | for (const token of line) { 24 | const to = from + token.content.length 25 | 26 | const decoration = Decoration.inline(from, to, { 27 | style: `color: ${token.color}`, 28 | }) 29 | 30 | decorations.push(decoration) 31 | 32 | from = to 33 | } 34 | 35 | from += 1 36 | } 37 | 38 | return decorations 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/sugar-high.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from 'prosemirror-view' 2 | import { tokenize } from 'sugar-high' 3 | 4 | import type { Parser } from './types' 5 | 6 | export type { Parser } 7 | 8 | /** 9 | * Copied from https://github.com/huozhi/sugar-high/blob/v0.6.1/lib/index.js#L80-L107 10 | */ 11 | const types = [ 12 | 'identifier', 13 | 'keyword', 14 | 'string', 15 | 'class', 16 | 'property', 17 | 'entity', 18 | 'jsxliterals', 19 | 'sign', 20 | 'comment', 21 | 'break', 22 | 'space', 23 | ] as const 24 | 25 | export function createParser(): Parser { 26 | return function parser({ content, pos }) { 27 | const decorations: Decoration[] = [] 28 | 29 | const tokens = tokenize(content) 30 | 31 | let from = pos + 1 32 | 33 | for (const [type, content] of tokens) { 34 | const to = from + content.length 35 | 36 | const decoration = Decoration.inline(from, to, { 37 | class: `sh__token--${types[type]}`, 38 | style: `color: var(--sh-${types[type]})`, 39 | }) 40 | 41 | decorations.push(decoration) 42 | 43 | from = to 44 | } 45 | 46 | return decorations 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /playground/shiki-lazy.ts: -------------------------------------------------------------------------------- 1 | import { createHighlightPlugin } from 'prosemirror-highlight' 2 | import { createParser, type Parser } from 'prosemirror-highlight/shiki' 3 | import { 4 | getSingletonHighlighter, 5 | type BuiltinLanguage, 6 | type Highlighter, 7 | } from 'shiki' 8 | 9 | let highlighter: Highlighter | undefined 10 | let parser: Parser | undefined 11 | 12 | /** 13 | * Lazy load highlighter and highlighter languages. 14 | * 15 | * When the highlighter or the required language is not loaded, it returns a 16 | * promise that resolves when the highlighter or the language is loaded. 17 | * Otherwise, it returns an array of decorations. 18 | */ 19 | const lazyParser: Parser = (options) => { 20 | if (!highlighter) { 21 | return getSingletonHighlighter({ 22 | themes: ['github-light'], 23 | langs: [], 24 | }).then((h) => { 25 | highlighter = h 26 | }) 27 | } 28 | 29 | const language = options.language as BuiltinLanguage 30 | if (language && !highlighter.getLoadedLanguages().includes(language)) { 31 | return highlighter.loadLanguage(language) 32 | } 33 | 34 | if (!parser) { 35 | parser = createParser(highlighter) 36 | } 37 | 38 | return parser(options) 39 | } 40 | 41 | export const shikiLazyPlugin = createHighlightPlugin({ parser: lazyParser }) 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | deploy: 55 | runs-on: ubuntu-latest 56 | permissions: 57 | contents: read 58 | deployments: write 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v4 62 | 63 | - uses: ./.github/actions/setup 64 | with: 65 | node-version: ${{ matrix.node }} 66 | 67 | - name: Build 68 | run: pnpm run build:playground 69 | 70 | - name: Publish to Cloudflare Pages 71 | uses: cloudflare/pages-action@v1 72 | with: 73 | apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} 74 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 75 | projectName: prosemirror-highlight 76 | gitHubToken: ${{ secrets.GITHUB_TOKEN }} 77 | workingDirectory: playground 78 | directory: dist 79 | -------------------------------------------------------------------------------- /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/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ProseMirror Highlight 8 | 9 | 13 | 17 | 18 | 55 | 56 | 57 | 58 |

59 | 60 | ProseMirror Highlight 61 | 62 |

63 |

64 | Highlight your ProseMirror code 65 | blocks with any syntax highlighter you like! 66 |

67 |
68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.8.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.7.0...v0.8.0) (2024-06-21) 4 | 5 | 6 | ### Features 7 | 8 | * accept shiki theme option ([#40](https://github.com/ocavue/prosemirror-highlight/issues/40)) ([fbfbfc9](https://github.com/ocavue/prosemirror-highlight/commit/fbfbfc9df48ac1bc8bdca2831f468ad77d658619)) 9 | 10 | ## [0.7.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.6.0...v0.7.0) (2024-06-21) 11 | 12 | 13 | ### ⚠ BREAKING CHANGES 14 | 15 | * remove shikiji support ([#36](https://github.com/ocavue/prosemirror-highlight/issues/36)) 16 | 17 | ### Miscellaneous Chores 18 | 19 | * update shiki ([#38](https://github.com/ocavue/prosemirror-highlight/issues/38)) ([9df0d59](https://github.com/ocavue/prosemirror-highlight/commit/9df0d5934616cd82e3d26ea26ac7f77923fff347)) 20 | 21 | 22 | ### Code Refactoring 23 | 24 | * remove shikiji support ([#36](https://github.com/ocavue/prosemirror-highlight/issues/36)) ([383e0d3](https://github.com/ocavue/prosemirror-highlight/commit/383e0d3e8f182c1ae988e545f40c23c5969e5b6c)) 25 | 26 | ## [0.6.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.5.0...v0.6.0) (2024-05-21) 27 | 28 | 29 | ### Features 30 | 31 | * support sugar-high ([#29](https://github.com/ocavue/prosemirror-highlight/issues/29)) ([6d367fe](https://github.com/ocavue/prosemirror-highlight/commit/6d367fe350fdf0b9a0e342276a7caa8fcede9f61)) 32 | 33 | ## [0.5.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.4.1...v0.5.0) (2024-02-07) 34 | 35 | 36 | ### Features 37 | 38 | * update shiki to v1.0.0 ([#24](https://github.com/ocavue/prosemirror-highlight/issues/24)) ([3dab37a](https://github.com/ocavue/prosemirror-highlight/commit/3dab37a41feb1e07be639cd348f5606561de63fe)) 39 | 40 | ## [0.4.1](https://github.com/ocavue/prosemirror-highlight/compare/v0.4.0...v0.4.1) (2024-01-23) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * support shikiji v0.10 ([#22](https://github.com/ocavue/prosemirror-highlight/issues/22)) ([ff5df2e](https://github.com/ocavue/prosemirror-highlight/commit/ff5df2e6b3033e2928e68ac3e822d908a62f801c)) 46 | 47 | ## [0.4.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.3...v0.4.0) (2024-01-01) 48 | 49 | 50 | ### Features 51 | 52 | * 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)) 53 | 54 | ## [0.3.3](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.2...v0.3.3) (2023-12-16) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * export type Parser ([#13](https://github.com/ocavue/prosemirror-highlight/issues/13)) ([350e37e](https://github.com/ocavue/prosemirror-highlight/commit/350e37eb0db49dcc1f75704553500823facdebf4)) 60 | 61 | ## [0.3.2](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.1...v0.3.2) (2023-12-14) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * support shikiji v0.9.0 ([7b8f1ce](https://github.com/ocavue/prosemirror-highlight/commit/7b8f1ce1dca760e3657b6e7fc9eba4df172aed47)) 67 | 68 | ## [0.3.1](https://github.com/ocavue/prosemirror-highlight/compare/v0.3.0...v0.3.1) (2023-12-12) 69 | 70 | 71 | ### Bug Fixes 72 | 73 | * fix publish script ([3814c85](https://github.com/ocavue/prosemirror-highlight/commit/3814c8503f73de91a78e9577142b827e493f3b56)) 74 | 75 | ## [0.3.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.2.0...v0.3.0) (2023-12-10) 76 | 77 | 78 | ### Features 79 | 80 | * support refractor ([#4](https://github.com/ocavue/prosemirror-highlight/issues/4)) ([ffee694](https://github.com/ocavue/prosemirror-highlight/commit/ffee694e0113bfe14a6f1dc05d0cbc5fcf679b9d)) 81 | 82 | ## [0.2.0](https://github.com/ocavue/prosemirror-highlight/compare/v0.1.0...v0.2.0) (2023-12-10) 83 | 84 | 85 | ### Features 86 | 87 | * support shikiji ([#2](https://github.com/ocavue/prosemirror-highlight/issues/2)) ([028728c](https://github.com/ocavue/prosemirror-highlight/commit/028728c70835adcd18b36e6e43fe4e736d8b3fcd)) 88 | 89 | ## 0.1.0 (2023-12-10) 90 | 91 | 92 | ### Features 93 | 94 | * support lowlight ([5bab86d](https://github.com/ocavue/prosemirror-highlight/commit/5bab86d6589fb879e94f4419e7ac813fe44589b1)) 95 | * support shiki ([5adab02](https://github.com/ocavue/prosemirror-highlight/commit/5adab02178134a1e32d6860554e2913bacc615f8)) 96 | 97 | 98 | ### Documentation 99 | 100 | * update readme ([cedbc68](https://github.com/ocavue/prosemirror-highlight/commit/cedbc68e1e090a53693aecb21d9c3145cf9dbd73)) 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-highlight", 3 | "type": "module", 4 | "version": "0.8.0", 5 | "packageManager": "pnpm@9.9.0", 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 | "@types/hast": "^3.0.0", 62 | "highlight.js": "^11.9.0", 63 | "lowlight": "^3.1.0", 64 | "prosemirror-model": "^1.19.3", 65 | "prosemirror-state": "^1.4.3", 66 | "prosemirror-transform": "^1.8.0", 67 | "prosemirror-view": "^1.32.4", 68 | "refractor": "^4.8.1", 69 | "shiki": "^1.9.0", 70 | "sugar-high": "^0.6.1" 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 | "shiki": { 98 | "optional": true 99 | }, 100 | "sugar-high": { 101 | "optional": true 102 | } 103 | }, 104 | "devDependencies": { 105 | "@antfu/ni": "^0.23.0", 106 | "@ocavue/eslint-config": "^2.6.0", 107 | "@types/hast": "^3.0.4", 108 | "@types/node": "^20.16.2", 109 | "eslint": "^9.9.1", 110 | "highlight.js": "^11.10.0", 111 | "jsdom": "^24.1.3", 112 | "lowlight": "^3.1.0", 113 | "prettier": "^3.3.3", 114 | "prosemirror-example-setup": "^1.2.3", 115 | "prosemirror-model": "^1.22.3", 116 | "prosemirror-schema-basic": "^1.2.3", 117 | "prosemirror-state": "^1.4.3", 118 | "prosemirror-transform": "^1.10.0", 119 | "prosemirror-view": "^1.34.1", 120 | "refractor": "^4.8.1", 121 | "shiki": "^1.15.2", 122 | "sugar-high": "^0.7.0", 123 | "tsup": "^8.2.4", 124 | "typescript": "^5.5.4", 125 | "vite": "^5.4.2", 126 | "vitest": "^2.0.5" 127 | }, 128 | "publishConfig": { 129 | "main": "./dist/index.js", 130 | "module": "./dist/index.js", 131 | "types": "./dist/index.d.ts", 132 | "exports": { 133 | ".": { 134 | "types": "./dist/index.d.ts", 135 | "default": "./dist/index.js" 136 | }, 137 | "./lowlight": { 138 | "types": "./dist/lowlight.d.ts", 139 | "default": "./dist/lowlight.js" 140 | }, 141 | "./refractor": { 142 | "types": "./dist/refractor.d.ts", 143 | "default": "./dist/refractor.js" 144 | }, 145 | "./shiki": { 146 | "types": "./dist/shiki.d.ts", 147 | "default": "./dist/shiki.js" 148 | }, 149 | "./sugar-high": { 150 | "types": "./dist/sugar-high.d.ts", 151 | "default": "./dist/sugar-high.js" 152 | } 153 | }, 154 | "typesVersions": { 155 | "*": { 156 | "*": [ 157 | "./dist/*", 158 | "./dist/index.d.ts" 159 | ] 160 | } 161 | } 162 | }, 163 | "renovate": { 164 | "dependencyDashboard": true, 165 | "extends": [ 166 | "github>ocavue/config-renovate" 167 | ] 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /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 | ### With [lowlight] (based on [Highlight.js]) 81 | 82 |
83 | Static loading of all languages 84 | 85 | ```ts 86 | import 'highlight.js/styles/default.css' 87 | 88 | import { common, createLowlight } from 'lowlight' 89 | 90 | import { createHighlightPlugin } from 'prosemirror-highlight' 91 | import { createParser } from 'prosemirror-highlight/lowlight' 92 | 93 | const lowlight = createLowlight(common) 94 | const parser = createParser(lowlight) 95 | export const lowlightPlugin = createHighlightPlugin({ parser }) 96 | ``` 97 | 98 |
99 | 100 | ### With [refractor] (based on [Prism]) 101 | 102 |
103 | Static loading of all languages 104 | 105 | ```ts 106 | import { refractor } from 'refractor' 107 | 108 | import { createHighlightPlugin } from 'prosemirror-highlight' 109 | import { createParser } from 'prosemirror-highlight/refractor' 110 | 111 | const parser = createParser(refractor) 112 | export const refractorPlugin = createHighlightPlugin({ parser }) 113 | ``` 114 | 115 |
116 | 117 | ### With [Sugar high] 118 | 119 |
120 | Highlight with CSS 121 | 122 | ```ts 123 | import { createHighlightPlugin } from 'prosemirror-highlight' 124 | import { createParser } from 'prosemirror-highlight/sugar-high' 125 | 126 | const parser = createParser() 127 | export const sugarHighPlugin = createHighlightPlugin({ parser }) 128 | ``` 129 | 130 | ```css 131 | :root { 132 | --sh-class: #2d5e9d; 133 | --sh-identifier: #354150; 134 | --sh-sign: #8996a3; 135 | --sh-property: #0550ae; 136 | --sh-entity: #249a97; 137 | --sh-jsxliterals: #6266d1; 138 | --sh-string: #00a99a; 139 | --sh-keyword: #f47067; 140 | --sh-comment: #a19595; 141 | } 142 | ``` 143 | 144 |
145 | 146 | ## Online demo 147 | 148 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/ocavue/prosemirror-highlight?file=playground%2Fmain.ts) 149 | 150 | ## Credits 151 | 152 | - [prosemirror-highlightjs] - Highlight.js syntax highlighting for ProseMirror 153 | 154 | ## License 155 | 156 | MIT 157 | 158 | [ProseMirror]: https://prosemirror.net 159 | [prosemirror-highlightjs]: https://github.com/b-kelly/prosemirror-highlightjs 160 | [lowlight]: https://github.com/wooorm/lowlight 161 | [Highlight.js]: https://github.com/highlightjs/highlight.js 162 | [Shiki]: https://github.com/shikijs/shiki 163 | [refractor]: https://github.com/wooorm/refractor 164 | [Prism]: https://github.com/PrismJS/prism 165 | [Sugar high]: https://github.com/huozhi/sugar-high 166 | -------------------------------------------------------------------------------- /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'], 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'] 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() 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 | }) 154 | 155 | if (decorations && Array.isArray(decorations)) { 156 | cache.set(pos, node, decorations) 157 | result.push(...decorations) 158 | } else if (decorations instanceof Promise) { 159 | cache.remove(pos) 160 | promises.push(decorations) 161 | } 162 | } 163 | } 164 | return false 165 | }) 166 | 167 | return [DecorationSet.create(doc, result), promises] as const 168 | } 169 | -------------------------------------------------------------------------------- /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 |           
 35 |             console.
 36 |             log(
 37 |             123+
 38 |             "456");
 39 |           
 40 |         
41 |
 42 |           
 43 |             print(
 44 |             "1+1",
 45 |             "=",2)
 46 |           
 47 |         
48 |
; 49 | " 50 | `, 51 | ) 52 | }) 53 | 54 | it('can highlight code blocks with refractor', async () => { 55 | const { createParser } = await import('../src/refractor') 56 | const { refractor } = await import('refractor') 57 | 58 | const parser = createParser(refractor) 59 | const plugin = createHighlightPlugin({ parser }) 60 | 61 | const state = EditorState.create({ doc, plugins: [plugin] }) 62 | const view = new EditorView(document.createElement('div'), { state }) 63 | 64 | const html = await formatHtml(view.dom.outerHTML) 65 | expect(html).toMatchInlineSnapshot( 66 | ` 67 | "
68 |
 69 |           
 70 |             console
 71 |             .
 72 |             log
 73 |             (
 74 |             123
 75 |             +
 76 |             "456"
 77 |             )
 78 |             ;
 79 |           
 80 |         
81 |
 82 |           
 83 |             print
 84 |             (
 85 |             "1+1"
 86 |             ,
 87 |             "="
 88 |             ,
 89 |             2
 90 |             )
 91 |           
 92 |         
93 |
; 94 | " 95 | `, 96 | ) 97 | }) 98 | 99 | it('can highlight code blocks with sugar-high', async () => { 100 | const { createParser } = await import('../src/sugar-high') 101 | 102 | const parser = createParser() 103 | const plugin = createHighlightPlugin({ parser }) 104 | 105 | const state = EditorState.create({ doc, plugins: [plugin] }) 106 | const view = new EditorView(document.createElement('div'), { state }) 107 | 108 | const html = await formatHtml(view.dom.outerHTML) 109 | expect(html).toMatchInlineSnapshot( 110 | ` 111 | "
112 |
113 |           
114 |             
115 |               console
116 |             
117 |             
118 |               .
119 |             
120 |             
121 |               log
122 |             
123 |             
124 |               (
125 |             
126 |             
127 |               123
128 |             
129 |             
130 |               +
131 |             
132 |             
133 |               "
134 |             
135 |             
136 |               456
137 |             
138 |             
139 |               "
140 |             
141 |             
142 |               )
143 |             
144 |             
145 |               ;
146 |             
147 |           
148 |         
149 |
150 |           
151 |             
152 |               print
153 |             
154 |             
155 |               (
156 |             
157 |             
158 |               "
159 |             
160 |             
161 |               1+1
162 |             
163 |             
164 |               "
165 |             
166 |             
167 |               ,
168 |             
169 |             
170 |               "
171 |             
172 |             
173 |               =
174 |             
175 |             
176 |               "
177 |             
178 |             
179 |               ,
180 |             
181 |             
182 |               2
183 |             
184 |             
185 |               )
186 |             
187 |           
188 |         
189 |
; 190 | " 191 | `, 192 | ) 193 | }) 194 | }) 195 | --------------------------------------------------------------------------------