Skip to content

Commit d994875

Browse files
Support code block // [!code -- or ++] notation (#4248)
1 parent a9b5521 commit d994875

5 files changed

Lines changed: 386 additions & 22 deletions

File tree

packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -114,27 +114,30 @@ function CodeHighlightLine(props: {
114114
withLineNumbers: boolean;
115115
}) {
116116
const { line, isLast, withLineNumbers, bg, fg } = props;
117+
const lineStyle = {
118+
color: fg?.color,
119+
...fg?.vars,
120+
backgroundColor: bg?.color,
121+
...bg?.vars,
122+
};
117123
return (
118124
<span
119-
className={tcls('highlight-line', line.highlighted && 'highlighted')}
120-
style={{
121-
color: fg?.color,
122-
...fg?.vars,
123-
backgroundColor: bg?.color,
124-
...bg?.vars,
125-
}}
126-
>
127-
{withLineNumbers && (
128-
<span
129-
className="highlight-line-number"
130-
style={{
131-
color: fg?.color,
132-
...fg?.vars,
133-
backgroundColor: bg?.color,
134-
...bg?.vars,
135-
}}
136-
/>
125+
className={tcls(
126+
'highlight-line',
127+
line.diff === 'added' && 'diff-added',
128+
line.diff === 'deleted' && 'diff-deleted',
129+
line.highlighted && 'highlighted'
137130
)}
131+
aria-label={
132+
line.diff === 'added'
133+
? 'Added line'
134+
: line.diff === 'deleted'
135+
? 'Removed line'
136+
: undefined
137+
}
138+
style={lineStyle}
139+
>
140+
{withLineNumbers && <span className="highlight-line-number" style={lineStyle} />}
138141
<span className="highlight-line-content">
139142
<CodeHighlightTokens tokens={line.tokens} />
140143
{!isLast && '\n'}

packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { expect, it } from 'bun:test';
22
import type { DocumentBlockCode } from '@gitbook/api';
33

4-
import { type RenderedInline, getInlines, highlight } from './highlight';
4+
import {
5+
type HighlightLine,
6+
type HighlightToken,
7+
type RenderedInline,
8+
getInlines,
9+
highlight,
10+
} from './highlight';
511

612
async function highlightWithInlines(block: DocumentBlockCode) {
713
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
@@ -690,6 +696,138 @@ it('should support multiple code tokens in an annotation', async () => {
690696
]);
691697
});
692698

699+
function joinLineContent(line: HighlightLine): string {
700+
const visit = (tokens: HighlightToken[]): string =>
701+
tokens
702+
.map((t) => {
703+
if (t.type === 'plain') return t.content;
704+
if (t.type === 'shiki') return t.token.content;
705+
return visit(t.children);
706+
})
707+
.join('');
708+
return visit(line.tokens);
709+
}
710+
711+
function singleLineBlock(syntax: string | undefined, text: string): DocumentBlockCode {
712+
return {
713+
object: 'block',
714+
type: 'code',
715+
data: syntax ? { syntax } : {},
716+
nodes: [
717+
{
718+
object: 'block',
719+
type: 'code-line',
720+
data: {},
721+
nodes: [
722+
{
723+
object: 'text',
724+
leaves: [{ object: 'leaf', marks: [], text }],
725+
},
726+
],
727+
},
728+
],
729+
};
730+
}
731+
732+
it('classifies and strips trailing // [!code ++] in JS', async () => {
733+
const lines = await highlightWithInlines(
734+
singleLineBlock('javascript', 'const a = 1 // [!code ++]')
735+
);
736+
expect(lines).toHaveLength(1);
737+
expect(lines[0]!.diff).toBe('added');
738+
expect(joinLineContent(lines[0]!)).toBe('const a = 1');
739+
});
740+
741+
it('classifies and strips trailing # [!code --] in Python', async () => {
742+
const lines = await highlightWithInlines(singleLineBlock('python', 'x = 1 # [!code --]'));
743+
expect(lines[0]!.diff).toBe('deleted');
744+
expect(joinLineContent(lines[0]!)).toBe('x = 1');
745+
});
746+
747+
it('classifies and strips <!-- [!code ++] --> in HTML', async () => {
748+
const lines = await highlightWithInlines(
749+
singleLineBlock('html', '<div></div> <!-- [!code ++] -->')
750+
);
751+
expect(lines[0]!.diff).toBe('added');
752+
expect(joinLineContent(lines[0]!)).toBe('<div></div>');
753+
});
754+
755+
it('classifies and strips /* [!code --] */ in CSS', async () => {
756+
const lines = await highlightWithInlines(
757+
singleLineBlock('css', '.a { color: red; } /* [!code --] */')
758+
);
759+
expect(lines[0]!.diff).toBe('deleted');
760+
expect(joinLineContent(lines[0]!)).toBe('.a { color: red; }');
761+
});
762+
763+
it('does not classify when marker is not at end of line', async () => {
764+
const lines = await highlightWithInlines(
765+
singleLineBlock('javascript', 'const x = 1 // [!code ++] trailing')
766+
);
767+
expect(lines[0]!.diff).toBeNull();
768+
expect(joinLineContent(lines[0]!)).toBe('const x = 1 // [!code ++] trailing');
769+
});
770+
771+
it('returns diff: null for lines without a marker', async () => {
772+
const lines = await highlightWithInlines(singleLineBlock('javascript', 'console.log("hi")'));
773+
expect(lines[0]!.diff).toBeNull();
774+
});
775+
776+
it('classifies and strips marker via plainHighlighting fallback', async () => {
777+
const lines = await highlightWithInlines(
778+
singleLineBlock(undefined, 'plain text // [!code ++]')
779+
);
780+
expect(lines[0]!.diff).toBe('added');
781+
expect(joinLineContent(lines[0]!)).toBe('plain text');
782+
});
783+
784+
it('preserves inline annotation when marker is stripped', async () => {
785+
const tokens = await highlightWithInlines({
786+
object: 'block',
787+
type: 'code',
788+
data: { syntax: 'javascript' },
789+
nodes: [
790+
{
791+
object: 'block',
792+
type: 'code-line',
793+
data: {},
794+
nodes: [
795+
{
796+
object: 'text',
797+
leaves: [{ object: 'leaf', marks: [], text: 'console.' }],
798+
},
799+
{
800+
object: 'inline',
801+
type: 'annotation',
802+
nodes: [
803+
{
804+
object: 'text',
805+
leaves: [{ object: 'leaf', marks: [], text: 'log' }],
806+
},
807+
],
808+
isVoid: false,
809+
fragments: [],
810+
},
811+
{
812+
object: 'text',
813+
leaves: [{ object: 'leaf', marks: [], text: '("Hi") // [!code ++]' }],
814+
},
815+
],
816+
},
817+
],
818+
});
819+
820+
expect(tokens[0]!.diff).toBe('added');
821+
expect(joinLineContent(tokens[0]!)).toBe('console.log("Hi")');
822+
// inline annotation around "log" must be preserved
823+
const hasAnnotation = tokens[0]!.tokens.some(
824+
(t) =>
825+
t.type === 'annotation' &&
826+
t.children.some((c) => c.type === 'shiki' && c.token.content === 'log')
827+
);
828+
expect(hasAnnotation).toBe(true);
829+
});
830+
693831
it('should handle \\r', async () => {
694832
const tokens = await highlightWithInlines({
695833
object: 'block',

packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,114 @@ export type HighlightTheme = {
3434
lines: HighlightLine[];
3535
};
3636

37+
export type LineDiffNotation = 'added' | 'deleted';
38+
3739
export type HighlightLine = {
3840
highlighted: boolean;
41+
diff: LineDiffNotation | null;
3942
tokens: HighlightToken[];
4043
};
4144

45+
/**
46+
* Detects an in-source diff notation marker at the end of a line, e.g.
47+
* `// [!code ++]`, `# [!code --]`, `<!-- [!code ++] -->`, `/* [!code --] *​/`.
48+
* Mirror of the gitbook-x parser; keep regex byte-for-byte identical.
49+
*/
50+
const NOTATION_PATTERN =
51+
/[ \t]*(?:(?:\/\/|#|--|;)\s*\[!code\s+(\+\+|--)\]|<!--\s*\[!code\s+(\+\+|--)\]\s*-->|\/\*\s*\[!code\s+(\+\+|--)\]\s*\*\/)\s*$/;
52+
53+
export function parseDiffNotation(
54+
line: string
55+
): { diff: LineDiffNotation; markerStart: number } | null {
56+
const match = NOTATION_PATTERN.exec(line);
57+
if (!match) {
58+
return null;
59+
}
60+
const variant = match[1] ?? match[2] ?? match[3];
61+
return {
62+
diff: variant === '++' ? 'added' : 'deleted',
63+
markerStart: match.index,
64+
};
65+
}
66+
67+
/**
68+
* Truncate a sequence of HighlightTokens so only the first `maxLen` characters
69+
* (counted across all tokens, recursing into annotations) remain. Used to strip
70+
* trailing diff-notation markers from rendered output.
71+
*/
72+
export function truncateHighlightTokens(
73+
tokens: HighlightToken[],
74+
maxLen: number
75+
): HighlightToken[] {
76+
const out: HighlightToken[] = [];
77+
let remaining = maxLen;
78+
for (const token of tokens) {
79+
if (remaining <= 0) {
80+
break;
81+
}
82+
const len = highlightTokenLength(token);
83+
if (len <= remaining) {
84+
out.push(token);
85+
remaining -= len;
86+
continue;
87+
}
88+
out.push(sliceHighlightToken(token, remaining));
89+
remaining = 0;
90+
}
91+
return out;
92+
}
93+
94+
function highlightTokenLength(token: HighlightToken): number {
95+
switch (token.type) {
96+
case 'plain':
97+
return token.content.length;
98+
case 'shiki':
99+
return token.token.content.length;
100+
case 'annotation':
101+
return token.children.reduce((acc, child) => acc + highlightTokenLength(child), 0);
102+
}
103+
}
104+
105+
/**
106+
* Concatenate the text content of a sequence of HighlightTokens, recursing
107+
* into annotation children. Mirror of {@link highlightTokenLength}.
108+
*/
109+
export function getHighlightTokensText(tokens: HighlightToken[]): string {
110+
return tokens
111+
.map((token) => {
112+
switch (token.type) {
113+
case 'plain':
114+
return token.content;
115+
case 'shiki':
116+
return token.token.content;
117+
case 'annotation':
118+
return getHighlightTokensText(token.children);
119+
}
120+
})
121+
.join('');
122+
}
123+
124+
function sliceHighlightToken(token: HighlightToken, maxLen: number): HighlightToken {
125+
switch (token.type) {
126+
case 'plain':
127+
return { type: 'plain', content: token.content.slice(0, maxLen) };
128+
case 'shiki': {
129+
const inner = token.token as ThemedToken & { start?: number; end?: number };
130+
const newContent = inner.content.slice(0, maxLen);
131+
const newToken: ThemedToken & { start?: number; end?: number } = {
132+
...inner,
133+
content: newContent,
134+
};
135+
if (typeof inner.start === 'number') {
136+
newToken.end = inner.start + newContent.length;
137+
}
138+
return { type: 'shiki', token: newToken };
139+
}
140+
case 'annotation':
141+
return { ...token, children: truncateHighlightTokens(token.children, maxLen) };
142+
}
143+
}
144+
42145
export type HighlightToken =
43146
| { type: 'plain'; content: string }
44147
| { type: 'shiki'; token: ThemedToken }
@@ -136,6 +239,9 @@ export async function highlight(
136239
const lineBlock = block.nodes[index];
137240
const result: HighlightToken[] = [];
138241

242+
const lineText = tokens.map((token) => token.content).join('');
243+
const notation = parseDiffNotation(lineText);
244+
139245
const eatToken = (): PositionedToken | null => {
140246
const token = tokens.shift();
141247
if (token) {
@@ -152,9 +258,14 @@ export async function highlight(
152258

153259
currentIndex += 1; // for the \n
154260

261+
const finalTokens = notation
262+
? truncateHighlightTokens(result, notation.markerStart)
263+
: result;
264+
155265
return {
156266
highlighted: Boolean(lineBlock?.data.highlighted),
157-
tokens: result,
267+
diff: notation?.diff ?? null,
268+
tokens: finalTokens,
158269
};
159270
}),
160271
};

packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/a
33
import { getNodeText } from '@/lib/document';
44
import { bundledThemesInfo } from 'shiki/themes';
55
import { customThemes } from './customThemes';
6-
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
6+
import {
7+
type HighlightTheme,
8+
type HighlightToken,
9+
type RenderedInline,
10+
getHighlightTokensText,
11+
parseDiffNotation,
12+
truncateHighlightTokens,
13+
} from './highlight';
714

815
/**
916
* Parse a code block without highlighting it.
@@ -62,9 +69,14 @@ export function plainHighlight(
6269
};
6370
});
6471

72+
// Detect diff notation against the built tokens (not the raw nodes)
73+
// so any evaluated inline expressions are included in the offset math.
74+
const notation = parseDiffNotation(getHighlightTokensText(tokens));
75+
6576
return {
6677
highlighted: Boolean(lineBlock.data.highlighted),
67-
tokens,
78+
diff: notation?.diff ?? null,
79+
tokens: notation ? truncateHighlightTokens(tokens, notation.markerStart) : tokens,
6880
};
6981
}),
7082
};

0 commit comments

Comments
 (0)