Skip to content

Commit 8813b8a

Browse files
committed
perf: support parsing and caching line by line based on shiki grammar state
1 parent bcbab2d commit 8813b8a

File tree

3 files changed

+84
-27
lines changed

3 files changed

+84
-27
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 0.0.7 (2024-10-06)
2+
3+
### Perf
4+
5+
- support parsing and caching line by line based on shiki grammar state
6+
17
## 0.0.6 (2024-10-06)
28

39
### Perf

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default antfu({
1616
...zin.stylistic,
1717
...zin.vue,
1818
...zin.patch,
19-
"no-return-assign": "off",
19+
"no-multi-assign": "off",
2020
"unicorn/prefer-dom-node-text-content": "off"
2121
}
2222
});

src/index.ts

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { BundledLanguage, BundledTheme, CodeToTokensWithThemesOptions, HighlighterCore, ThemedTokenWithVariants } from "shiki";
1+
import type { BundledLanguage, BundledTheme, CodeToTokensWithThemesOptions, GrammarState, HighlighterCore, ThemedTokenWithVariants } from "shiki";
22
import { debounce } from "./utils";
33

44
export interface MountPlainShikiOptions {
@@ -40,11 +40,17 @@ export interface MountPlainShikiOptions {
4040

4141
export type CreatePlainShikiReturns = ReturnType<typeof createPlainShiki>;
4242

43-
interface ColorLoads {
43+
interface ColorLoad {
4444
token: ThemedTokenWithVariants;
4545
range: Range;
4646
}
4747

48+
interface LoadLine {
49+
text: string;
50+
lastGrammarState: GrammarState;
51+
loads: ColorLoad[];
52+
}
53+
4854
export function createPlainShiki(shiki: HighlighterCore) {
4955
const isSupported = () => "CSS" in globalThis && "highlights" in CSS;
5056

@@ -65,6 +71,7 @@ export function createPlainShiki(shiki: HighlighterCore) {
6571
const stylesheet = new CSSStyleSheet();
6672
document.adoptedStyleSheets.push(stylesheet);
6773

74+
const loadLines: LoadLine[] = [];
6875
const names = new Set<string>();
6976

7077
if (isSupported()) {
@@ -79,16 +86,28 @@ export function createPlainShiki(shiki: HighlighterCore) {
7986
document.adoptedStyleSheets.splice(idx, 1);
8087
}
8188

82-
function patch(loads: ColorLoads[]) {
83-
for (const name of names) {
84-
const highlight = CSS.highlights.get(name);
85-
highlight?.clear();
89+
function resolveName(theme: string, color: string) {
90+
return `shiki-${theme}-${color.slice(1).toLowerCase()}`;
91+
}
92+
93+
function patch(loads: ColorLoad[], oldLoads: ColorLoad[]) {
94+
for (const { token, range } of oldLoads) {
95+
for (const theme in token.variants) {
96+
const { color } = token.variants[theme];
97+
const name = resolveName(theme, color!);
98+
99+
const highlight = CSS.highlights.get(name);
100+
if (!highlight) {
101+
continue;
102+
}
103+
highlight.delete(range);
104+
}
86105
}
87106

88107
for (const { token, range } of loads) {
89108
for (const theme in token.variants) {
90109
const { color } = token.variants[theme];
91-
const name = `shiki-${theme}-${color!.slice(1).toLowerCase()}`;
110+
const name = resolveName(theme, color!);
92111
const isDefault = theme === "light";
93112

94113
let highlight = CSS.highlights.get(name);
@@ -110,6 +129,21 @@ export function createPlainShiki(shiki: HighlighterCore) {
110129
}
111130
}
112131

132+
function diff(textLines: string[]) {
133+
let i = 0;
134+
for (; i < textLines.length; i++) {
135+
if (textLines[i] !== loadLines[i]?.text) {
136+
return i;
137+
}
138+
for (const { range } of loadLines[i]?.loads ?? []) {
139+
if (range.collapsed) {
140+
return i;
141+
}
142+
}
143+
}
144+
return i;
145+
}
146+
113147
function update() {
114148
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
115149

@@ -120,33 +154,52 @@ export function createPlainShiki(shiki: HighlighterCore) {
120154
}
121155

122156
const { innerText } = el;
123-
const tokenLines = shiki.codeToTokensWithThemes(innerText, {
124-
lang,
125-
themes
126-
});
157+
const textLines = innerText.split("\n");
127158

128-
let i = 0;
159+
let i = diff(textLines);
160+
let lineOffset = textLines.slice(0, i).reduce((res, text) => res + text.length + 1, 0);
161+
162+
let j = 0;
129163
let offset = 0;
130164
let isCorrect = false;
165+
findNodeAndOffset(lineOffset);
166+
167+
for (; i < textLines.length; i++) {
168+
const text = textLines[i];
131169

132-
const loads: ColorLoads[] = [];
133-
for (const tokens of tokenLines) {
134-
for (const token of tokens) {
135-
const [startNode, startOffset] = findNodeAndOffset(token.offset);
136-
const [endNode, endOffset] = findNodeAndOffset(token.offset + token.content.length);
170+
const tokenLines = shiki.codeToTokensWithThemes(text, {
171+
lang,
172+
themes
173+
});
174+
175+
const loads: ColorLoad[] = [];
176+
for (const token of tokenLines[0]) {
177+
const [startNode, startOffset] = findNodeAndOffset(lineOffset + token.offset);
178+
const [endNode, endOffset] = findNodeAndOffset(lineOffset + token.offset + token.content.length);
137179

138180
const range = document.createRange();
139181
range.setStart(startNode, startOffset);
140182
range.setEnd(endNode, endOffset);
141-
142183
loads.push({ token, range });
143184
}
185+
186+
const loadLine = loadLines[i] ??= {} as LoadLine;
187+
loadLine.text = text;
188+
189+
patch(loads, loadLine.loads ?? []);
190+
loadLine.loads = loads;
191+
192+
const lastGrammarState = shiki.getLastGrammarState(text ?? "", { lang });
193+
if (loadLine.lastGrammarState !== lastGrammarState) {
194+
loadLine.lastGrammarState = lastGrammarState;
195+
lineOffset += text.length + 1;
196+
}
197+
else break;
144198
}
145-
patch(loads);
146199

147200
function findNodeAndOffset(tokenOffset: number) {
148-
for (; i < textNodes.length; i++) {
149-
const node = textNodes[i];
201+
for (; j < textNodes.length; j++) {
202+
const node = textNodes[j];
150203
const { textContent } = node;
151204
if (!textContent) {
152205
continue;
@@ -157,15 +210,13 @@ export function createPlainShiki(shiki: HighlighterCore) {
157210
isCorrect = true;
158211
}
159212

160-
if (offset + textContent.length >= tokenOffset) {
161-
return [node, tokenOffset - offset] as const;
162-
}
163-
else {
213+
if (offset + textContent.length < tokenOffset) {
164214
offset += textContent.length;
165215
isCorrect = false;
166216
}
217+
else break;
167218
}
168-
throw new RangeError(`[Plain Shiki] cannot find node at offset ${tokenOffset}.`);
219+
return [textNodes[j], tokenOffset - offset] as const;
169220
}
170221
}
171222

0 commit comments

Comments
 (0)