Skip to content

Commit b641376

Browse files
authored
Merge pull request #532 from code-hike/feat/start-end-annotations
Add start/end comments for multi-line annotation ranges
2 parents 308b527 + 291d9fa commit b641376

File tree

6 files changed

+367
-16
lines changed

6 files changed

+367
-16
lines changed

.changeset/bright-llamas-tie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"codehike": minor
3+
---
4+
5+
Add support for `!name(start)` and `!name(end)` comment markers as an alternative way to define multi-line code annotation ranges.

apps/web/content/docs/concepts/annotations.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ We can use the `name` of those handlers as comments in the code to use the compo
4040

4141
## Annotation Comments
4242

43-
We use comments to annotate codeblocks. **The comment syntax depends on the language**. For example, in javascript we use `// !name(1:5)` but in python we use `# !name(1:5)`. For JSON (that doesn't support comments), the recommendation is to instead use `jsonc` or `json5`, which support comments.
43+
We use comments to annotate codeblocks. **The comment syntax depends on the language**. For example, in javascript we use `// !name(1:5)`, but in python we use `# !name(1:5)`. For JSON (that doesn't support comments), the recommendation is to instead use `jsonc` or `json5`, which support comments.
4444

4545
In the previous example we can see the two types of annotations:
4646

@@ -217,3 +217,9 @@ The regular expressions also support flags The two most common are `g` for globa
217217
You can also use [capturing groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Capturing_group) (see [fold example](/docs/code/fold)):
218218

219219
<Demo name="annotations/groups" />
220+
221+
## Using start/end comments instead of ranges
222+
223+
Instead of counting lines for the range, you can use `!name(start)` and `!name(end)` comments to mark the block:
224+
225+
<Demo name="annotations/start-end" />
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
```js
2+
const lorem = ipsum == null ? 0 : 1
3+
// !border(start)
4+
dolor = lorem - sit(dolor)
5+
let amet = lorem ? consectetur(ipsum) : 3
6+
const elit = amet + dolor
7+
// !border(end)
8+
sed = elit * 2
9+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Content from "./content.md"
2+
import { RawCode, Pre, highlight } from "codehike/code"
3+
import { AnnotationHandler } from "codehike/code"
4+
5+
export default function Page() {
6+
return <Content components={{ Code }} />
7+
}
8+
9+
export async function Code({ codeblock }: { codeblock: RawCode }) {
10+
const highlighted = await highlight(codeblock, "github-dark")
11+
return (
12+
<Pre
13+
className="m-0 bg-zinc-950"
14+
code={highlighted}
15+
handlers={[borderHandler]}
16+
/>
17+
)
18+
}
19+
const borderHandler: AnnotationHandler = {
20+
name: "border",
21+
Block: ({ annotation, children }) => (
22+
<div style={{ border: "1px solid red" }}>{children}</div>
23+
),
24+
}

packages/codehike/src/code/extract-annotations.test.ts

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from "vitest"
1+
import { expect, test, vi } from "vitest"
22
import { splitAnnotationsAndCode } from "./extract-annotations.js"
33

44
async function t(comment: string) {
@@ -7,6 +7,14 @@ async function t(comment: string) {
77
return annotations[0]
88
}
99

10+
function getBlockRange(annotation: { ranges: any[] }, index = 0) {
11+
const range = annotation.ranges[index]
12+
if (!("fromLineNumber" in range) || !("toLineNumber" in range)) {
13+
throw new Error("Expected block range")
14+
}
15+
return range
16+
}
17+
1018
test("extracts basic annotation name", async () => {
1119
const annotation = await t("!foo bar")
1220
expect(annotation.name).toEqual("foo")
@@ -58,3 +66,226 @@ test("extracts name with complex regex pattern", async () => {
5866
const annotation = annotations[0]
5967
expect(annotation.name).toEqual("tooltip")
6068
})
69+
70+
// start/end range marker tests
71+
72+
test("start/end creates block annotation spanning the range", async () => {
73+
const code = [
74+
"let a = 1",
75+
"// !focus(start)",
76+
"let b = 2",
77+
"let c = 3",
78+
"// !focus(end)",
79+
"let d = 4",
80+
].join("\n")
81+
const { code: resultCode, annotations } = await splitAnnotationsAndCode(
82+
code,
83+
"javascript",
84+
"!",
85+
)
86+
expect(resultCode).not.toContain("!focus")
87+
expect(annotations).toHaveLength(1)
88+
const a = annotations[0]
89+
expect(a.name).toEqual("focus")
90+
expect(a.ranges).toHaveLength(1)
91+
const range = getBlockRange(a)
92+
// after removing 2 comment lines, the code is 4 lines
93+
// "let b = 2" is line 2, "let c = 3" is line 3
94+
expect(range.fromLineNumber).toEqual(2)
95+
expect(range.toLineNumber).toEqual(3)
96+
})
97+
98+
test("start/end preserves query string", async () => {
99+
const code = [
100+
"// !box(start) myquery",
101+
"let x = 1",
102+
"// !box(end)",
103+
].join("\n")
104+
const { annotations } = await splitAnnotationsAndCode(
105+
code,
106+
"javascript",
107+
"!",
108+
)
109+
expect(annotations).toHaveLength(1)
110+
expect(annotations[0].name).toEqual("box")
111+
expect(annotations[0].query).toEqual("myquery")
112+
})
113+
114+
test("start/end works with other annotations", async () => {
115+
const code = [
116+
"// !mark",
117+
"let a = 1",
118+
"// !focus(start)",
119+
"let b = 2",
120+
"let c = 3",
121+
"// !focus(end)",
122+
"let d = 4",
123+
].join("\n")
124+
const { annotations } = await splitAnnotationsAndCode(
125+
code,
126+
"javascript",
127+
"!",
128+
)
129+
expect(annotations).toHaveLength(2)
130+
const mark = annotations.find((a) => a.name === "mark")
131+
const focus = annotations.find((a) => a.name === "focus")
132+
expect(mark).toBeDefined()
133+
expect(focus).toBeDefined()
134+
const range = getBlockRange(focus!)
135+
expect(range.fromLineNumber).toBeDefined()
136+
expect(range.toLineNumber).toBeDefined()
137+
})
138+
139+
test("multiple start/end pairs of same name", async () => {
140+
const code = [
141+
"let a = 1",
142+
"// !focus(start)",
143+
"let b = 2",
144+
"// !focus(end)",
145+
"let c = 3",
146+
"// !focus(start)",
147+
"let d = 4",
148+
"// !focus(end)",
149+
"let e = 5",
150+
].join("\n")
151+
const { annotations } = await splitAnnotationsAndCode(
152+
code,
153+
"javascript",
154+
"!",
155+
)
156+
expect(annotations).toHaveLength(2)
157+
expect(annotations[0].name).toEqual("focus")
158+
expect(annotations[1].name).toEqual("focus")
159+
// The two ranges should not overlap
160+
const r0 = getBlockRange(annotations[0])
161+
const r1 = getBlockRange(annotations[1])
162+
expect(r0.toLineNumber).toBeLessThan(r1.fromLineNumber)
163+
})
164+
165+
test("same-name nested start/end pairs preserve nesting", async () => {
166+
const code = [
167+
"// !focus(start)",
168+
"const outer = 1",
169+
"// !focus(start)",
170+
"const inner = 2",
171+
"// !focus(end)",
172+
"const outerTail = 3",
173+
"// !focus(end)",
174+
"const after = 4",
175+
].join("\n")
176+
const { annotations } = await splitAnnotationsAndCode(
177+
code,
178+
"javascript",
179+
"!",
180+
)
181+
182+
expect(annotations).toHaveLength(2)
183+
expect(annotations[0].name).toEqual("focus")
184+
expect(annotations[0].ranges[0]).toEqual({
185+
fromLineNumber: 1,
186+
toLineNumber: 3,
187+
})
188+
expect(annotations[1].name).toEqual("focus")
189+
expect(annotations[1].ranges[0]).toEqual({
190+
fromLineNumber: 2,
191+
toLineNumber: 2,
192+
})
193+
})
194+
195+
test("different annotation names with start/end", async () => {
196+
const code = [
197+
"// !focus(start)",
198+
"let a = 1",
199+
"// !mark(start)",
200+
"let b = 2",
201+
"// !mark(end)",
202+
"let c = 3",
203+
"// !focus(end)",
204+
].join("\n")
205+
const { annotations } = await splitAnnotationsAndCode(
206+
code,
207+
"javascript",
208+
"!",
209+
)
210+
expect(annotations).toHaveLength(2)
211+
const focus = annotations.find((a) => a.name === "focus")
212+
const mark = annotations.find((a) => a.name === "mark")
213+
expect(focus).toBeDefined()
214+
expect(mark).toBeDefined()
215+
})
216+
217+
test("start/end removes comment lines from code", async () => {
218+
const code = [
219+
"let a = 1",
220+
"// !focus(start)",
221+
"let b = 2",
222+
"// !focus(end)",
223+
"let c = 3",
224+
].join("\n")
225+
const { code: resultCode } = await splitAnnotationsAndCode(
226+
code,
227+
"javascript",
228+
"!",
229+
)
230+
const lines = resultCode.split("\n")
231+
expect(lines).toHaveLength(3)
232+
expect(lines[0]).toContain("let a = 1")
233+
expect(lines[1]).toContain("let b = 2")
234+
expect(lines[2]).toContain("let c = 3")
235+
})
236+
237+
test("adjacent start/end markers are ignored instead of creating empty ranges", async () => {
238+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
239+
try {
240+
const code = [
241+
"// !focus(start)",
242+
"// !focus(end)",
243+
"const x = 1",
244+
].join("\n")
245+
246+
const { code: resultCode, annotations } = await splitAnnotationsAndCode(
247+
code,
248+
"javascript",
249+
"!",
250+
)
251+
252+
expect(resultCode).toEqual("const x = 1")
253+
expect(annotations).toHaveLength(0)
254+
expect(warn).toHaveBeenCalledWith(
255+
"Code Hike warning: Empty !focus start/end annotation range",
256+
)
257+
} finally {
258+
warn.mockRestore()
259+
}
260+
})
261+
262+
test("start/end works with Python comments", async () => {
263+
const code = [
264+
"x = 1",
265+
"# !focus(start)",
266+
"y = 2",
267+
"z = 3",
268+
"# !focus(end)",
269+
"w = 4",
270+
].join("\n")
271+
const { annotations } = await splitAnnotationsAndCode(code, "python", "!")
272+
expect(annotations).toHaveLength(1)
273+
expect(annotations[0].name).toEqual("focus")
274+
const range = getBlockRange(annotations[0])
275+
expect(range.fromLineNumber).toEqual(2)
276+
expect(range.toLineNumber).toEqual(3)
277+
})
278+
279+
test("start/end works with block comments", async () => {
280+
const code = [
281+
"int a = 1;",
282+
"/* !mark(start) */",
283+
"int b = 2;",
284+
"int c = 3;",
285+
"/* !mark(end) */",
286+
"int d = 4;",
287+
].join("\n")
288+
const { annotations } = await splitAnnotationsAndCode(code, "c", "!")
289+
expect(annotations).toHaveLength(1)
290+
expect(annotations[0].name).toEqual("mark")
291+
})

0 commit comments

Comments
 (0)