diff --git a/apps/web/content/docs/concepts/annotations.mdx b/apps/web/content/docs/concepts/annotations.mdx
index 7a5dddb5..7b8a9782 100644
--- a/apps/web/content/docs/concepts/annotations.mdx
+++ b/apps/web/content/docs/concepts/annotations.mdx
@@ -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:
@@ -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)):
+
+## 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:
+
+
diff --git a/apps/web/demos/annotations/start-end/content.md b/apps/web/demos/annotations/start-end/content.md
new file mode 100644
index 00000000..bd8e77f8
--- /dev/null
+++ b/apps/web/demos/annotations/start-end/content.md
@@ -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
+```
diff --git a/apps/web/demos/annotations/start-end/page.tsx b/apps/web/demos/annotations/start-end/page.tsx
new file mode 100644
index 00000000..b79a76ad
--- /dev/null
+++ b/apps/web/demos/annotations/start-end/page.tsx
@@ -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
+}
+
+export async function Code({ codeblock }: { codeblock: RawCode }) {
+ const highlighted = await highlight(codeblock, "github-dark")
+ return (
+
+ )
+}
+const borderHandler: AnnotationHandler = {
+ name: "border",
+ Block: ({ annotation, children }) => (
+ {children}
+ ),
+}
diff --git a/packages/codehike/CHANGELOG.md b/packages/codehike/CHANGELOG.md
index ca888f4f..f0255a38 100644
--- a/packages/codehike/CHANGELOG.md
+++ b/packages/codehike/CHANGELOG.md
@@ -1,5 +1,11 @@
# codehike
+## 1.1.0
+
+### Minor Changes
+
+- [#532](https://github.com/code-hike/codehike/pull/532) [`b4d10f5`](https://github.com/code-hike/codehike/commit/b4d10f5330c6c30a79eed5a0f42710bec822d51c) Thanks [@pomber](https://github.com/pomber)! - Add support for `!name(start)` and `!name(end)` comment markers as an alternative way to define multi-line code annotation ranges.
+
## 1.0.7
### Patch Changes
diff --git a/packages/codehike/package.json b/packages/codehike/package.json
index 8897f0d4..325a5119 100644
--- a/packages/codehike/package.json
+++ b/packages/codehike/package.json
@@ -1,6 +1,6 @@
{
"name": "codehike",
- "version": "1.0.7",
+ "version": "1.1.0",
"description": "Build rich content websites with Markdown and React",
"keywords": [
"react",
diff --git a/packages/codehike/src/code/extract-annotations.test.ts b/packages/codehike/src/code/extract-annotations.test.ts
index 77a8bd1a..9ed43ba2 100644
--- a/packages/codehike/src/code/extract-annotations.test.ts
+++ b/packages/codehike/src/code/extract-annotations.test.ts
@@ -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) {
@@ -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")
@@ -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")
+})
diff --git a/packages/codehike/src/code/extract-annotations.tsx b/packages/codehike/src/code/extract-annotations.tsx
index d5ff7929..4b969755 100644
--- a/packages/codehike/src/code/extract-annotations.tsx
+++ b/packages/codehike/src/code/extract-annotations.tsx
@@ -1,23 +1,90 @@
import { Annotation, extractAnnotations } from "@code-hike/lighter"
+const START_MARKER = "\0start\0"
+const END_MARKER = "\0end\0"
+
export async function splitAnnotationsAndCode(
code: string,
lang: string,
annotationPrefix: string,
) {
- let annotations: Annotation[] = []
- let codeWithoutAnnotations = code
-
- const { code: newCode, annotations: newAnnotations } =
- await extractCommentAnnotations(
- codeWithoutAnnotations,
- lang,
- annotationPrefix,
- )
- annotations = [...annotations, ...newAnnotations]
- codeWithoutAnnotations = newCode
+ const { code: newCode, annotations: rawAnnotations } =
+ await extractCommentAnnotations(code, lang, annotationPrefix)
+
+ const annotations = processStartEndMarkers(rawAnnotations)
+
+ return { code: newCode, annotations }
+}
+
+function getLineFromRange(range: any): number {
+ if (range.lineNumber) return range.lineNumber
+ return range.fromLineNumber
+}
+
+function processStartEndMarkers(annotations: Annotation[]): Annotation[] {
+ const stacks = new Map<
+ string,
+ { name: string; query: string; line: number; order: number }[]
+ >()
+ const orderedAnnotations: { order: number; annotation: Annotation }[] = []
+
+ for (const [order, a] of annotations.entries()) {
+ const q = a.query ?? ""
+ if (q.startsWith(START_MARKER)) {
+ const start = {
+ name: a.name,
+ query: q.slice(START_MARKER.length),
+ line: getLineFromRange(a.ranges[0]),
+ order,
+ }
+ const stack = stacks.get(a.name) ?? []
+ stack.push(start)
+ stacks.set(a.name, stack)
+ } else if (q.startsWith(END_MARKER)) {
+ const stack = stacks.get(a.name)
+ const start = stack?.pop()
+ if (!start) {
+ console.warn(
+ `Code Hike warning: Unmatched !${a.name}(end) annotation`,
+ )
+ continue
+ }
+
+ const endLine = getLineFromRange(a.ranges[0]) - 1
+ if (endLine < start.line) {
+ console.warn(
+ `Code Hike warning: Empty !${a.name} start/end annotation range`,
+ )
+ continue
+ }
- return { code: codeWithoutAnnotations, annotations }
+ orderedAnnotations.push({
+ order: start.order,
+ annotation: {
+ name: start.name,
+ query: start.query,
+ ranges: [{ fromLineNumber: start.line, toLineNumber: endLine }],
+ },
+ })
+ } else {
+ orderedAnnotations.push({ order, annotation: a })
+ }
+ }
+
+ if (orderedAnnotations.length === annotations.length) {
+ return annotations
+ }
+
+ for (const stack of stacks.values()) {
+ for (const start of stack) {
+ console.warn(
+ `Code Hike warning: Unmatched !${start.name}(start) annotation`,
+ )
+ }
+ }
+
+ orderedAnnotations.sort((a, b) => a.order - b.order)
+ return orderedAnnotations.map((entry) => entry.annotation)
}
async function extractCommentAnnotations(
@@ -45,12 +112,21 @@ async function extractCommentAnnotations(
return null
}
const name = match[1]
- const rangeString = match[2]
- const query = match[3]?.trim()
+ let rangeString = match[2]
+ let query = match[3]?.trim() ?? ""
if (!name || !name.startsWith(annotationPrefix)) {
return null
}
+ // Handle start/end range markers: !name(start) and !name(end)
+ if (rangeString === "(start)") {
+ query = START_MARKER + query
+ rangeString = "(1)"
+ } else if (rangeString === "(end)") {
+ query = END_MARKER + query
+ rangeString = "(1)"
+ }
+
return {
name: name.slice(annotationPrefix.length),
rangeString,