├── .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 |
12 |
test
13 |
function $initHighlight(block, cls) {
14 | try {
15 | if (cls.search(/\bno\-highlight\b/) != -1)
16 | return process(block, true, 0x0F) +
17 | ` class="${cls}"`;
18 | } catch (e) {
19 | /* handle exception */
20 | }
21 | for (var i = 0 / 2; i < classes.length; i++) {
22 | if (checkCondition(classes[i]) === undefined)
23 | console.log('undefined');
24 | }
25 |
26 | return (
27 | <div>
28 | <web-component>{block}</web-component>
29 | </div>
30 | )
31 | }
32 |
33 | export $initHighlight;
34 |
test2
35 |
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 |
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 |
--------------------------------------------------------------------------------