Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-llamas-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"codehike": minor
---

Add support for `!name(start)` and `!name(end)` comment markers as an alternative way to define multi-line code annotation ranges.
8 changes: 7 additions & 1 deletion apps/web/content/docs/concepts/annotations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ We can use the `name` of those handlers as comments in the code to use the compo

## Annotation Comments

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.
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.

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

Expand Down Expand Up @@ -217,3 +217,9 @@ The regular expressions also support flags The two most common are `g` for globa
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)):

<Demo name="annotations/groups" />

## Using start/end comments instead of ranges

Instead of counting lines for the range, you can use `!name(start)` and `!name(end)` comments to mark the block:

<Demo name="annotations/start-end" />
9 changes: 9 additions & 0 deletions apps/web/demos/annotations/start-end/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
```js
const lorem = ipsum == null ? 0 : 1
// !border(start)
dolor = lorem - sit(dolor)
let amet = lorem ? consectetur(ipsum) : 3
const elit = amet + dolor
// !border(end)
sed = elit * 2
```
24 changes: 24 additions & 0 deletions apps/web/demos/annotations/start-end/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Content from "./content.md"
import { RawCode, Pre, highlight } from "codehike/code"
import { AnnotationHandler } from "codehike/code"

export default function Page() {
return <Content components={{ Code }} />
}

export async function Code({ codeblock }: { codeblock: RawCode }) {
const highlighted = await highlight(codeblock, "github-dark")
return (
<Pre
className="m-0 bg-zinc-950"
code={highlighted}
handlers={[borderHandler]}
/>
)
}
const borderHandler: AnnotationHandler = {
name: "border",
Block: ({ annotation, children }) => (
<div style={{ border: "1px solid red" }}>{children}</div>
),
}
233 changes: 232 additions & 1 deletion packages/codehike/src/code/extract-annotations.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "vitest"
import { expect, test, vi } from "vitest"
import { splitAnnotationsAndCode } from "./extract-annotations.js"

async function t(comment: string) {
Expand All @@ -7,6 +7,14 @@ async function t(comment: string) {
return annotations[0]
}

function getBlockRange(annotation: { ranges: any[] }, index = 0) {
const range = annotation.ranges[index]
if (!("fromLineNumber" in range) || !("toLineNumber" in range)) {
throw new Error("Expected block range")
}
return range
}

test("extracts basic annotation name", async () => {
const annotation = await t("!foo bar")
expect(annotation.name).toEqual("foo")
Expand Down Expand Up @@ -58,3 +66,226 @@ test("extracts name with complex regex pattern", async () => {
const annotation = annotations[0]
expect(annotation.name).toEqual("tooltip")
})

// start/end range marker tests

test("start/end creates block annotation spanning the range", async () => {
const code = [
"let a = 1",
"// !focus(start)",
"let b = 2",
"let c = 3",
"// !focus(end)",
"let d = 4",
].join("\n")
const { code: resultCode, annotations } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)
expect(resultCode).not.toContain("!focus")
expect(annotations).toHaveLength(1)
const a = annotations[0]
expect(a.name).toEqual("focus")
expect(a.ranges).toHaveLength(1)
const range = getBlockRange(a)
// after removing 2 comment lines, the code is 4 lines
// "let b = 2" is line 2, "let c = 3" is line 3
expect(range.fromLineNumber).toEqual(2)
expect(range.toLineNumber).toEqual(3)
})

test("start/end preserves query string", async () => {
const code = [
"// !box(start) myquery",
"let x = 1",
"// !box(end)",
].join("\n")
const { annotations } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)
expect(annotations).toHaveLength(1)
expect(annotations[0].name).toEqual("box")
expect(annotations[0].query).toEqual("myquery")
})

test("start/end works with other annotations", async () => {
const code = [
"// !mark",
"let a = 1",
"// !focus(start)",
"let b = 2",
"let c = 3",
"// !focus(end)",
"let d = 4",
].join("\n")
const { annotations } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)
expect(annotations).toHaveLength(2)
const mark = annotations.find((a) => a.name === "mark")
const focus = annotations.find((a) => a.name === "focus")
expect(mark).toBeDefined()
expect(focus).toBeDefined()
const range = getBlockRange(focus!)
expect(range.fromLineNumber).toBeDefined()
expect(range.toLineNumber).toBeDefined()
})

