1import Prism, { LanguageMap } from 'prismjs';
2import { Block, Text, Decoration } from 'slate';
3import { Plugin } from '@grafana/slate-react';
4import Options, { OptionsFormat } from './options';
5import TOKEN_MARK from './TOKEN_MARK';
6
7export interface Token {
8  content: string;
9  offsets?: {
10    start: number;
11    end: number;
12  };
13  types: string[];
14  aliases: string[];
15  prev?: Token | null;
16  next?: Token | null;
17}
18
19/**
20 * A Slate plugin to highlight code syntax.
21 */
22export function SlatePrism(optsParam: OptionsFormat = {}, prismLanguages = Prism.languages as LanguageMap): Plugin {
23  const opts: Options = new Options(optsParam);
24
25  return {
26    decorateNode: (node, editor, next) => {
27      if (!opts.onlyIn(node)) {
28        return next();
29      }
30
31      const block = Block.create(node as Block);
32      const grammarName = opts.getSyntax(block);
33      const grammar = prismLanguages[grammarName];
34
35      if (!grammar) {
36        // Grammar not loaded
37        return [];
38      }
39
40      // Tokenize the whole block text
41      const texts = block.getTexts();
42      const blockText = texts.map((text) => text && text.getText()).join('\n');
43      const tokens = Prism.tokenize(blockText, grammar);
44      const flattened = flattenTokens(tokens);
45
46      const newData = editor.value.data.set('tokens', flattened);
47      editor.setData(newData);
48      return decorateNode(opts, tokens, block);
49    },
50
51    renderDecoration: (props, editor, next) =>
52      opts.renderDecoration(
53        {
54          children: props.children,
55          decoration: props.decoration,
56        },
57        editor as any,
58        next
59      ),
60  };
61}
62
63/**
64 * Returns the decoration for a node
65 */
66function decorateNode(opts: Options, tokens: Array<string | Prism.Token>, block: Block) {
67  const texts = block.getTexts();
68
69  // The list of decorations to return
70  const decorations: Decoration[] = [];
71  let textStart = 0;
72  let textEnd = 0;
73
74  texts.forEach((text) => {
75    textEnd = textStart + text!.getText().length;
76
77    let offset = 0;
78    function processToken(token: string | Prism.Token, accu?: string | number) {
79      if (typeof token === 'string') {
80        if (accu) {
81          const decoration = createDecoration({
82            text: text!,
83            textStart,
84            textEnd,
85            start: offset,
86            end: offset + token.length,
87            className: `prism-token token ${accu}`,
88            block,
89          });
90
91          if (decoration) {
92            decorations.push(decoration);
93          }
94        }
95        offset += token.length;
96      } else {
97        accu = `${accu} ${token.type}`;
98        if (token.alias) {
99          accu += ' ' + token.alias;
100        }
101
102        if (typeof token.content === 'string') {
103          const decoration = createDecoration({
104            text: text!,
105            textStart,
106            textEnd,
107            start: offset,
108            end: offset + token.content.length,
109            className: `prism-token token ${accu}`,
110            block,
111          });
112
113          if (decoration) {
114            decorations.push(decoration);
115          }
116
117          offset += token.content.length;
118        } else {
119          // When using token.content instead of token.matchedStr, token can be deep
120          for (let i = 0; i < token.content.length; i += 1) {
121            // @ts-ignore
122            processToken(token.content[i], accu);
123          }
124        }
125      }
126    }
127
128    tokens.forEach(processToken);
129    textStart = textEnd + 1; // account for added `\n`
130  });
131
132  return decorations;
133}
134
135/**
136 * Return a decoration range for the given text.
137 */
138function createDecoration({
139  text,
140  textStart,
141  textEnd,
142  start,
143  end,
144  className,
145  block,
146}: {
147  text: Text; // The text being decorated
148  textStart: number; // Its start position in the whole text
149  textEnd: number; // Its end position in the whole text
150  start: number; // The position in the whole text where the token starts
151  end: number; // The position in the whole text where the token ends
152  className: string; // The prism token classname
153  block: Block;
154}): Decoration | null {
155  if (start >= textEnd || end <= textStart) {
156    // Ignore, the token is not in the text
157    return null;
158  }
159
160  // Shrink to this text boundaries
161  start = Math.max(start, textStart);
162  end = Math.min(end, textEnd);
163
164  // Now shift offsets to be relative to this text
165  start -= textStart;
166  end -= textStart;
167
168  const myDec = block.createDecoration({
169    object: 'decoration',
170    anchor: {
171      key: text.key,
172      offset: start,
173      object: 'point',
174    },
175    focus: {
176      key: text.key,
177      offset: end,
178      object: 'point',
179    },
180    type: TOKEN_MARK,
181    data: { className },
182  });
183
184  return myDec;
185}
186
187function flattenToken(token: string | Prism.Token | Array<string | Prism.Token>): Token[] {
188  if (typeof token === 'string') {
189    return [
190      {
191        content: token,
192        types: [],
193        aliases: [],
194      },
195    ];
196  } else if (Array.isArray(token)) {
197    return token.flatMap((t) => flattenToken(t));
198  } else if (token instanceof Prism.Token) {
199    return flattenToken(token.content).flatMap((t) => {
200      let aliases: string[] = [];
201      if (typeof token.alias === 'string') {
202        aliases = [token.alias];
203      } else {
204        aliases = token.alias ?? [];
205      }
206
207      return {
208        content: t.content,
209        types: [token.type, ...t.types],
210        aliases: [...aliases, ...t.aliases],
211      };
212    });
213  }
214
215  return [];
216}
217
218export function flattenTokens(token: string | Prism.Token | Array<string | Prism.Token>) {
219  const tokens = flattenToken(token);
220
221  if (!tokens.length) {
222    return [];
223  }
224
225  const firstToken = tokens[0];
226  firstToken.prev = null;
227  firstToken.next = tokens.length >= 2 ? tokens[1] : null;
228  firstToken.offsets = {
229    start: 0,
230    end: firstToken.content.length,
231  };
232
233  for (let i = 1; i < tokens.length - 1; i++) {
234    tokens[i].prev = tokens[i - 1];
235    tokens[i].next = tokens[i + 1];
236
237    tokens[i].offsets = {
238      start: tokens[i - 1].offsets!.end,
239      end: tokens[i - 1].offsets!.end + tokens[i].content.length,
240    };
241  }
242
243  const lastToken = tokens[tokens.length - 1];
244  lastToken.prev = tokens.length >= 2 ? tokens[tokens.length - 2] : null;
245  lastToken.next = null;
246  lastToken.offsets = {
247    start: tokens.length >= 2 ? tokens[tokens.length - 2].offsets!.end : 0,
248    end:
249      tokens.length >= 2 ? tokens[tokens.length - 2].offsets!.end + lastToken.content.length : lastToken.content.length,
250  };
251
252  return tokens;
253}
254