├── .eslintignore ├── .eslintrc.js ├── .github └── dependabot.yml ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── demo ├── demo.css ├── demo.js └── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── getHighlightDecorations.ts ├── index.ts ├── plugin.ts └── sample-schema.ts ├── test ├── getHighlightDecorations.test.ts ├── helpers.ts ├── plugin.test.ts ├── sample-schema.test.ts └── tsconfig.json ├── tsconfig.eslint.json ├── tsconfig.json └── vite.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: ["./tsconfig.eslint.json"], 7 | ecmaFeatures: { 8 | jsx: true, 9 | }, 10 | }, 11 | plugins: ["@typescript-eslint", "jest"], 12 | extends: [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 16 | "plugin:jest/recommended", 17 | "prettier", 18 | ], 19 | rules: { 20 | "no-console": "error", 21 | "no-alert": "error", 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | reviewers: 13 | - "b-kelly" 14 | allow: 15 | - dependency-name: "prosemirror-*" 16 | dependency-name: "highlight.js" 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vscode/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo/ 2 | test/ 3 | .vscode/ 4 | *.js 5 | !dist/*.js 6 | *.json 7 | .github/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ben Kelly 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-highlightjs 2 | 3 | ## Usage 4 | 5 | ```js 6 | import hljs from "highlight.js/lib/core"; 7 | import { highlightPlugin } from "prosemirror-highlightjs"; 8 | 9 | let state = new EditorView(..., { 10 | state: EditorState.create({ 11 | doc: ..., 12 | plugins: [highlightPlugin(hljs)], 13 | }) 14 | }); 15 | ``` 16 | 17 | Or import just the decoration parser and write your own plugin: 18 | 19 | ```js 20 | import { getHighlightDecorations } from "prosemirror-highlightjs"; 21 | 22 | let plugin = new Plugin({ 23 | state: { 24 | init(config, instance) { 25 | let content = getHighlightDecorations( 26 | instance.doc, 27 | hljs, 28 | blockTypes, 29 | languageExtractor 30 | ); 31 | return DecorationSet.create(instance.doc, content); 32 | }, 33 | apply(tr, set) { 34 | if (!tr.docChanged) { 35 | return set.map(tr.mapping, tr.doc); 36 | } 37 | 38 | let content = getHighlightDecorations( 39 | tr.doc, 40 | hljs, 41 | blockTypes, 42 | languageExtractor 43 | ); 44 | return DecorationSet.create(tr.doc, content); 45 | }, 46 | }, 47 | props: { 48 | decorations(state) { 49 | return this.getState(state); 50 | }, 51 | }, 52 | }); 53 | ``` 54 | 55 | ## Theming considerations 56 | 57 | Due to how ProseMirror renders decorations, some existing highlight.js themes might not work as expected. 58 | ProseMirror collapses all nested/overlapping decoration structures, causing a structure such as 59 | `.hljs-function > (.hljs-keyword + .hljs-title)` to instead render as `.hljs-function.hljs-keyword + .hljs-function.hljs.title`. 60 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | min-height: 300px; 3 | border: 1px solid black; 4 | padding: 12px 8px; 5 | } 6 | -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | import hljs from "highlight.js/lib/core"; 2 | import "highlight.js/styles/stackoverflow-dark.css"; 3 | import { baseKeymap } from "prosemirror-commands"; 4 | import { keymap } from "prosemirror-keymap"; 5 | import { DOMParser, Schema } from "prosemirror-model"; 6 | import { EditorState } from "prosemirror-state"; 7 | import { EditorView } from "prosemirror-view"; 8 | import "prosemirror-view/style/prosemirror.css"; 9 | import { highlightPlugin } from "../src/index"; 10 | import { schema } from "../src/sample-schema"; 11 | import "./demo.css"; 12 | 13 | import js from "highlight.js/lib/languages/javascript"; 14 | 15 | hljs.registerLanguage("javascript", js); 16 | 17 | var extendedSchema = new Schema({ 18 | nodes: { 19 | doc: { 20 | content: "block+", 21 | }, 22 | text: { 23 | group: "inline", 24 | }, 25 | code_block: { 26 | ...schema.nodes.code_block.spec, 27 | toDOM(node) { 28 | return [ 29 | "pre", 30 | { "data-params": node.attrs.params, class: "hljs" }, 31 | ["code", 0], 32 | ]; 33 | }, 34 | }, 35 | paragraph: { 36 | content: "inline*", 37 | group: "block", 38 | parseDOM: [{ tag: "p" }], 39 | toDOM() { 40 | return ["p", 0]; 41 | }, 42 | }, 43 | }, 44 | marks: {}, 45 | }); 46 | 47 | let content = document.querySelector("#content"); 48 | 49 | // create our prosemirror document and attach to window for easy local debugging 50 | window.view = new EditorView(document.querySelector("#editor"), { 51 | state: EditorState.create({ 52 | doc: DOMParser.fromSchema(extendedSchema).parse(content), 53 | schema: extendedSchema, 54 | plugins: [ 55 | keymap(baseKeymap), 56 | keymap({ 57 | // pressing TAB (naively) inserts four spaces in code_blocks 58 | Tab: (state, dispatch) => { 59 | let { $head } = state.selection; 60 | if (!$head.parent.type.spec.code) { 61 | return false; 62 | } 63 | if (dispatch) { 64 | dispatch(state.tr.insertText(" ").scrollIntoView()); 65 | } 66 | 67 | return true; 68 | }, 69 | }), 70 | highlightPlugin(hljs), 71 | ], 72 | }), 73 | }); 74 | 75 | // highlight our "static" version to compare 76 | let clone = document.querySelector("#content-clone"); 77 | clone.innerHTML = content.querySelector("pre").outerHTML; 78 | hljs.highlightElement(clone.querySelector("pre code")); 79 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Demo 8 | 9 | 10 | 11 | 36 |

Editor

37 |
38 |

Reference sample

39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: "ts-jest", 3 | testEnvironment: "jsdom", 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-highlightjs", 3 | "version": "0.9.1", 4 | "description": "ProseMirror plugin to highlight code with highlight.js", 5 | "type": "module", 6 | "keywords": [ 7 | "prosemirror", 8 | "highlightjs", 9 | "highlight.js" 10 | ], 11 | "homepage": "https://github.com/b-kelly/prosemirror-highlightjs", 12 | "license": "MIT", 13 | "author": "Ben Kelly", 14 | "main": "dist/index.umd.cjs", 15 | "module": "dist/index.js", 16 | "types": "./dist/index.d.ts", 17 | "exports": { 18 | ".": { 19 | "import": "./dist/index.js", 20 | "require": "./dist/index.umd.cjs" 21 | } 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/b-kelly/prosemirror-highlightjs.git" 26 | }, 27 | "scripts": { 28 | "start": "vite serve demo", 29 | "build": "tsc && vite build", 30 | "test": "jest", 31 | "prepublishOnly": "npm run build" 32 | }, 33 | "peerDependencies": { 34 | "highlight.js": "^11.6.0", 35 | "prosemirror-model": "^1.18.1", 36 | "prosemirror-state": "^1.4.1", 37 | "prosemirror-view": "^1.26.5" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^28.1.5", 41 | "@typescript-eslint/eslint-plugin": "^5.30.6", 42 | "@typescript-eslint/parser": "^5.30.6", 43 | "eslint": "^8.19.0", 44 | "eslint-config-prettier": "^8.5.0", 45 | "eslint-plugin-jest": "^26.5.3", 46 | "highlight.js": "^11.6.0", 47 | "jest": "^28.1.3", 48 | "jest-environment-jsdom": "^28.1.3", 49 | "prettier": "^2.7.1", 50 | "prosemirror-commands": "^1.3.0", 51 | "prosemirror-keymap": "^1.2.0", 52 | "prosemirror-transform": "^1.6.0", 53 | "prosemirror-view": "^1.26.5", 54 | "ts-jest": "^28.0.6", 55 | "typescript": "^4.7.4", 56 | "vite": "^3.0.0", 57 | "vite-plugin-dts": "^1.3.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/getHighlightDecorations.ts: -------------------------------------------------------------------------------- 1 | import type { Emitter, HLJSApi, HLJSOptions } from "highlight.js"; 2 | import type { Node as ProseMirrorNode } from "prosemirror-model"; 3 | import { Decoration } from "prosemirror-view"; 4 | 5 | /** TODO default emitter type for hljs */ 6 | interface TokenTreeEmitter extends Emitter { 7 | options: HLJSOptions; 8 | walk: (r: Renderer) => void; 9 | } 10 | 11 | type DataNode = { scope?: string; sublanguage?: boolean }; 12 | 13 | interface Renderer { 14 | addText: (text: string) => void; 15 | openNode: (node: DataNode) => void; 16 | closeNode: (node: DataNode) => void; 17 | value: () => unknown; 18 | } 19 | 20 | type RendererNode = { 21 | from: number; 22 | to: number; 23 | scope?: string; 24 | classes: string; 25 | }; 26 | 27 | /** 28 | * Gets all nodes with a type in nodeTypes from a document 29 | * @param doc The document to search 30 | * @param nodeTypes The types of nodes to get 31 | */ 32 | function getNodesOfType( 33 | doc: ProseMirrorNode, 34 | nodeTypes: string[] 35 | ): { node: ProseMirrorNode; pos: number }[] { 36 | const blocks: { node: ProseMirrorNode; pos: number }[] = []; 37 | 38 | if (nodeTypes.includes("doc")) { 39 | blocks.push({ node: doc, pos: -1 }); 40 | } 41 | 42 | doc.descendants((child, pos) => { 43 | if (child.isBlock && nodeTypes.indexOf(child.type.name) > -1) { 44 | blocks.push({ 45 | node: child, 46 | pos: pos, 47 | }); 48 | 49 | return false; 50 | } 51 | 52 | return; 53 | }); 54 | 55 | return blocks; 56 | } 57 | 58 | interface GetHighlightDecorationsOptions { 59 | /** 60 | * A method that is called before the render process begins where any non-null return value cancels the render; useful for decoration caching on untouched nodes 61 | * @param block The node that is about to render 62 | * @param pos The position in the document of the node 63 | * @returns An array of the decorations that should be used instead of rendering; cancels the render if a non-null value is returned 64 | */ 65 | preRenderer?: (block: ProseMirrorNode, pos: number) => Decoration[] | null; 66 | 67 | /** 68 | * A method that is called after the render process ends with the result of the node render passed; useful for decoration caching 69 | * @param block The node that was renderer 70 | * @param pos The position of the node in the document 71 | * @param decorations The decorations that were rendered for this node 72 | */ 73 | postRenderer?: ( 74 | block: ProseMirrorNode, 75 | pos: number, 76 | decorations: Decoration[] 77 | ) => void; 78 | 79 | /** 80 | * A method that is called when a block is autohighlighted with the detected language passed; useful for caching the detected language for future use 81 | * @param block The node that was renderer 82 | * @param pos The position of the node in the document 83 | * @param detectedLanguage The language that was detected during autohighlight 84 | */ 85 | autohighlightCallback?: ( 86 | block: ProseMirrorNode, 87 | pos: number, 88 | detectedLanguage: string | undefined 89 | ) => void; 90 | } 91 | 92 | /** 93 | * Gets all highlighting decorations from a ProseMirror document 94 | * @param doc The doc to search applicable blocks to highlight 95 | * @param hljs The pre-configured highlight.js instance to use for parsing 96 | * @param nodeTypes An array containing all the node types to target for highlighting 97 | * @param languageExtractor A method that is passed a prosemirror node and returns the language string to use when highlighting that node 98 | * @param options The options to alter the behavior of getHighlightDecorations 99 | */ 100 | export function getHighlightDecorations( 101 | doc: ProseMirrorNode, 102 | hljs: HLJSApi, 103 | nodeTypes: string[], 104 | languageExtractor: (node: ProseMirrorNode) => string | null, 105 | options?: GetHighlightDecorationsOptions 106 | ): Decoration[] { 107 | if (!doc || !doc.nodeSize || !nodeTypes?.length || !languageExtractor) { 108 | return []; 109 | } 110 | 111 | const blocks = getNodesOfType(doc, nodeTypes); 112 | 113 | let decorations: Decoration[] = []; 114 | 115 | blocks.forEach((b) => { 116 | // attempt to run the prerenderer if it exists 117 | if (options?.preRenderer) { 118 | const prerenderedDecorations = options.preRenderer(b.node, b.pos); 119 | 120 | // if the returned decorations are non-null, use them instead of rendering our own 121 | if (prerenderedDecorations) { 122 | decorations = [...decorations, ...prerenderedDecorations]; 123 | return; 124 | } 125 | } 126 | 127 | const language = languageExtractor(b.node); 128 | 129 | // if the langauge is specified, but isn't loaded, skip highlighting 130 | if (language && !hljs.getLanguage(language)) { 131 | return; 132 | } 133 | 134 | const result = language 135 | ? hljs.highlight(b.node.textContent, { language }) 136 | : hljs.highlightAuto(b.node.textContent); 137 | 138 | // if we autohighlighted and have a callback set, call it 139 | if (!language && result.language && options?.autohighlightCallback) { 140 | options.autohighlightCallback(b.node, b.pos, result.language); 141 | } 142 | 143 | const emitter = result._emitter as TokenTreeEmitter; 144 | 145 | const renderer = new ProseMirrorRenderer( 146 | emitter, 147 | b.pos, 148 | emitter.options.classPrefix 149 | ); 150 | 151 | const value = renderer.value(); 152 | 153 | const localDecorations: Decoration[] = []; 154 | value.forEach((v) => { 155 | if (!v.scope) { 156 | return; 157 | } 158 | 159 | const decoration = Decoration.inline(v.from, v.to, { 160 | class: v.classes, 161 | }); 162 | 163 | localDecorations.push(decoration); 164 | }); 165 | 166 | if (options?.postRenderer) { 167 | options.postRenderer(b.node, b.pos, localDecorations); 168 | } 169 | 170 | decorations = [...decorations, ...localDecorations]; 171 | }); 172 | 173 | return decorations; 174 | } 175 | 176 | class ProseMirrorRenderer implements Renderer { 177 | private buffer: RendererNode[]; 178 | private nodeQueue: RendererNode[]; 179 | private classPrefix: string; 180 | private currentPosition: number; 181 | 182 | constructor( 183 | tree: TokenTreeEmitter, 184 | startingBlockPos: number, 185 | classPrefix: string 186 | ) { 187 | this.buffer = []; 188 | this.nodeQueue = []; 189 | this.classPrefix = classPrefix; 190 | this.currentPosition = startingBlockPos + 1; 191 | tree.walk(this); 192 | } 193 | 194 | get currentNode() { 195 | return this.nodeQueue.length ? this.nodeQueue.slice(-1) : null; 196 | } 197 | 198 | addText(text: string) { 199 | const node = this.currentNode; 200 | 201 | if (!node) { 202 | return; 203 | } 204 | 205 | this.currentPosition += text.length; 206 | } 207 | 208 | openNode(node: DataNode) { 209 | let className = node.scope || ""; 210 | if (node.sublanguage) { 211 | className = `language-${className}`; 212 | } else { 213 | className = this.expandScopeName(className); 214 | } 215 | 216 | const item = this.newNode(); 217 | item.scope = node.scope; 218 | item.classes = className; 219 | item.from = this.currentPosition; 220 | 221 | this.nodeQueue.push(item); 222 | } 223 | 224 | closeNode(node: DataNode) { 225 | const item = this.nodeQueue.pop(); 226 | 227 | // will this ever happen in practice? 228 | // if the nodeQueue is empty, we have nothing to close 229 | if (!item) { 230 | throw "Cannot close node!"; 231 | } 232 | 233 | item.to = this.currentPosition; 234 | 235 | // will this ever happen in practice? 236 | if (node.scope !== item.scope) { 237 | throw "Mismatch!"; 238 | } 239 | 240 | this.buffer.push(item); 241 | } 242 | 243 | value() { 244 | return this.buffer; 245 | } 246 | 247 | private newNode(): RendererNode { 248 | return { 249 | from: 0, 250 | to: 0, 251 | scope: undefined, 252 | classes: "", 253 | }; 254 | } 255 | 256 | // TODO logic taken from upstream 257 | private expandScopeName(name: string): string { 258 | if (name.includes(".")) { 259 | const pieces = name.split("."); 260 | const prefix = pieces.shift() || ""; 261 | return [ 262 | `${this.classPrefix}${prefix}`, 263 | ...pieces.map((x, i) => `${x}${"_".repeat(i + 1)}`), 264 | ].join(" "); 265 | } 266 | return `${this.classPrefix}${name}`; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./getHighlightDecorations"; 2 | export * from "./plugin"; 3 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { HLJSApi } from "highlight.js"; 2 | import { Node as ProseMirrorNode } from "prosemirror-model"; 3 | import { Plugin, PluginKey, Transaction } from "prosemirror-state"; 4 | import type { Mapping } from "prosemirror-transform"; 5 | import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; 6 | import { getHighlightDecorations } from "./getHighlightDecorations"; 7 | 8 | // TODO `map` is not actually part of the exposed api for Decoration, 9 | // so we have to add our own type definitions to expose it 10 | declare module "prosemirror-view" { 11 | interface Decoration { 12 | map: ( 13 | mapping: Mapping, 14 | offset: number, 15 | oldOffset: number 16 | ) => Decoration; 17 | } 18 | } 19 | 20 | /** Describes the current state of the highlightPlugin */ 21 | export interface HighlightPluginState { 22 | cache: DecorationCache; 23 | decorations: DecorationSet; 24 | autodetectedLanguages: { 25 | node: ProseMirrorNode; 26 | pos: number; 27 | language: string | undefined; 28 | }[]; 29 | } 30 | 31 | /** Represents a cache of doc positions to the node and decorations at that position */ 32 | export class DecorationCache { 33 | private cache: { 34 | [pos: number]: { node: ProseMirrorNode; decorations: Decoration[] }; 35 | }; 36 | 37 | constructor(cache: { 38 | [pos: number]: { node: ProseMirrorNode; decorations: Decoration[] }; 39 | }) { 40 | this.cache = { ...cache }; 41 | } 42 | 43 | /** 44 | * Gets the cache entry at the given doc position, or null if it doesn't exist 45 | * @param pos The doc position of the node you want the cache for 46 | */ 47 | get(pos: number): { node: ProseMirrorNode; decorations: Decoration[] } { 48 | return this.cache[pos] || null; 49 | } 50 | 51 | /** 52 | * Sets the cache entry at the given position with the give node/decoration values 53 | * @param pos The doc position of the node to set the cache for 54 | * @param node The node to place in cache 55 | * @param decorations The decorations to place in cache 56 | */ 57 | set(pos: number, node: ProseMirrorNode, decorations: Decoration[]): void { 58 | if (pos < 0) { 59 | return; 60 | } 61 | 62 | this.cache[pos] = { node, decorations }; 63 | } 64 | 65 | /** 66 | * Removes the value at the oldPos (if it exists) and sets the new position to the given values 67 | * @param oldPos The old node position to overwrite 68 | * @param newPos The new node position to set the cache for 69 | * @param node The new node to place in cache 70 | * @param decorations The new decorations to place in cache 71 | */ 72 | replace( 73 | oldPos: number, 74 | newPos: number, 75 | node: ProseMirrorNode, 76 | decorations: Decoration[] 77 | ): void { 78 | this.remove(oldPos); 79 | this.set(newPos, node, decorations); 80 | } 81 | 82 | /** 83 | * Removes the cache entry at the given position 84 | * @param pos The doc position to remove from cache 85 | */ 86 | remove(pos: number): void { 87 | delete this.cache[pos]; 88 | } 89 | 90 | /** 91 | * Invalidates the cache by removing all decoration entries on nodes that have changed, 92 | * updating the positions of the nodes that haven't and removing all the entries that have been deleted; 93 | * NOTE: this does not affect the current cache, but returns an entirely new one 94 | * @param tr A transaction to map the current cache to 95 | */ 96 | invalidate(tr: Transaction): DecorationCache { 97 | const returnCache = new DecorationCache(this.cache); 98 | const mapping = tr.mapping; 99 | Object.keys(this.cache).forEach((k) => { 100 | const pos = +k; 101 | 102 | if (pos < 0) { 103 | return; 104 | } 105 | 106 | const result = mapping.mapResult(pos); 107 | const mappedNode = tr.doc.nodeAt(result.pos); 108 | const { node, decorations } = this.get(pos); 109 | 110 | if (result.deleted || !mappedNode?.eq(node)) { 111 | returnCache.remove(pos); 112 | } else if (pos !== result.pos) { 113 | // update the decorations' from/to values to match the new node position 114 | const updatedDecorations = decorations 115 | .map((d) => d.map(mapping, 0, 0)) 116 | .filter((d) => d !== null); 117 | returnCache.replace( 118 | pos, 119 | result.pos, 120 | mappedNode, 121 | updatedDecorations 122 | ); 123 | } 124 | }); 125 | 126 | return returnCache; 127 | } 128 | } 129 | 130 | /** 131 | * Creates a plugin that highlights the contents of all nodes (via Decorations) with a type passed in blockTypes 132 | * @param hljs The pre-configured instance of highlightjs to use for parsing 133 | * @param nodeTypes An array containing all the node types to target for highlighting 134 | * @param languageExtractor A method that is passed a prosemirror node and returns the language string to use when highlighting that node; defaults to using `node.attrs.params` 135 | * @param languageSetter A method that is called after language autodetection of a node in order to save a autohighlight value for future use 136 | */ 137 | export function highlightPlugin( 138 | hljs: HLJSApi, 139 | nodeTypes: string[] = ["code_block"], 140 | languageExtractor?: (node: ProseMirrorNode) => string, 141 | languageSetter?: ( 142 | tr: Transaction, 143 | node: ProseMirrorNode, 144 | pos: number, 145 | language: string | undefined 146 | ) => Transaction | null 147 | ): Plugin { 148 | const extractor = 149 | languageExtractor || 150 | function (node: ProseMirrorNode) { 151 | const detectedLanguage = node.attrs 152 | .detectedHighlightLanguage as string; 153 | const params = node.attrs.params as string; 154 | return detectedLanguage || params?.split(" ")[0] || ""; 155 | }; 156 | 157 | const setter = 158 | languageSetter || 159 | function (tr, node, pos, language) { 160 | const attrs = node.attrs || {}; 161 | 162 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 163 | // @ts-expect-error 164 | attrs["detectedHighlightLanguage"] = language; 165 | 166 | // set the params attribute of the node to the detected language 167 | return tr.setNodeMarkup(pos, undefined, attrs); 168 | }; 169 | 170 | const getDecos = (doc: ProseMirrorNode, cache: DecorationCache) => { 171 | const autodetectedLanguages: { 172 | node: ProseMirrorNode; 173 | pos: number; 174 | language: string | undefined; 175 | }[] = []; 176 | 177 | const content = getHighlightDecorations( 178 | doc, 179 | hljs, 180 | nodeTypes, 181 | extractor, 182 | { 183 | preRenderer: (_, pos) => cache.get(pos)?.decorations, 184 | postRenderer: (b, pos, decorations) => { 185 | cache.set(pos, b, decorations); 186 | }, 187 | autohighlightCallback: (node, pos, language) => { 188 | autodetectedLanguages.push({ 189 | node, 190 | pos, 191 | language, 192 | }); 193 | }, 194 | } 195 | ); 196 | 197 | return { content, autodetectedLanguages }; 198 | }; 199 | 200 | // key the plugin so we can easily find it in the state later 201 | const key = new PluginKey(); 202 | 203 | return new Plugin({ 204 | key, 205 | state: { 206 | init(_, instance) { 207 | const cache = new DecorationCache({}); 208 | const result = getDecos(instance.doc, cache); 209 | return { 210 | cache: cache, 211 | decorations: DecorationSet.create( 212 | instance.doc, 213 | result.content 214 | ), 215 | autodetectedLanguages: result.autodetectedLanguages, 216 | }; 217 | }, 218 | apply(tr, data) { 219 | const updatedCache = data.cache.invalidate(tr); 220 | if (!tr.docChanged) { 221 | return { 222 | cache: updatedCache, 223 | decorations: data.decorations.map(tr.mapping, tr.doc), 224 | autodetectedLanguages: [], 225 | }; 226 | } 227 | 228 | const result = getDecos(tr.doc, updatedCache); 229 | 230 | return { 231 | cache: updatedCache, 232 | decorations: DecorationSet.create(tr.doc, result.content), 233 | autodetectedLanguages: result.autodetectedLanguages, 234 | }; 235 | }, 236 | }, 237 | props: { 238 | decorations(this: Plugin, state) { 239 | return this.getState(state)?.decorations; 240 | }, 241 | }, 242 | view(initialView: EditorView) { 243 | // TODO `view` is only called when the state is attached to an EditorView 244 | // this is likely not a problem for the majority of users, but could be an issue 245 | // for consumers using this plugin server-side with no view 246 | 247 | // dispatches a transaction to update a node's language if needed 248 | const updateNodeLanguages = (view: EditorView) => { 249 | const pluginState = key.getState(view.state); 250 | 251 | // if there's no pluginState found or if no block was autodetected, no need to do anything 252 | if (!pluginState || !pluginState.autodetectedLanguages.length) { 253 | return; 254 | } 255 | 256 | let tr = view.state.tr; 257 | 258 | // for each autodetected language, place it 259 | pluginState.autodetectedLanguages.forEach((l) => { 260 | if (l.language) { 261 | const newTr = setter(tr, l.node, l.pos, l.language); 262 | tr = newTr || tr; 263 | } 264 | }); 265 | 266 | // ensure that our behind-the-scenes update doesn't get added to the editor history 267 | tr = tr.setMeta("addToHistory", false); 268 | 269 | view.dispatch(tr); 270 | }; 271 | 272 | // go ahead and update the nodes immediately 273 | updateNodeLanguages(initialView); 274 | 275 | // update all the nodes whenever the document updates 276 | return { 277 | update: updateNodeLanguages, 278 | }; 279 | }, 280 | }); 281 | } 282 | -------------------------------------------------------------------------------- /src/sample-schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Node } from "prosemirror-model"; 2 | 3 | /** 4 | * Sample schema to show how a code_block node would look like for use with the default plugin settings; 5 | * not included in the `index` bundle purposefully since this was mostly just created for tests/demo purposes 6 | */ 7 | export const schema = new Schema({ 8 | nodes: { 9 | doc: { 10 | content: "code_block+", 11 | }, 12 | text: { 13 | group: "inline", 14 | }, 15 | code_block: { 16 | content: "text*", 17 | group: "block", 18 | code: true, 19 | defining: true, 20 | marks: "", 21 | attrs: { 22 | params: { default: "" }, 23 | detectedHighlightLanguage: { default: "" }, 24 | }, 25 | parseDOM: [ 26 | { 27 | tag: "pre", 28 | preserveWhitespace: "full", 29 | getAttrs: (node: HTMLElement | string) => ({ 30 | params: 31 | (node)?.getAttribute("data-params") || "", 32 | }), 33 | }, 34 | ], 35 | toDOM(node: Node) { 36 | return [ 37 | "pre", 38 | { "data-params": node.attrs.params as string }, 39 | ["code", 0], 40 | ]; 41 | }, 42 | }, 43 | }, 44 | marks: {}, 45 | }); 46 | -------------------------------------------------------------------------------- /test/getHighlightDecorations.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { DOMParser, Schema } from "prosemirror-model"; 3 | import type { Decoration } from "prosemirror-view"; 4 | import { getHighlightDecorations } from "../src"; 5 | import { 6 | createDoc, 7 | escapeHtml, 8 | hljsInstance, 9 | nativeVsPluginTests, 10 | } from "./helpers"; 11 | 12 | describe("getHighlightDecorations", () => { 13 | it("should do basic highlighting", () => { 14 | const doc = createDoc([ 15 | { code: `console.log("hello world!");`, language: "javascript" }, 16 | ]); 17 | const decorations = getHighlightDecorations( 18 | doc, 19 | hljsInstance, 20 | ["code_block"], 21 | (node) => { 22 | expect(node).not.toBeNull(); 23 | expect(node.type.name).toBe("code_block"); 24 | return "javascript"; 25 | } 26 | ); 27 | 28 | expect(decorations).toBeTruthy(); 29 | expect(decorations).not.toHaveLength(0); 30 | }); 31 | 32 | it("should be resilient to bad params", () => { 33 | const doc = createDoc([ 34 | { code: `console.log("hello world!");`, language: "javascript" }, 35 | ]); 36 | 37 | // null doc 38 | // @ts-expect-error TS errors as we'd expect, but I want to simulate a JS consumer passing in bad vals 39 | let decorations = getHighlightDecorations(null, null, null, null); 40 | expect(decorations).toBeTruthy(); 41 | expect(decorations).toHaveLength(0); 42 | 43 | // null hljs 44 | // @ts-expect-error More errors... 45 | decorations = getHighlightDecorations(doc, null, null, null); 46 | expect(decorations).toBeTruthy(); 47 | expect(decorations).toHaveLength(0); 48 | 49 | // null nodeTypes 50 | // @ts-expect-error You guessed it... 51 | decorations = getHighlightDecorations(doc, hljsInstance, null, null); 52 | expect(decorations).toBeTruthy(); 53 | expect(decorations).toHaveLength(0); 54 | 55 | // empty nodeTypes 56 | // @ts-expect-error Still... 57 | decorations = getHighlightDecorations(doc, hljsInstance, [], null); 58 | expect(decorations).toBeTruthy(); 59 | expect(decorations).toHaveLength(0); 60 | 61 | // empty nodeTypes 62 | // @ts-expect-error Still... 63 | decorations = getHighlightDecorations(doc, hljsInstance, [], null); 64 | expect(decorations).toBeTruthy(); 65 | expect(decorations).toHaveLength(0); 66 | 67 | // empty languageExtractor 68 | decorations = getHighlightDecorations( 69 | doc, 70 | hljsInstance, 71 | ["javascript"], 72 | // @ts-expect-error Last one... 73 | null 74 | ); 75 | expect(decorations).toBeTruthy(); 76 | expect(decorations).toHaveLength(0); 77 | }); 78 | 79 | it("should auto-highlight on an empty language", () => { 80 | const doc = createDoc([ 81 | { code: `System.out.println("hello world!");` }, 82 | ]); 83 | const decorations = getHighlightDecorations( 84 | doc, 85 | hljsInstance, 86 | ["code_block"], 87 | () => null 88 | ); 89 | 90 | expect(decorations).toBeTruthy(); 91 | expect(decorations).not.toHaveLength(0); 92 | }); 93 | 94 | it("should cancel on non-null prerender", () => { 95 | const doc = createDoc([ 96 | { code: `console.log("hello world!");`, language: "javascript" }, 97 | ]); 98 | const decorations = getHighlightDecorations( 99 | doc, 100 | hljsInstance, 101 | ["code_block"], 102 | () => "javascript", 103 | { 104 | preRenderer: (node, pos) => { 105 | expect(node).not.toBeNull(); 106 | expect(node.type.name).toBe("code_block"); 107 | expect(typeof pos === "number").toBe(true); 108 | return []; 109 | }, 110 | } 111 | ); 112 | 113 | expect(decorations).toBeTruthy(); 114 | expect(decorations).toHaveLength(0); 115 | }); 116 | 117 | it("should continue on null prerender", () => { 118 | const doc = createDoc([ 119 | { code: `console.log("hello world!");`, language: "javascript" }, 120 | ]); 121 | const decorations = getHighlightDecorations( 122 | doc, 123 | hljsInstance, 124 | ["code_block"], 125 | () => "javascript", 126 | { 127 | preRenderer: () => null, 128 | } 129 | ); 130 | 131 | expect(decorations).toBeTruthy(); 132 | expect(decorations).not.toHaveLength(0); 133 | }); 134 | 135 | it("should call postrender", () => { 136 | let renderedDecorations: Decoration[] = []; 137 | 138 | const doc = createDoc([ 139 | { code: `console.log("hello world!");`, language: "javascript" }, 140 | ]); 141 | const decorations = getHighlightDecorations( 142 | doc, 143 | hljsInstance, 144 | ["code_block"], 145 | () => "javascript", 146 | { 147 | postRenderer: (node, pos, decos) => { 148 | expect(node).not.toBeNull(); 149 | expect(node.type.name).toBe("code_block"); 150 | expect(typeof pos).toBe("number"); 151 | 152 | renderedDecorations = decos; 153 | }, 154 | } 155 | ); 156 | 157 | expect(decorations).toBeTruthy(); 158 | expect(decorations).not.toHaveLength(0); 159 | expect(decorations).toEqual(renderedDecorations); 160 | }); 161 | 162 | it("should call autohighlightCallback", () => { 163 | const doc = createDoc([{ code: `console.log("hello world!");` }]); 164 | 165 | let detectedLanguage: string | undefined = undefined; 166 | 167 | getHighlightDecorations(doc, hljsInstance, ["code_block"], () => null, { 168 | autohighlightCallback: (_, __, language) => { 169 | detectedLanguage = language; 170 | }, 171 | }); 172 | 173 | expect(detectedLanguage).toBe("javascript"); 174 | }); 175 | 176 | it.each([undefined, "javascript"])( 177 | "should call not autohighlightCallback (%s)", 178 | (language) => { 179 | const doc = createDoc([{ code: "", language }]); 180 | 181 | getHighlightDecorations( 182 | doc, 183 | hljsInstance, 184 | ["code_block"], 185 | () => null, 186 | { 187 | autohighlightCallback: (_, __, ___) => { 188 | throw "This should not have been called!"; 189 | }, 190 | } 191 | ); 192 | 193 | expect(true).toBeTruthy(); 194 | } 195 | ); 196 | 197 | it("should support highlighting the doc node itself", () => { 198 | const schema = new Schema({ 199 | nodes: { 200 | text: { 201 | group: "inline", 202 | }, 203 | doc: { 204 | content: "text*", 205 | }, 206 | }, 207 | }); 208 | const element = document.createElement("div"); 209 | element.innerHTML = escapeHtml(`console.log("hello world!");`); 210 | 211 | const doc = DOMParser.fromSchema(schema).parse(element); 212 | const decorations = getHighlightDecorations( 213 | doc, 214 | hljsInstance, 215 | ["doc"], 216 | () => "javascript" 217 | ); 218 | 219 | expect(decorations).toBeTruthy(); 220 | expect(Object.keys(decorations)).toHaveLength(3); 221 | }); 222 | 223 | it.each(nativeVsPluginTests)( 224 | "should create the same decorations as a native highlightBlock call (%p)", 225 | (language, codeString) => { 226 | // get all the decorations generated by our prosemirror plugin 227 | const doc = createDoc([{ code: codeString, language: language }]); 228 | const decorations = getHighlightDecorations( 229 | doc, 230 | hljsInstance, 231 | ["code_block"], 232 | () => language 233 | ) 234 | // decorations are not guaranteed to come back in sorted order, so sort by doc position 235 | .sort((a, b) => { 236 | const sort = a.from - b.from; 237 | 238 | // if one decoration exactly wraps another, the one that ends last is the "first" 239 | // e.g. a() will sort as `class1, class2` 240 | if (!sort) { 241 | return b.to - a.to; 242 | } 243 | 244 | return sort; 245 | }) 246 | // @ts-expect-error using internal apis here for convenience 247 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 248 | .map((d) => d.type.attrs.class as string); 249 | 250 | // run the code through highlightjs and get all the "decorations" from it 251 | const hljsOutput = hljsInstance.highlight(codeString, { 252 | language, 253 | }).value; 254 | const container = document.createElement("pre"); 255 | container.innerHTML = hljsOutput; 256 | const hljsDecorations = Array.from( 257 | container.querySelectorAll("span") 258 | ) 259 | .map((d) => d.className) 260 | .filter((c) => c.startsWith("hljs-")); 261 | 262 | //expect(decorations.length).toBe(hljsDecorations.length); 263 | expect(decorations).toStrictEqual(hljsDecorations); 264 | } 265 | ); 266 | }); 267 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import hljs from "highlight.js/lib/core"; 3 | import { DOMParser, Node } from "prosemirror-model"; 4 | import { EditorState } from "prosemirror-state"; 5 | import { highlightPlugin } from "../src/index"; 6 | import { schema } from "../src/sample-schema"; 7 | 8 | hljs.registerLanguage( 9 | "javascript", 10 | require("highlight.js/lib/languages/javascript") 11 | ); 12 | 13 | hljs.registerLanguage("csharp", require("highlight.js/lib/languages/csharp")); 14 | 15 | hljs.registerLanguage("python", require("highlight.js/lib/languages/python")); 16 | 17 | hljs.registerLanguage("java", require("highlight.js/lib/languages/java")); 18 | 19 | hljs.registerLanguage("xml", require("highlight.js/lib/languages/xml")); 20 | 21 | hljs.registerAliases("js_alias", { 22 | languageName: "javascript", 23 | }); 24 | 25 | export const hljsInstance = hljs; 26 | 27 | export function escapeHtml(html: string) { 28 | return html 29 | .replace(/&/g, "&") 30 | .replace(//g, ">") 32 | .replace(/"/g, """) 33 | .replace(/'/g, "'"); 34 | } 35 | 36 | export function createDoc(input: { code: string; language?: string }[]): Node { 37 | const doc = document.createElement("div"); 38 | 39 | doc.innerHTML = input.reduce((p, n) => { 40 | return ( 41 | p + 42 | `
${escapeHtml(
 43 |                 n.code
 44 |             )}
` 45 | ); 46 | }, ""); 47 | 48 | return DOMParser.fromSchema(schema).parse(doc); 49 | } 50 | 51 | export function createStateImpl( 52 | input: { code: string; language?: string }[], 53 | addPlugins = true 54 | ): EditorState { 55 | return EditorState.create({ 56 | doc: createDoc(input), 57 | schema: schema, 58 | plugins: addPlugins ? [highlightPlugin(hljs)] : [], 59 | }); 60 | } 61 | 62 | export function createState( 63 | code: string, 64 | language?: string, 65 | addPlugins = true 66 | ): EditorState { 67 | return createStateImpl( 68 | [ 69 | { 70 | code, 71 | language, 72 | }, 73 | ], 74 | addPlugins 75 | ); 76 | } 77 | 78 | export const nativeVsPluginTests = [ 79 | [ 80 | "xml", 81 | ` 82 | 83 | 101 | 102 | 103 | 104 | Hello world! 105 | 106 | `, 114 | ], 115 | [ 116 | "javascript", 117 | `function $initHighlight(block, cls) { 118 | try { 119 | const x = true; 120 | } catch (e) { 121 | /* handle exception */ 122 | } 123 | for (var i = 0 / 2; i < classes.length; i++) { 124 | if (checkCondition(classes[i]) === undefined) 125 | console.log('undefined'); 126 | } 127 | 128 | return; 129 | } 130 | 131 | export $initHighlight;`, 132 | ], 133 | ]; 134 | -------------------------------------------------------------------------------- /test/plugin.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { DecorationSet, EditorView } from "prosemirror-view"; 3 | import { createState, createStateImpl } from "./helpers"; 4 | import { DecorationCache } from "../src"; 5 | import { schema } from "../src/sample-schema"; 6 | import { TextSelection, EditorState } from "prosemirror-state"; 7 | 8 | /** Helper function to "illegally" get the private contents of a DecorationCache */ 9 | function getCacheContents(cache: DecorationCache) { 10 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 11 | // @ts-expect-error We don't want to expose .cache publicly, but... I don't care. I wrote it. 12 | return cache.cache; 13 | } 14 | 15 | function getDecorationsFromPlugin(editorState: EditorState) { 16 | const pluginState = editorState.plugins[0].getState(editorState) as { 17 | decorations: DecorationSet; 18 | }; 19 | return pluginState.decorations; 20 | } 21 | 22 | function getNodeHighlightAttrs(state: EditorState) { 23 | const block = (state.doc.toJSON() as { 24 | content: { type: string; attrs: { [key: string]: unknown } }[]; 25 | }).content.find((n) => n.type === "code_block"); 26 | 27 | return { 28 | params: block?.attrs?.params, 29 | detectedHighlightLanguage: block?.attrs?.detectedHighlightLanguage, 30 | }; 31 | } 32 | 33 | describe("DecorationCache", () => { 34 | it("should do basic CRUD operations", () => { 35 | // init with a pre-filled cache and check 36 | const initial = { 37 | 0: { 38 | node: schema.node("code_block", { params: "test0" }), 39 | decorations: [], 40 | }, 41 | }; 42 | const cache = new DecorationCache(initial); 43 | expect(getCacheContents(cache)).toStrictEqual(initial); 44 | 45 | // get existing 46 | expect(cache.get(0)).toStrictEqual(initial[0]); 47 | 48 | // get non-existing 49 | expect(cache.get(-1)).toBeNull(); 50 | 51 | // set non-existing 52 | let node = schema.node("code_block", { params: "test10" }); 53 | cache.set(10, node, []); 54 | expect(cache.get(10)).toStrictEqual({ node, decorations: [] }); 55 | 56 | // set existing 57 | node = schema.node("code_block", { params: "test10 again" }); 58 | cache.set(10, node, []); 59 | expect(cache.get(10)).toStrictEqual({ node, decorations: [] }); 60 | 61 | // replace existing 62 | node = schema.node("code_block", { params: "test20" }); 63 | cache.replace(10, 20, node, []); 64 | expect(cache.get(20)).toStrictEqual({ node, decorations: [] }); 65 | 66 | // replace non-existing 67 | node = schema.node("code_block", { params: "test30" }); 68 | cache.replace(-1, 30, node, []); 69 | expect(cache.get(30)).toStrictEqual({ node, decorations: [] }); 70 | 71 | // remove existing 72 | cache.remove(30); 73 | expect(cache.get(30)).toBeNull(); 74 | 75 | // remove non-existing 76 | expect(() => { 77 | cache.remove(-1); 78 | }).not.toThrow(); 79 | }); 80 | 81 | it("should not invalidate on a transaction that does not change the doc", () => { 82 | const state = createState(`console.log("hello world");`, "javascript"); 83 | const doc = state.doc; 84 | let tr = state.tr; 85 | 86 | const cache = new DecorationCache({ 87 | 0: { node: doc.nodeAt(0)!, decorations: [] }, 88 | }); 89 | 90 | // add a transaction that doesn't alter the doc 91 | tr = tr.setSelection(TextSelection.create(tr.doc, 1, 5)); 92 | 93 | // ensure the docs have not changed 94 | expect(tr.doc.eq(doc)).toBe(true); 95 | expect(tr.docChanged).toBe(false); 96 | 97 | // "invalidate" the cache 98 | const updatedCache = cache.invalidate(tr); 99 | 100 | expect(updatedCache.get(0)).toStrictEqual(cache.get(0)); 101 | }); 102 | 103 | it("should invalidate on a transaction that changes the doc", () => { 104 | const state = createState(`console.log("hello world");`, "javascript"); 105 | const doc = state.doc; 106 | let tr = state.tr; 107 | 108 | const cache = new DecorationCache({ 109 | 0: { node: doc.nodeAt(0)!, decorations: [] }, 110 | }); 111 | 112 | // add a transaction that alters the doc 113 | tr = tr.insert( 114 | 0, 115 | schema.node( 116 | "code_block", 117 | { params: "cpp" }, 118 | schema.text(`cout << "hello world";`) 119 | ) 120 | ); 121 | 122 | // ensure the docs have changed 123 | expect(tr.doc.eq(doc)).toBe(false); 124 | expect(tr.docChanged).toBe(true); 125 | 126 | // invalidate the cache 127 | const updatedCache = cache.invalidate(tr); 128 | // get the new position of the old block from the transaction 129 | const newPos = tr.mapping.map(0); 130 | 131 | expect(updatedCache.get(newPos)).toStrictEqual(cache.get(0)); 132 | }); 133 | }); 134 | 135 | describe("highlightPlugin", () => { 136 | it.each([ 137 | ["should highlight with loaded language", "javascript"], 138 | ["should auto-highlight with loaded language", undefined], 139 | ["should highlight on aliased loaded language", "js_alias"], 140 | ])("%s", (_, language) => { 141 | const state = createState(`console.log("hello world");`, language); 142 | 143 | // TODO check all props? 144 | const pluginState: DecorationSet = getDecorationsFromPlugin(state); 145 | 146 | // the decorations should be loaded 147 | expect(pluginState).not.toBe(DecorationSet.empty); 148 | 149 | // TODO try and check the actual content of the decorations 150 | }); 151 | 152 | it("should skip highlighting on invalid/not loaded language", () => { 153 | const state = createState( 154 | `console.log("hello world");`, 155 | "fake_language" 156 | ); 157 | 158 | // TODO check all props? 159 | const pluginState: DecorationSet = getDecorationsFromPlugin(state); 160 | 161 | // the decorations should NOT be loaded 162 | expect(pluginState).toBe(DecorationSet.empty); 163 | }); 164 | 165 | it("should highlight multiple nodes", () => { 166 | const state = createStateImpl([ 167 | { 168 | code: `console.log("hello world");`, 169 | language: "javascript", 170 | }, 171 | { 172 | code: `System.out.println("hello world");`, 173 | language: "java", 174 | }, 175 | { 176 | code: `Debug.Log("hello world");`, 177 | language: "csharp", 178 | }, 179 | ]); 180 | 181 | // TODO check all props? 182 | const pluginState: DecorationSet = getDecorationsFromPlugin(state); 183 | 184 | // the decorations should be loaded 185 | expect(pluginState).not.toBe(DecorationSet.empty); 186 | 187 | // TODO try and check the actual content of the decorations 188 | }); 189 | 190 | it("should reuse cached decorations on updates that don't change the doc", () => { 191 | let state = createStateImpl([ 192 | { 193 | code: `console.log("hello world");`, 194 | language: "javascript", 195 | }, 196 | { 197 | code: `just some text`, 198 | language: "plaintext", 199 | }, 200 | { 201 | code: `Debug.Log("hello world");`, 202 | language: "csharp", 203 | }, 204 | ]); 205 | 206 | const initialPluginState = state.plugins[0].getState(state) as { 207 | cache: DecorationCache; 208 | decorations: DecorationSet; 209 | }; 210 | expect(initialPluginState.decorations).not.toBe(DecorationSet.empty); 211 | 212 | // add a transaction that doesn't alter the doc 213 | const tr = state.tr.setSelection( 214 | TextSelection.create(state.tr.doc, 1, 5) 215 | ); 216 | state = state.apply(tr); 217 | 218 | // get the updated state and check that it matches the old 219 | const updatedPluginState = state.plugins[0].getState(state) as { 220 | cache: DecorationCache; 221 | decorations: DecorationSet; 222 | }; 223 | expect(updatedPluginState).toStrictEqual(initialPluginState); 224 | }); 225 | 226 | it("should update some cache decorations when a single node is updated", () => { 227 | const blockContents = [ 228 | { 229 | code: `console.log("hello world");`, 230 | language: "javascript", 231 | }, 232 | { 233 | code: `print("hello world")`, 234 | language: "python", 235 | }, 236 | { 237 | code: `Debug.Log("hello world");`, 238 | language: "csharp", 239 | }, 240 | { 241 | code: `just some text`, 242 | language: "plaintext", 243 | }, 244 | ]; 245 | let state = createStateImpl(blockContents); 246 | 247 | const initialPluginState = state.plugins[0].getState(state) as { 248 | cache: DecorationCache; 249 | decorations: DecorationSet; 250 | }; 251 | expect(initialPluginState.decorations).not.toBe(DecorationSet.empty); 252 | 253 | // get the positions of the blocks from the cache 254 | const initialPositions = Object.keys( 255 | getCacheContents(initialPluginState.cache) 256 | ) 257 | .map((k) => +k) 258 | .sort(); 259 | 260 | // plaintext blocks don't get *any* decorations, so expect the cache to not include these 261 | expect(initialPositions).toHaveLength(blockContents.length - 1); 262 | 263 | // add a transaction that alters the doc 264 | const addedText = "asdf "; // NOTE: use nonsense text so the highlighter doesn't pick it up 265 | const tr = state.tr.insertText(addedText, initialPositions[1] + 1); 266 | state = state.apply(tr); 267 | 268 | // get the updated state and check that the positions are offset as expected and the decorations match 269 | const updatedPluginState = state.plugins[0].getState(state) as { 270 | cache: DecorationCache; 271 | decorations: DecorationSet; 272 | }; 273 | const updatedPositions = Object.keys( 274 | getCacheContents(updatedPluginState.cache) 275 | ) 276 | .map((k) => +k) 277 | .sort(); 278 | 279 | // content after this node was untouched, so the position and data hasn't changed 280 | expect(updatedPositions[0]).toBe(initialPositions[0]); 281 | expect(updatedPluginState.cache.get(updatedPositions[0])).toStrictEqual( 282 | initialPluginState.cache.get(initialPositions[0]) 283 | ); 284 | 285 | // this node was touched; the position should not have changed, but the nodes and decorations will have 286 | let initialContent = initialPluginState.cache.get(initialPositions[1]); 287 | let updatedContent = updatedPluginState.cache.get(updatedPositions[1]); 288 | expect(updatedPositions[1]).toBe(initialPositions[1]); 289 | expect(updatedContent.node).not.toStrictEqual(initialContent.node); 290 | expect(updatedContent.node.textContent).toBe( 291 | addedText + initialContent.node.textContent 292 | ); 293 | 294 | updatedContent.decorations.forEach((d, i) => { 295 | const initial = initialContent.decorations[i]; 296 | expect(d.from).toBe(initial.from + addedText.length); 297 | expect(d.to).toBe(initial.to + addedText.length); 298 | }); 299 | 300 | // this node was not touched, but its position, along with all the decorations, have been shifted forward 301 | initialContent = initialPluginState.cache.get(initialPositions[2]); 302 | updatedContent = updatedPluginState.cache.get(updatedPositions[2]); 303 | expect(updatedPositions[2]).toBe( 304 | initialPositions[2] + addedText.length 305 | ); 306 | expect(updatedContent.node).toStrictEqual(initialContent.node); 307 | 308 | updatedContent.decorations.forEach((d, i) => { 309 | const initial = initialContent.decorations[i]; 310 | expect(d.from).toBe(initial.from + addedText.length); 311 | expect(d.to).toBe(initial.to + addedText.length); 312 | }); 313 | }); 314 | 315 | it("should save autodetected languages back onto the node", () => { 316 | let state = createState(`console.log("hello world");`); 317 | 318 | // the autodetected language stuff is only set when in a view (unfortunately...) 319 | const view = new EditorView(document.createElement("div"), { 320 | state, 321 | }); 322 | 323 | const pluginState: DecorationSet = getDecorationsFromPlugin(view.state); 324 | let attrs = getNodeHighlightAttrs(view.state); 325 | 326 | // the decorations should be loaded (indicating the plugin highlighted the content) 327 | expect(pluginState).not.toBe(DecorationSet.empty); 328 | 329 | // the detected language should be set onto the node on first plugin run 330 | expect(attrs.params).toBe(""); 331 | expect(attrs.detectedHighlightLanguage).toBe("javascript"); 332 | 333 | // fire off a transaction to make sure that the value stuck 334 | state = view.state.applyTransaction(state.tr.insertText("a", 1)).state; 335 | view.updateState(state); 336 | attrs = getNodeHighlightAttrs(view.state); 337 | expect(attrs.params).toBe(""); 338 | expect(attrs.detectedHighlightLanguage).toBe("javascript"); 339 | }); 340 | }); 341 | -------------------------------------------------------------------------------- /test/sample-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { createState, createStateImpl } from "./helpers"; 2 | 3 | describe("sample-schema", () => { 4 | it.each(["", "javascript"])( 5 | "should create a schema with the proper attrs (%s) set", 6 | (language) => { 7 | const code = `console.log("hello world");`; 8 | const state = createState(code, language, false); 9 | 10 | // expect the doc to be a specific shape 11 | expect(state.doc.childCount).toBe(1); 12 | expect(state.doc.child(0).type.name).toBe("code_block"); 13 | expect(state.doc.child(0).attrs.params).toBe(language); 14 | expect(state.doc.child(0).childCount).toBe(1); 15 | expect(state.doc.child(0).child(0).isText).toBe(true); 16 | expect(state.doc.child(0).child(0).text).toBe(code); 17 | } 18 | ); 19 | 20 | it("should create multiple nodes", () => { 21 | const state = createStateImpl([ 22 | { 23 | code: `console.log("hello world");`, 24 | language: "javascript", 25 | }, 26 | { 27 | code: `Debug.Log("hello world");`, 28 | language: "csharp", 29 | }, 30 | ]); 31 | 32 | expect(state.doc.childCount).toBe(2); 33 | expect(state.doc.child(0).type.name).toBe("code_block"); 34 | expect(state.doc.child(0).attrs.params).toBe("javascript"); 35 | expect(state.doc.child(1).type.name).toBe("code_block"); 36 | expect(state.doc.child(1).attrs.params).toBe("csharp"); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../src/**/*", "./**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*", 5 | "test/**/*.ts", 6 | ] 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ESNext", 5 | "allowJs": false, 6 | "esModuleInterop": true, 7 | "moduleResolution": "Node", 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitReturns": true, 11 | "noUnusedLocals": true, 12 | "sourceMap": true, 13 | "lib": ["ESNext", "DOM"], 14 | "useDefineForClassFields": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "noUnusedParameters": true, 19 | "skipLibCheck": true 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: resolve("src/index.ts"), 9 | name: "prosemirror-highlightjs", 10 | fileName: "index", 11 | }, 12 | rollupOptions: { 13 | external: [ 14 | "prosemirror-model", 15 | "prosemirror-state", 16 | "prosemirror-view", 17 | "highlight.js", 18 | ], 19 | output: { 20 | globals: {}, 21 | }, 22 | }, 23 | }, 24 | plugins: [dts()], 25 | }); 26 | --------------------------------------------------------------------------------