diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx index 86424a706..80447664a 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/CodeBlockRenderer.tsx @@ -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 ( - {withLineNumbers && ( - + 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 && } {!isLast && '\n'} diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts index aedd7d182..31fbafd11 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.test.ts @@ -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 in HTML', async () => { + const lines = await highlightWithInlines( + singleLineBlock('html', '
') + ); + expect(lines[0]!.diff).toBe('added'); + expect(joinLineContent(lines[0]!)).toBe('
'); +}); + +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', diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts index f431c1133..965704bc7 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/highlight.ts @@ -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 --] *​/`. + * Mirror of the gitbook-x parser; keep regex byte-for-byte identical. + */ +const NOTATION_PATTERN = + /[ \t]*(?:(?:\/\/|#|--|;)\s*\[!code\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, }; }), }; diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts index 78621c599..00212c8e4 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/plain-highlight.ts @@ -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, }; }), }; diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css index de9409c77..7928c23cf 100644 --- a/packages/gitbook/src/components/RootLayout/globals.css +++ b/packages/gitbook/src/components/RootLayout/globals.css @@ -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;