test("multiple start/end pairs of same name", async () => {
const code = [
"let a = 1",
"// !focus(start)",
"let b = 2",
"// !focus(end)",
"let c = 3",
"// !focus(start)",
"let d = 4",
"// !focus(end)",
"let e = 5",
].join("\n")
const { annotations } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)
expect(annotations).toHaveLength(2)
expect(annotations[0].name).toEqual("focus")
expect(annotations[1].name).toEqual("focus")
// The two ranges should not overlap
const r0 = getBlockRange(annotations[0])
const r1 = getBlockRange(annotations[1])
expect(r0.toLineNumber).toBeLessThan(r1.fromLineNumber)
})

test("same-name nested start/end pairs preserve nesting", async () => {
const code = [
"// !focus(start)",
"const outer = 1",
"// !focus(start)",
"const inner = 2",
"// !focus(end)",
"const outerTail = 3",
"// !focus(end)",
"const after = 4",
].join("\n")
const { annotations } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)

expect(annotations).toHaveLength(2)
expect(annotations[0].name).toEqual("focus")
expect(annotations[0].ranges[0]).toEqual({
fromLineNumber: 1,
toLineNumber: 3,
})
expect(annotations[1].name).toEqual("focus")
expect(annotations[1].ranges[0]).toEqual({
fromLineNumber: 2,
toLineNumber: 2,
})
})

test("different annotation names with start/end", async () => {
const code = [
"// !focus(start)",
"let a = 1",
"// !mark(start)",
"let b = 2",
"// !mark(end)",
"let c = 3",
"// !focus(end)",
].join("\n")
const { annotations } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)
expect(annotations).toHaveLength(2)
const focus = annotations.find((a) => a.name === "focus")
const mark = annotations.find((a) => a.name === "mark")
expect(focus).toBeDefined()
expect(mark).toBeDefined()
})

test("start/end removes comment lines from code", async () => {
const code = [
"let a = 1",
"// !focus(start)",
"let b = 2",
"// !focus(end)",
"let c = 3",
].join("\n")
const { code: resultCode } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)
const lines = resultCode.split("\n")
expect(lines).toHaveLength(3)
expect(lines[0]).toContain("let a = 1")
expect(lines[1]).toContain("let b = 2")
expect(lines[2]).toContain("let c = 3")
})

test("adjacent start/end markers are ignored instead of creating empty ranges", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
try {
const code = [
"// !focus(start)",
"// !focus(end)",
"const x = 1",
].join("\n")

const { code: resultCode, annotations } = await splitAnnotationsAndCode(
code,
"javascript",
"!",
)

expect(resultCode).toEqual("const x = 1")
expect(annotations).toHaveLength(0)
expect(warn).toHaveBeenCalledWith(
"Code Hike warning: Empty !focus start/end annotation range",
)
} finally {
warn.mockRestore()
}
})

test("start/end works with Python comments", async () => {
const code = [
"x = 1",
"# !focus(start)",
"y = 2",
"z = 3",
"# !focus(end)",
"w = 4",
].join("\n")
const { annotations } = await splitAnnotationsAndCode(code, "python", "!")
expect(annotations).toHaveLength(1)
expect(annotations[0].name).toEqual("focus")
const range = getBlockRange(annotations[0])
expect(range.fromLineNumber).toEqual(2)
expect(range.toLineNumber).toEqual(3)
})

test("start/end works with block comments", async () => {
const code = [
"int a = 1;",
"/* !mark(start) */",
"int b = 2;",
"int c = 3;",
"/* !mark(end) */",
"int d = 4;",
].join("\n")
const { annotations } = await splitAnnotationsAndCode(code, "c", "!")
expect(annotations).toHaveLength(1)
expect(annotations[0].name).toEqual("mark")
})
Loading
Loading