mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Support code block // [!code -- or ++] notation (#4248)
This commit is contained in:
@@ -114,27 +114,30 @@ function CodeHighlightLine(props: {
|
||||
withLineNumbers: boolean;
|
||||
}) {
|
||||
const { line, isLast, withLineNumbers, bg, fg } = props;
|
||||
const lineStyle = {
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={tcls('highlight-line', line.highlighted && 'highlighted')}
|
||||
style={{
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
}}
|
||||
>
|
||||
{withLineNumbers && (
|
||||
<span
|
||||
className="highlight-line-number"
|
||||
style={{
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
}}
|
||||
/>
|
||||
className={tcls(
|
||||
'highlight-line',
|
||||
line.diff === 'added' && 'diff-added',
|
||||
line.diff === 'deleted' && 'diff-deleted',
|
||||
line.highlighted && 'highlighted'
|
||||
)}
|
||||
aria-label={
|
||||
line.diff === 'added'
|
||||
? 'Added line'
|
||||
: line.diff === 'deleted'
|
||||
? 'Removed line'
|
||||
: undefined
|
||||
}
|
||||
style={lineStyle}
|
||||
>
|
||||
{withLineNumbers && <span className="highlight-line-number" style={lineStyle} />}
|
||||
<span className="highlight-line-content">
|
||||
<CodeHighlightTokens tokens={line.tokens} />
|
||||
{!isLast && '\n'}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { expect, it } from 'bun:test';
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { type RenderedInline, getInlines, highlight } from './highlight';
|
||||
import {
|
||||
type HighlightLine,
|
||||
type HighlightToken,
|
||||
type RenderedInline,
|
||||
getInlines,
|
||||
highlight,
|
||||
} from './highlight';
|
||||
|
||||
async function highlightWithInlines(block: DocumentBlockCode) {
|
||||
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
|
||||
@@ -690,6 +696,138 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
function joinLineContent(line: HighlightLine): string {
|
||||
const visit = (tokens: HighlightToken[]): string =>
|
||||
tokens
|
||||
.map((t) => {
|
||||
if (t.type === 'plain') return t.content;
|
||||
if (t.type === 'shiki') return t.token.content;
|
||||
return visit(t.children);
|
||||
})
|
||||
.join('');
|
||||
return visit(line.tokens);
|
||||
}
|
||||
|
||||
function singleLineBlock(syntax: string | undefined, text: string): DocumentBlockCode {
|
||||
return {
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: syntax ? { syntax } : {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('classifies and strips trailing // [!code ++] in JS', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('javascript', 'const a = 1 // [!code ++]')
|
||||
);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('const a = 1');
|
||||
});
|
||||
|
||||
it('classifies and strips trailing # [!code --] in Python', async () => {
|
||||
const lines = await highlightWithInlines(singleLineBlock('python', 'x = 1 # [!code --]'));
|
||||
expect(lines[0]!.diff).toBe('deleted');
|
||||
expect(joinLineContent(lines[0]!)).toBe('x = 1');
|
||||
});
|
||||
|
||||
it('classifies and strips <!-- [!code ++] --> in HTML', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('html', '<div></div> <!-- [!code ++] -->')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('<div></div>');
|
||||
});
|
||||
|
||||
it('classifies and strips /* [!code --] */ in CSS', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('css', '.a { color: red; } /* [!code --] */')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('deleted');
|
||||
expect(joinLineContent(lines[0]!)).toBe('.a { color: red; }');
|
||||
});
|
||||
|
||||
it('does not classify when marker is not at end of line', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('javascript', 'const x = 1 // [!code ++] trailing')
|
||||
);
|
||||
expect(lines[0]!.diff).toBeNull();
|
||||
expect(joinLineContent(lines[0]!)).toBe('const x = 1 // [!code ++] trailing');
|
||||
});
|
||||
|
||||
it('returns diff: null for lines without a marker', async () => {
|
||||
const lines = await highlightWithInlines(singleLineBlock('javascript', 'console.log("hi")'));
|
||||
expect(lines[0]!.diff).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies and strips marker via plainHighlighting fallback', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock(undefined, 'plain text // [!code ++]')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('plain text');
|
||||
});
|
||||
|
||||
it('preserves inline annotation when marker is stripped', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: { syntax: 'javascript' },
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'console.' }],
|
||||
},
|
||||
{
|
||||
object: 'inline',
|
||||
type: 'annotation',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'log' }],
|
||||
},
|
||||
],
|
||||
isVoid: false,
|
||||
fragments: [],
|
||||
},
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: '("Hi") // [!code ++]' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tokens[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(tokens[0]!)).toBe('console.log("Hi")');
|
||||
// inline annotation around "log" must be preserved
|
||||
const hasAnnotation = tokens[0]!.tokens.some(
|
||||
(t) =>
|
||||
t.type === 'annotation' &&
|
||||
t.children.some((c) => c.type === 'shiki' && c.token.content === 'log')
|
||||
);
|
||||
expect(hasAnnotation).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle \\r', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
|
||||
@@ -34,11 +34,114 @@ export type HighlightTheme = {
|
||||
lines: HighlightLine[];
|
||||
};
|
||||
|
||||
export type LineDiffNotation = 'added' | 'deleted';
|
||||
|
||||
export type HighlightLine = {
|
||||
highlighted: boolean;
|
||||
diff: LineDiffNotation | null;
|
||||
tokens: HighlightToken[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects an in-source diff notation marker at the end of a line, e.g.
|
||||
* `// [!code ++]`, `# [!code --]`, `<!-- [!code ++] -->`, `/* [!code --] */`.
|
||||
* Mirror of the gitbook-x parser; keep regex byte-for-byte identical.
|
||||
*/
|
||||
const NOTATION_PATTERN =
|
||||
/[ \t]*(?:(?:\/\/|#|--|;)\s*\[!code\s+(\+\+|--)\]|<!--\s*\[!code\s+(\+\+|--)\]\s*-->|\/\*\s*\[!code\s+(\+\+|--)\]\s*\*\/)\s*$/;
|
||||
|
||||
export function parseDiffNotation(
|
||||
line: string
|
||||
): { diff: LineDiffNotation; markerStart: number } | null {
|
||||
const match = NOTATION_PATTERN.exec(line);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const variant = match[1] ?? match[2] ?? match[3];
|
||||
return {
|
||||
diff: variant === '++' ? 'added' : 'deleted',
|
||||
markerStart: match.index,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a sequence of HighlightTokens so only the first `maxLen` characters
|
||||
* (counted across all tokens, recursing into annotations) remain. Used to strip
|
||||
* trailing diff-notation markers from rendered output.
|
||||
*/
|
||||
export function truncateHighlightTokens(
|
||||
tokens: HighlightToken[],
|
||||
maxLen: number
|
||||
): HighlightToken[] {
|
||||
const out: HighlightToken[] = [];
|
||||
let remaining = maxLen;
|
||||
for (const token of tokens) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
const len = highlightTokenLength(token);
|
||||
if (len <= remaining) {
|
||||
out.push(token);
|
||||
remaining -= len;
|
||||
continue;
|
||||
}
|
||||
out.push(sliceHighlightToken(token, remaining));
|
||||
remaining = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function highlightTokenLength(token: HighlightToken): number {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return token.content.length;
|
||||
case 'shiki':
|
||||
return token.token.content.length;
|
||||
case 'annotation':
|
||||
return token.children.reduce((acc, child) => acc + highlightTokenLength(child), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate the text content of a sequence of HighlightTokens, recursing
|
||||
* into annotation children. Mirror of {@link highlightTokenLength}.
|
||||
*/
|
||||
export function getHighlightTokensText(tokens: HighlightToken[]): string {
|
||||
return tokens
|
||||
.map((token) => {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return token.content;
|
||||
case 'shiki':
|
||||
return token.token.content;
|
||||
case 'annotation':
|
||||
return getHighlightTokensText(token.children);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function sliceHighlightToken(token: HighlightToken, maxLen: number): HighlightToken {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return { type: 'plain', content: token.content.slice(0, maxLen) };
|
||||
case 'shiki': {
|
||||
const inner = token.token as ThemedToken & { start?: number; end?: number };
|
||||
const newContent = inner.content.slice(0, maxLen);
|
||||
const newToken: ThemedToken & { start?: number; end?: number } = {
|
||||
...inner,
|
||||
content: newContent,
|
||||
};
|
||||
if (typeof inner.start === 'number') {
|
||||
newToken.end = inner.start + newContent.length;
|
||||
}
|
||||
return { type: 'shiki', token: newToken };
|
||||
}
|
||||
case 'annotation':
|
||||
return { ...token, children: truncateHighlightTokens(token.children, maxLen) };
|
||||
}
|
||||
}
|
||||
|
||||
export type HighlightToken =
|
||||
| { type: 'plain'; content: string }
|
||||
| { type: 'shiki'; token: ThemedToken }
|
||||
@@ -136,6 +239,9 @@ export async function highlight(
|
||||
const lineBlock = block.nodes[index];
|
||||
const result: HighlightToken[] = [];
|
||||
|
||||
const lineText = tokens.map((token) => token.content).join('');
|
||||
const notation = parseDiffNotation(lineText);
|
||||
|
||||
const eatToken = (): PositionedToken | null => {
|
||||
const token = tokens.shift();
|
||||
if (token) {
|
||||
@@ -152,9 +258,14 @@ export async function highlight(
|
||||
|
||||
currentIndex += 1; // for the \n
|
||||
|
||||
const finalTokens = notation
|
||||
? truncateHighlightTokens(result, notation.markerStart)
|
||||
: result;
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock?.data.highlighted),
|
||||
tokens: result,
|
||||
diff: notation?.diff ?? null,
|
||||
tokens: finalTokens,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/a
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { bundledThemesInfo } from 'shiki/themes';
|
||||
import { customThemes } from './customThemes';
|
||||
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
|
||||
import {
|
||||
type HighlightTheme,
|
||||
type HighlightToken,
|
||||
type RenderedInline,
|
||||
getHighlightTokensText,
|
||||
parseDiffNotation,
|
||||
truncateHighlightTokens,
|
||||
} from './highlight';
|
||||
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
@@ -62,9 +69,14 @@ export function plainHighlight(
|
||||
};
|
||||
});
|
||||
|
||||
// Detect diff notation against the built tokens (not the raw nodes)
|
||||
// so any evaluated inline expressions are included in the offset math.
|
||||
const notation = parseDiffNotation(getHighlightTokensText(tokens));
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock.data.highlighted),
|
||||
tokens,
|
||||
diff: notation?.diff ?? null,
|
||||
tokens: notation ? truncateHighlightTokens(tokens, notation.markerStart) : tokens,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -345,6 +345,51 @@ html.dark .shiki span {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Diff lines (`// [!code ++]` / `// [!code --]` notation).
|
||||
*
|
||||
* Colors mirror the gitbook-x editor exactly so the published page matches
|
||||
* what writers see while editing (see gitbook-x's spine-css shades: --green-3,
|
||||
* --green-5, --red-3, --red-5).
|
||||
*
|
||||
* Rules are defined at top-level (not nested) and use `!important` to win
|
||||
* against the themed `html.theme-muted .highlight-line` variants generated by
|
||||
* the @apply directives above. The combined `.diff-*.highlighted` state also
|
||||
* resets `filter: none` to cancel the `.highlighted` invert-10 that would
|
||||
* otherwise muddy the tint.
|
||||
*/
|
||||
.highlight-line.diff-added {
|
||||
background-color: #e1f8ec !important; /* gbx --green-3 (light) */
|
||||
@apply rounded-md;
|
||||
}
|
||||
.highlight-line.diff-deleted {
|
||||
background-color: #ffeae6 !important; /* gbx --red-3 (light) */
|
||||
@apply rounded-md;
|
||||
}
|
||||
.highlight-line.diff-added.highlighted {
|
||||
background-color: #baebd3 !important; /* gbx --green-5 (light) */
|
||||
filter: none !important;
|
||||
}
|
||||
.highlight-line.diff-deleted.highlighted {
|
||||
background-color: #ffcac0 !important; /* gbx --red-5 (light) */
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
html.dark .highlight-line.diff-added {
|
||||
background-color: #113023 !important; /* gbx --green-3 (dark) */
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted {
|
||||
background-color: #411510 !important; /* gbx --red-3 (dark) */
|
||||
}
|
||||
html.dark .highlight-line.diff-added.highlighted {
|
||||
background-color: #064b34 !important; /* gbx --green-5 (dark) */
|
||||
filter: none !important;
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted.highlighted {
|
||||
background-color: #67100a !important; /* gbx --red-5 (dark) */
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.highlight-line-number {
|
||||
@apply table-cell whitespace-nowrap w-0 text-sm text-tint pl-4 pr-2 text-right bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base sticky -left-2;
|
||||
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
|
||||
@@ -354,6 +399,61 @@ html.dark .shiki span {
|
||||
}
|
||||
}
|
||||
|
||||
/* Diff line gutter (line numbers) — same tint as the line itself. */
|
||||
.diff-added > .highlight-line-number {
|
||||
background-color: #e1f8ec !important;
|
||||
}
|
||||
.diff-deleted > .highlight-line-number {
|
||||
background-color: #ffeae6 !important;
|
||||
}
|
||||
.diff-added.highlighted > .highlight-line-number {
|
||||
background-color: #baebd3 !important;
|
||||
}
|
||||
.diff-deleted.highlighted > .highlight-line-number {
|
||||
background-color: #ffcac0 !important;
|
||||
}
|
||||
html.dark .diff-added > .highlight-line-number {
|
||||
background-color: #113023 !important;
|
||||
}
|
||||
html.dark .diff-deleted > .highlight-line-number {
|
||||
background-color: #411510 !important;
|
||||
}
|
||||
html.dark .diff-added.highlighted > .highlight-line-number {
|
||||
background-color: #064b34 !important;
|
||||
}
|
||||
html.dark .diff-deleted.highlighted > .highlight-line-number {
|
||||
background-color: #67100a !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* +/- prefix on diff lines so the state is conveyed by more than color (WCAG
|
||||
* 1.4.1). The shift this introduces on diff lines vs. non-diff lines is itself
|
||||
* a redundant visual cue — supports color-blind users twice over. `user-select:
|
||||
* none` keeps the marker out of copy-to-clipboard.
|
||||
*/
|
||||
.highlight-line.diff-added .highlight-line-content::before,
|
||||
.highlight-line.diff-deleted .highlight-line-content::before {
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
margin-right: 0.5ch;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.highlight-line.diff-added .highlight-line-content::before {
|
||||
content: "+";
|
||||
color: #247758; /* gbx --green-11 light */
|
||||
}
|
||||
.highlight-line.diff-deleted .highlight-line-content::before {
|
||||
content: "\2212"; /* U+2212 minus sign — visually clearer than a hyphen */
|
||||
color: #de0000; /* gbx --red-11 light */
|
||||
}
|
||||
html.dark .highlight-line.diff-added .highlight-line-content::before {
|
||||
color: #6ecea5; /* gbx --green-11 dark */
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted .highlight-line-content::before {
|
||||
color: #ff9080; /* gbx --red-11 dark */
|
||||
}
|
||||
|
||||
|
||||
.highlight-line-content {
|
||||
@apply table-cell text-sm px-4;
|
||||
|
||||
Reference in New Issue
Block a user