Skip to content

Commit 73ed44e

Browse files
authored
Add highlighting support to code blocks (#1654)
* Add highlighting support to code blocks * BR tag support * Add screen reader support * Refactor highlight parsing logic to eliminate extra blank lines from highlighted code. Update tests to ensure correct handling of highlight markers and improve clipboard copy functionality. * Add more docs * Type fixes * Update snapshots * Update syntax
1 parent 4f00825 commit 73ed44e

17 files changed

Lines changed: 1121 additions & 36 deletions

File tree

services/main-frontend/src/components/course-material/ContentRenderer/core/formatting/CodeBlock/CopyButton.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ const ICON_COLORS = {
2626
ERROR: baseTheme.colors.red[300],
2727
} as const
2828

29+
/** Plain code string to copy; uses newlines (CodeBlock passes cleanCode; br in source is already normalized). */
2930
interface CopyButtonProps {
3031
content: string
3132
}
3233

3334
const buttonStyles = css`
3435
position: absolute;
35-
top: 10px;
36-
right: 10px;
36+
top: 26px;
37+
right: 26px;
3738
background: transparent;
3839
border: none;
3940
cursor: pointer;
@@ -56,11 +57,11 @@ const buttonStyles = css`
5657
color: ${ICON_COLORS.ERROR};
5758
}
5859
&:hover:not([data-status="default"]) {
59-
background-color: ${baseTheme.colors.gray[600]};
60+
background-color: rgba(255, 255, 255, 0.08);
6061
}
6162
&:hover[data-status="default"] {
6263
transform: scale(1.1);
63-
background-color: ${baseTheme.colors.gray[600]};
64+
background-color: rgba(255, 255, 255, 0.08);
6465
}
6566
`
6667

@@ -107,7 +108,7 @@ const AnimatedDiv = animated.div as React.FC<{
107108
}>
108109

109110
/**
110-
* Button component that copies text to clipboard.
111+
* Copies the given code string to clipboard. Content is expected to use newlines (upstream handles <br> → \n; escaped br stays literal).
111112
* Shows success/error state for 2 seconds after copy attempt.
112113
*/
113114
export const CopyButton: React.FC<CopyButtonProps> = ({ content }) => {

services/main-frontend/src/components/course-material/ContentRenderer/core/formatting/CodeBlock/SyntaxHighlightedContainer.tsx

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,66 @@
33
import "highlight.js/styles/atom-one-dark.css"
44
import { css } from "@emotion/css"
55
import hljs from "highlight.js"
6-
import { memo, useEffect, useMemo, useRef } from "react"
6+
import { memo, useEffect, useRef } from "react"
77

8-
import { replaceBrTagsWithNewlines } from "./utils"
8+
import { ensureLineHighlightPluginRegistered } from "./lineHighlightPlugin"
99

1010
import { sanitizeCourseMaterialHtml } from "@/utils/course-material/sanitizeCourseMaterialHtml"
1111

12+
ensureLineHighlightPluginRegistered(hljs)
13+
1214
interface SyntaxHighlightedContainerProps {
1315
content: string | undefined
16+
highlightedLines?: Set<number>
1417
}
1518

19+
const codeBlockStyles = css`
20+
background-color: #1a2333;
21+
border-radius: 4px;
22+
font-variant-ligatures: none;
23+
font-feature-settings: "liga" 0;
24+
.code-line {
25+
display: block;
26+
}
27+
.highlighted-line {
28+
background-color: rgba(255, 255, 100, 0.1);
29+
margin: 0 -16px;
30+
padding: 0 16px 0 13px;
31+
border-left: 3px solid #ffd700;
32+
}
33+
`
34+
1635
/**
17-
* Renders code with syntax highlighting using highlight.js.
36+
* Renders code with syntax highlighting using highlight.js. Optionally wraps lines and highlights specific lines.
37+
* Receives content that already uses newlines (CodeBlock normalizes `<br>` to `\n` upstream; escaped br stays literal).
1838
*/
19-
const SyntaxHighlightedContainer: React.FC<SyntaxHighlightedContainerProps> = ({ content }) => {
39+
const SyntaxHighlightedContainer: React.FC<SyntaxHighlightedContainerProps> = ({
40+
content,
41+
highlightedLines,
42+
}) => {
2043
const ref = useRef<HTMLElement>(null)
2144

22-
const replacedContent = useMemo(() => {
23-
return replaceBrTagsWithNewlines(content) ?? ""
24-
}, [content])
25-
2645
useEffect(() => {
2746
if (!ref.current) {
2847
return
2948
}
49+
50+
delete ref.current.dataset.hljsLineWrapped
51+
delete ref.current.dataset.highlighted
52+
if (highlightedLines && highlightedLines.size > 0) {
53+
ref.current.dataset.highlightLines = Array.from(highlightedLines)
54+
.sort((a, b) => a - b)
55+
.join(",")
56+
} else {
57+
delete ref.current.dataset.highlightLines
58+
}
59+
60+
// Sanitization is the source of truth for HTML safety; highlight.js does not preserve arbitrary HTML.
61+
ref.current.innerHTML = sanitizeCourseMaterialHtml(content ?? "")
3062
hljs.highlightElement(ref.current)
31-
}, [ref])
32-
33-
return (
34-
<code
35-
className={css`
36-
background-color: #1a2333;
37-
border-radius: 4px;
38-
font-variant-ligatures: none;
39-
font-feature-settings: "liga" 0;
40-
`}
41-
ref={ref}
42-
dangerouslySetInnerHTML={{ __html: sanitizeCourseMaterialHtml(replacedContent) }}
43-
/>
44-
)
63+
}, [content, highlightedLines])
64+
65+
return <code className={codeBlockStyles} ref={ref} />
4566
}
4667

4768
export default memo(SyntaxHighlightedContainer)

services/main-frontend/src/components/course-material/ContentRenderer/core/formatting/CodeBlock/__tests__/CodeBlock.test.tsx

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"use client"
22

3-
import { render } from "@testing-library/react"
3+
import { fireEvent, render, waitFor } from "@testing-library/react"
44
import "@testing-library/jest-dom"
55

6+
import { parseHighlightedCode } from "../highlightParser"
67
import CodeBlock from "../index"
8+
import { replaceBrTagsWithNewlines } from "../utils"
79

810
// Helper function for rendering CodeBlock with default content
911
const renderCodeBlock = (content = 'console.log("Hello, World!")') =>
@@ -68,4 +70,201 @@ describe("CodeBlock", () => {
6870
// The code block should decode the encoded ampersands
6971
expect(codeElement?.textContent).toBe("apt-get update && apt-get install -y curl python3")
7072
})
73+
74+
describe("line highlighting", () => {
75+
it("should remove highlight markers from displayed code", async () => {
76+
const contentWithMarkers = "const x = 1 // HIGHLIGHT LINE\nfoo()\nbar()"
77+
const { container } = renderCodeBlock(contentWithMarkers)
78+
await waitFor(() => {
79+
const codeElement = container.querySelector("code")
80+
expect(codeElement?.textContent).not.toContain("// HIGHLIGHT LINE")
81+
expect(codeElement?.textContent).toContain("const x = 1")
82+
expect(codeElement?.textContent).toContain("foo()")
83+
})
84+
})
85+
86+
it("should apply highlighted-line class to marked lines", async () => {
87+
const contentWithMarkers = "line1 // HIGHLIGHT LINE\nline2\nline3"
88+
const { container } = renderCodeBlock(contentWithMarkers)
89+
await waitFor(
90+
() => {
91+
const highlighted = container.querySelectorAll(".highlighted-line")
92+
expect(highlighted.length).toBeGreaterThanOrEqual(1)
93+
expect(highlighted[0].textContent).toContain("line1")
94+
},
95+
{ timeout: 2000 },
96+
)
97+
})
98+
99+
it("should wrap lines in code-line spans when highlighting is used", async () => {
100+
const contentWithMarkers = "a // HIGHLIGHT LINE\nb\nc"
101+
const { container } = renderCodeBlock(contentWithMarkers)
102+
await waitFor(
103+
() => {
104+
const codeLines = container.querySelectorAll(".code-line")
105+
expect(codeLines.length).toBeGreaterThanOrEqual(2)
106+
},
107+
{ timeout: 2000 },
108+
)
109+
})
110+
111+
it("should copy clean code without markers when copy button is used", async () => {
112+
const writeText = jest.fn(() => Promise.resolve())
113+
Object.defineProperty(navigator, "clipboard", {
114+
value: { writeText },
115+
configurable: true,
116+
})
117+
const consoleSpy = jest.spyOn(console, "info").mockImplementation()
118+
const contentWithMarkers =
119+
"const url = process.env.URI // HIGHLIGHT LINE\nmongoose.connect(url)"
120+
const { container } = renderCodeBlock(contentWithMarkers)
121+
const copyButton = container.querySelector('button[aria-label="copy-to-clipboard"]')
122+
expect(copyButton).toBeInTheDocument()
123+
fireEvent.click(copyButton!)
124+
await waitFor(() => {
125+
expect(writeText).toHaveBeenCalled()
126+
})
127+
expect(writeText).toHaveBeenCalledWith("const url = process.env.URI\nmongoose.connect(url)")
128+
consoleSpy.mockRestore()
129+
})
130+
131+
it("copied text matches cleanCode when content has <br> and highlight markers (no extra blank lines from markers)", async () => {
132+
const writeText = jest.fn(() => Promise.resolve())
133+
Object.defineProperty(navigator, "clipboard", {
134+
value: { writeText },
135+
configurable: true,
136+
})
137+
const contentWithBrAndMarkers =
138+
"const url = process.env.MONGODB_URI // HIGHLIGHT LINE<br><br>mongoose.connect(url)<br>// BEGIN HIGHLIGHT<br>.then(result => {<br> console.log('connected to MongoDB')<br>})<br>.catch(error => {<br> console.log('error connecting to MongoDB:', error.message)<br>})<br>// END HIGHLIGHT<br><br>module.exports = mongoose.model('Note', noteSchema) // HIGHLIGHT LINE"
139+
const { container } = renderCodeBlock(contentWithBrAndMarkers)
140+
await waitFor(() => {
141+
const codeElement = container.querySelector("code")
142+
expect(codeElement?.textContent).not.toContain("// HIGHLIGHT")
143+
})
144+
const copyButton = container.querySelector('button[aria-label="copy-to-clipboard"]')
145+
fireEvent.click(copyButton!)
146+
await waitFor(() => {
147+
expect(writeText).toHaveBeenCalled()
148+
})
149+
const copied = (writeText.mock.calls[0] as unknown as [string] | undefined)?.[0]
150+
expect(copied).toBeDefined()
151+
const processed = replaceBrTagsWithNewlines(contentWithBrAndMarkers)
152+
const { cleanCode: expected } = parseHighlightedCode(processed ?? "")
153+
expect(String(copied).replace(/\r\n/g, "\n")).toBe(expected)
154+
})
155+
156+
it("should highlight range between BEGIN HIGHLIGHT and END HIGHLIGHT", async () => {
157+
const contentWithRange = "before\n// BEGIN HIGHLIGHT\nmid1\nmid2\n// END HIGHLIGHT\nafter"
158+
const { container } = renderCodeBlock(contentWithRange)
159+
await waitFor(
160+
() => {
161+
const codeElement = container.querySelector("code")
162+
expect(codeElement?.textContent).not.toContain("// BEGIN HIGHLIGHT")
163+
expect(codeElement?.textContent).not.toContain("// END HIGHLIGHT")
164+
const highlighted = container.querySelectorAll(".highlighted-line")
165+
expect(highlighted.length).toBeGreaterThanOrEqual(2)
166+
},
167+
{ timeout: 2000 },
168+
)
169+
})
170+
171+
it("should recognize highlight markers when content has <br> tags", async () => {
172+
const contentWithBrAndMarkers =
173+
"const x = 1 // HIGHLIGHT LINE<br/>const y = 2<br/>// BEGIN HIGHLIGHT<br/>const z = 3<br/>// END HIGHLIGHT"
174+
const { container } = renderCodeBlock(contentWithBrAndMarkers)
175+
await waitFor(
176+
() => {
177+
const codeElement = container.querySelector("code")
178+
expect(codeElement?.textContent).not.toContain("// HIGHLIGHT LINE")
179+
expect(codeElement?.textContent).not.toContain("// BEGIN HIGHLIGHT")
180+
expect(codeElement?.textContent).not.toContain("// END HIGHLIGHT")
181+
const highlighted = container.querySelectorAll(".highlighted-line")
182+
expect(highlighted.length).toBeGreaterThanOrEqual(2)
183+
expect(highlighted[0].textContent).toContain("const x = 1")
184+
},
185+
{ timeout: 2000 },
186+
)
187+
})
188+
189+
it("correctly highlights lines in code with blank lines", async () => {
190+
const contentWithBlanks = "first\n\nthird // HIGHLIGHT LINE"
191+
const { container } = renderCodeBlock(contentWithBlanks)
192+
await waitFor(
193+
() => {
194+
const lines = container.querySelectorAll(".code-line")
195+
expect(lines.length).toBe(3)
196+
expect(lines[2].classList.contains("highlighted-line")).toBe(true)
197+
},
198+
{ timeout: 2000 },
199+
)
200+
})
201+
202+
it("should correctly highlight lines inside multi-line block comments", async () => {
203+
const content = "// BEGIN HIGHLIGHT\n/*\n * block comment\n */\n// END HIGHLIGHT\ncode"
204+
const { container } = renderCodeBlock(content)
205+
await waitFor(
206+
() => {
207+
const highlighted = container.querySelectorAll(".highlighted-line")
208+
expect(highlighted.length).toBeGreaterThanOrEqual(3)
209+
},
210+
{ timeout: 2000 },
211+
)
212+
})
213+
214+
it("should not double-wrap lines on re-render", async () => {
215+
const content = "a // HIGHLIGHT LINE\nb"
216+
const data = {
217+
attributes: { content },
218+
name: "core/code" as const,
219+
isValid: true,
220+
clientId: "test-id",
221+
innerBlocks: [],
222+
}
223+
const { container, rerender } = render(<CodeBlock data={data} id="test-id" isExam={false} />)
224+
await waitFor(() => {
225+
expect(container.querySelectorAll(".code-line").length).toBe(2)
226+
})
227+
rerender(<CodeBlock data={data} id="test-id" isExam={false} />)
228+
await waitFor(() => {
229+
expect(container.querySelectorAll(".code-line").length).toBe(2)
230+
})
231+
})
232+
233+
it("should highlight lines when language is auto-detected", async () => {
234+
const content = "const x = 1 // HIGHLIGHT LINE\nconst y = 2"
235+
const { container } = renderCodeBlock(content)
236+
await waitFor(
237+
() => {
238+
const highlighted = container.querySelectorAll(".highlighted-line")
239+
expect(highlighted.length).toBe(1)
240+
expect(highlighted[0].textContent).toContain("const x = 1")
241+
},
242+
{ timeout: 2000 },
243+
)
244+
})
245+
246+
it("should strip # HIGHLIGHT LINE and highlight regardless of language", async () => {
247+
const content = "x = 1 # HIGHLIGHT LINE\ny = 2"
248+
const { container } = renderCodeBlock(content)
249+
await waitFor(
250+
() => {
251+
const codeEl = container.querySelector("code")
252+
expect(codeEl?.textContent).not.toContain("# HIGHLIGHT LINE")
253+
const highlighted = container.querySelectorAll(".highlighted-line")
254+
expect(highlighted.length).toBe(1)
255+
expect(highlighted[0].textContent?.trim()).toBe("x = 1")
256+
},
257+
{ timeout: 2000 },
258+
)
259+
})
260+
261+
it("announces highlighted lines to screen readers when highlights are present", () => {
262+
const content = "line1 // HIGHLIGHT LINE\nline2\nline3 // HIGHLIGHT LINE"
263+
const { container } = renderCodeBlock(content)
264+
const preParent = container.querySelector("pre")?.parentElement
265+
const srOnlySpan = preParent?.querySelector(":scope > span")
266+
expect(srOnlySpan).toBeInTheDocument()
267+
expect(srOnlySpan?.textContent?.length).toBeGreaterThan(0)
268+
})
269+
})
71270
})

0 commit comments

Comments
 (0)