From 9067ae2a7ac171ce1aca4c60a4fedc6b8f0fb756 Mon Sep 17 00:00:00 2001 From: Rodrigo Pombo Date: Mon, 16 Mar 2026 16:24:42 +0100 Subject: [PATCH 1/8] Support multi-line annotation ranges with start/end comments Add `!name(start)` and `!name(end)` syntax for defining multi-line annotation ranges. This allows annotations like `!focus` to span multiple lines without specifying explicit line numbers. Closes #530 Co-Authored-By: Claude Opus 4.6 --- .../src/code/extract-annotations.test.ts | 166 ++++++++++++++++++ .../codehike/src/code/extract-annotations.tsx | 103 +++++++++-- 2 files changed, 256 insertions(+), 13 deletions(-) diff --git a/packages/codehike/src/code/extract-annotations.test.ts b/packages/codehike/src/code/extract-annotations.test.ts index 77a8bd1a..40f19f45 100644 --- a/packages/codehike/src/code/extract-annotations.test.ts +++ b/packages/codehike/src/code/extract-annotations.test.ts @@ -58,3 +58,169 @@ 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 = a.ranges[0] + // 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() + expect(focus!.ranges[0].fromLineNumber).toBeDefined() + expect(focus!.ranges[0].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 = annotations[0].ranges[0] + const r1 = annotations[1].ranges[0] + expect(r0.toLineNumber).toBeLessThan(r1.fromLineNumber) +}) + +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("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") + expect(annotations[0].ranges[0].fromLineNumber).toEqual(2) + expect(annotations[0].ranges[0].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..0908d522 100644 --- a/packages/codehike/src/code/extract-annotations.tsx +++ b/packages/codehike/src/code/extract-annotations.tsx @@ -1,23 +1,91 @@ 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, + 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 regular: Annotation[] = [] + const starts: { name: string; query: string; line: number }[] = [] + const ends: { name: string; query: string; line: number }[] = [] + + for (const a of annotations) { + const q = a.query ?? "" + if (q.startsWith(START_MARKER)) { + starts.push({ + name: a.name, + query: q.slice(START_MARKER.length), + line: getLineFromRange(a.ranges[0]), + }) + } else if (q.startsWith(END_MARKER)) { + ends.push({ + name: a.name, + query: q.slice(END_MARKER.length), + line: getLineFromRange(a.ranges[0]), + }) + } else { + regular.push(a) + } + } + + if (starts.length === 0 && ends.length === 0) { + return annotations + } + + const paired: Annotation[] = [] + const usedEnds = new Set() + + for (const start of starts) { + // find the first unused end with the same name that comes after the start + const endIndex = ends.findIndex( + (e, i) => + !usedEnds.has(i) && + e.name === start.name && + e.line >= start.line, ) - annotations = [...annotations, ...newAnnotations] - codeWithoutAnnotations = newCode + if (endIndex === -1) { + console.warn( + `Code Hike warning: Unmatched !${start.name}(start) annotation`, + ) + continue + } + usedEnds.add(endIndex) + const end = ends[endIndex] + paired.push({ + name: start.name, + query: start.query, + ranges: [ + { fromLineNumber: start.line, toLineNumber: end.line - 1 }, + ], + }) + } - return { code: codeWithoutAnnotations, annotations } + for (let i = 0; i < ends.length; i++) { + if (!usedEnds.has(i)) { + console.warn( + `Code Hike warning: Unmatched !${ends[i].name}(end) annotation`, + ) + } + } + + return [...regular, ...paired] } async function extractCommentAnnotations( @@ -45,12 +113,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 = undefined + } else if (rangeString === "(end)") { + query = END_MARKER + query + rangeString = undefined + } + return { name: name.slice(annotationPrefix.length), rangeString, From ddd16d2ffb560a8b7c76f4edca876a277ff06505 Mon Sep 17 00:00:00 2001 From: Rodrigo Pombo Date: Mon, 16 Mar 2026 16:41:44 +0100 Subject: [PATCH 2/8] Fix extract annotations tests --- .../src/code/extract-annotations.test.ts | 57 ++++++++++++- .../codehike/src/code/extract-annotations.tsx | 85 +++++++++---------- 2 files changed, 98 insertions(+), 44 deletions(-) diff --git a/packages/codehike/src/code/extract-annotations.test.ts b/packages/codehike/src/code/extract-annotations.test.ts index 40f19f45..49fce8a3 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) { @@ -153,6 +153,36 @@ test("multiple start/end pairs of same name", async () => { 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)", @@ -195,6 +225,31 @@ test("start/end removes comment lines from code", async () => { 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", diff --git a/packages/codehike/src/code/extract-annotations.tsx b/packages/codehike/src/code/extract-annotations.tsx index 0908d522..190c668e 100644 --- a/packages/codehike/src/code/extract-annotations.tsx +++ b/packages/codehike/src/code/extract-annotations.tsx @@ -22,70 +22,69 @@ function getLineFromRange(range: any): number { } function processStartEndMarkers(annotations: Annotation[]): Annotation[] { - const regular: Annotation[] = [] - const starts: { name: string; query: string; line: number }[] = [] - const ends: { name: string; query: string; line: number }[] = [] + const stacks = new Map< + string, + { name: string; query: string; line: number; order: number }[] + >() + const orderedAnnotations: { order: number; annotation: Annotation }[] = [] - for (const a of annotations) { + for (const [order, a] of annotations.entries()) { const q = a.query ?? "" if (q.startsWith(START_MARKER)) { - starts.push({ + 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)) { - ends.push({ - name: a.name, - query: q.slice(END_MARKER.length), - line: getLineFromRange(a.ranges[0]), + 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 + } + + orderedAnnotations.push({ + order: start.order, + annotation: { + name: start.name, + query: start.query, + ranges: [{ fromLineNumber: start.line, toLineNumber: endLine }], + }, }) } else { - regular.push(a) + orderedAnnotations.push({ order, annotation: a }) } } - if (starts.length === 0 && ends.length === 0) { + if (orderedAnnotations.length === annotations.length) { return annotations } - const paired: Annotation[] = [] - const usedEnds = new Set() - - for (const start of starts) { - // find the first unused end with the same name that comes after the start - const endIndex = ends.findIndex( - (e, i) => - !usedEnds.has(i) && - e.name === start.name && - e.line >= start.line, - ) - if (endIndex === -1) { + for (const stack of stacks.values()) { + for (const start of stack) { console.warn( `Code Hike warning: Unmatched !${start.name}(start) annotation`, ) - continue - } - usedEnds.add(endIndex) - const end = ends[endIndex] - paired.push({ - name: start.name, - query: start.query, - ranges: [ - { fromLineNumber: start.line, toLineNumber: end.line - 1 }, - ], - }) - } - - for (let i = 0; i < ends.length; i++) { - if (!usedEnds.has(i)) { - console.warn( - `Code Hike warning: Unmatched !${ends[i].name}(end) annotation`, - ) } } - return [...regular, ...paired] + orderedAnnotations.sort((a, b) => a.order - b.order) + return orderedAnnotations.map((entry) => entry.annotation) } async function extractCommentAnnotations( From 275e3c6ea0643e37ecbc982415966c41218c4843 Mon Sep 17 00:00:00 2001 From: Rodrigo Pombo Date: Mon, 16 Mar 2026 16:55:10 +0100 Subject: [PATCH 3/8] Fix CI type errors for annotation tests --- .../src/code/extract-annotations.test.ts | 24 +++++++++++++------ .../codehike/src/code/extract-annotations.tsx | 4 ++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/codehike/src/code/extract-annotations.test.ts b/packages/codehike/src/code/extract-annotations.test.ts index 49fce8a3..9ed43ba2 100644 --- a/packages/codehike/src/code/extract-annotations.test.ts +++ b/packages/codehike/src/code/extract-annotations.test.ts @@ -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") @@ -80,7 +88,7 @@ test("start/end creates block annotation spanning the range", async () => { const a = annotations[0] expect(a.name).toEqual("focus") expect(a.ranges).toHaveLength(1) - const range = a.ranges[0] + 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) @@ -123,8 +131,9 @@ test("start/end works with other annotations", async () => { const focus = annotations.find((a) => a.name === "focus") expect(mark).toBeDefined() expect(focus).toBeDefined() - expect(focus!.ranges[0].fromLineNumber).toBeDefined() - expect(focus!.ranges[0].toLineNumber).toBeDefined() + const range = getBlockRange(focus!) + expect(range.fromLineNumber).toBeDefined() + expect(range.toLineNumber).toBeDefined() }) test("multiple start/end pairs of same name", async () => { @@ -148,8 +157,8 @@ test("multiple start/end pairs of same name", async () => { expect(annotations[0].name).toEqual("focus") expect(annotations[1].name).toEqual("focus") // The two ranges should not overlap - const r0 = annotations[0].ranges[0] - const r1 = annotations[1].ranges[0] + const r0 = getBlockRange(annotations[0]) + const r1 = getBlockRange(annotations[1]) expect(r0.toLineNumber).toBeLessThan(r1.fromLineNumber) }) @@ -262,8 +271,9 @@ test("start/end works with Python comments", async () => { const { annotations } = await splitAnnotationsAndCode(code, "python", "!") expect(annotations).toHaveLength(1) expect(annotations[0].name).toEqual("focus") - expect(annotations[0].ranges[0].fromLineNumber).toEqual(2) - expect(annotations[0].ranges[0].toLineNumber).toEqual(3) + const range = getBlockRange(annotations[0]) + expect(range.fromLineNumber).toEqual(2) + expect(range.toLineNumber).toEqual(3) }) test("start/end works with block comments", async () => { diff --git a/packages/codehike/src/code/extract-annotations.tsx b/packages/codehike/src/code/extract-annotations.tsx index 190c668e..4b969755 100644 --- a/packages/codehike/src/code/extract-annotations.tsx +++ b/packages/codehike/src/code/extract-annotations.tsx @@ -121,10 +121,10 @@ async function extractCommentAnnotations( // Handle start/end range markers: !name(start) and !name(end) if (rangeString === "(start)") { query = START_MARKER + query - rangeString = undefined + rangeString = "(1)" } else if (rangeString === "(end)") { query = END_MARKER + query - rangeString = undefined + rangeString = "(1)" } return { From b4d10f5330c6c30a79eed5a0f42710bec822d51c Mon Sep 17 00:00:00 2001 From: Rodrigo Pombo Date: Mon, 16 Mar 2026 19:33:24 +0100 Subject: [PATCH 4/8] Add changeset for start/end annotation comments --- .changeset/bright-llamas-tie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bright-llamas-tie.md diff --git a/.changeset/bright-llamas-tie.md b/.changeset/bright-llamas-tie.md new file mode 100644 index 00000000..2247d119 --- /dev/null +++ b/.changeset/bright-llamas-tie.md @@ -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. From 1d9a6d81db3da6ab76ad646c242a1cdbc9a3957e Mon Sep 17 00:00:00 2001 From: Rodrigo Pombo Date: Mon, 16 Mar 2026 19:35:47 +0100 Subject: [PATCH 5/8] Document start end annotation comments --- apps/web/content/docs/code/focus.mdx | 2 ++ .../web/content/docs/concepts/annotations.mdx | 19 +++++++++++++++++-- apps/web/demos/focus/content.md | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/web/content/docs/code/focus.mdx b/apps/web/content/docs/code/focus.mdx index f2c1c166..7dcb7239 100644 --- a/apps/web/content/docs/code/focus.mdx +++ b/apps/web/content/docs/code/focus.mdx @@ -10,6 +10,8 @@ Focus blocks of code. Dim the unfocused code. Ensure the focused code is visible Useful when you want to change a codeblock focus depending on the user's interaction. +The demo below uses `!focus(start)` and `!focus(end)` comments to define the focused range. + ## !implementation diff --git a/apps/web/content/docs/concepts/annotations.mdx b/apps/web/content/docs/concepts/annotations.mdx index 7a5dddb5..e6b6660f 100644 --- a/apps/web/content/docs/concepts/annotations.mdx +++ b/apps/web/content/docs/concepts/annotations.mdx @@ -40,13 +40,28 @@ 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)` or `// !name(start)`, but in python we use `# !name(1:5)` or `# !name(start)`. 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: -- **Block annotations** are applied to a block of lines. They use parenthesis `()` to define the range of lines. The numbers are relative to the line where the comment is placed. +- **Block annotations** are applied to a block of lines. They can use parenthesis `()` to define the range of lines, or `!name(start)` / `!name(end)` comments to mark the beginning and end of the block. Numeric ranges are relative to the line where the comment is placed. - **Inline annotations** are applied to a group of tokens inside a line. They use square brackets `[]` to define the range of columns included in the annotation. +For example, these two block annotations are equivalent: + +```js +// !focus(1:2) +const b = 2 +const c = 3 +``` + +```js +// !focus(start) +const b = 2 +const c = 3 +// !focus(end) +``` + ## Annotation Query Any extra content in the annotation comment is passed to the annotation components as `query`. diff --git a/apps/web/demos/focus/content.md b/apps/web/demos/focus/content.md index 461c3621..b7026f0f 100644 --- a/apps/web/demos/focus/content.md +++ b/apps/web/demos/focus/content.md @@ -9,10 +9,11 @@ function ipsum(ipsum, dolor = 1) { return dolor } -// !focus(1:5) +// !focus(start) function dolor(ipsum, dolor = 1) { const sit = ipsum == null ? 0 : ipsum.sit dolor = sit - amet(dolor) return sit ? consectetur(ipsum) : [] } +// !focus(end) ``` From eaa0b0b173a6f05b0dd1b5b4a0b1021403a10450 Mon Sep 17 00:00:00 2001 From: Rodrigo Pombo Date: Tue, 17 Mar 2026 09:18:56 +0100 Subject: [PATCH 6/8] Revert docs --- apps/web/content/docs/code/focus.mdx | 2 -- .../web/content/docs/concepts/annotations.mdx | 19 ++----------------- apps/web/demos/focus/content.md | 3 +-- 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/apps/web/content/docs/code/focus.mdx b/apps/web/content/docs/code/focus.mdx index 7dcb7239..f2c1c166 100644 --- a/apps/web/content/docs/code/focus.mdx +++ b/apps/web/content/docs/code/focus.mdx @@ -10,8 +10,6 @@ Focus blocks of code. Dim the unfocused code. Ensure the focused code is visible Useful when you want to change a codeblock focus depending on the user's interaction. -The demo below uses `!focus(start)` and `!focus(end)` comments to define the focused range. - ## !implementation diff --git a/apps/web/content/docs/concepts/annotations.mdx b/apps/web/content/docs/concepts/annotations.mdx index e6b6660f..cdbfe7b5 100644 --- a/apps/web/content/docs/concepts/annotations.mdx +++ b/apps/web/content/docs/concepts/annotations.mdx @@ -40,28 +40,13 @@ 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)` or `// !name(start)`, but in python we use `# !name(1:5)` or `# !name(start)`. 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: -- **Block annotations** are applied to a block of lines. They can use parenthesis `()` to define the range of lines, or `!name(start)` / `!name(end)` comments to mark the beginning and end of the block. Numeric ranges are relative to the line where the comment is placed. +- **Block annotations** are applied to a block of lines. They use parenthesis `()` to define the range of lines. The numbers are relative to the line where the comment is placed. - **Inline annotations** are applied to a group of tokens inside a line. They use square brackets `[]` to define the range of columns included in the annotation. -For example, these two block annotations are equivalent: - -```js -// !focus(1:2) -const b = 2 -const c = 3 -``` - -```js -// !focus(start) -const b = 2 -const c = 3 -// !focus(end) -``` - ## Annotation Query Any extra content in the annotation comment is passed to the annotation components as `query`. diff --git a/apps/web/demos/focus/content.md b/apps/web/demos/focus/content.md index b7026f0f..461c3621 100644 --- a/apps/web/demos/focus/content.md +++ b/apps/web/demos/focus/content.md @@ -9,11 +9,10 @@ function ipsum(ipsum, dolor = 1) { return dolor } -// !focus(start) +// !focus(1:5) function dolor(ipsum, dolor = 1) { const sit = ipsum == null ? 0 : ipsum.sit dolor = sit - amet(dolor) return sit ? consectetur(ipsum) : [] } -// !focus(end) ``` From 291d9fad5ae4c501ac17291a661c6f0fa297b016 Mon Sep 17 00:00:00 2001 From: Rodrigo Pombo Date: Tue, 17 Mar 2026 09:27:04 +0100 Subject: [PATCH 7/8] Annotation docs --- .../web/content/docs/concepts/annotations.mdx | 6 +++++ .../demos/annotations/start-end/content.md | 9 +++++++ apps/web/demos/annotations/start-end/page.tsx | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 apps/web/demos/annotations/start-end/content.md create mode 100644 apps/web/demos/annotations/start-end/page.tsx diff --git a/apps/web/content/docs/concepts/annotations.mdx b/apps/web/content/docs/concepts/annotations.mdx index cdbfe7b5..7b8a9782 100644 --- a/apps/web/content/docs/concepts/annotations.mdx +++ b/apps/web/content/docs/concepts/annotations.mdx @@ -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}
+ ), +} From 64eee9d60a254bbb104798caae03faf58412940c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 08:32:03 +0000 Subject: [PATCH 8/8] codehike@1.1.0 --- .changeset/bright-llamas-tie.md | 5 ----- packages/codehike/CHANGELOG.md | 6 ++++++ packages/codehike/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/bright-llamas-tie.md diff --git a/.changeset/bright-llamas-tie.md b/.changeset/bright-llamas-tie.md deleted file mode 100644 index 2247d119..00000000 --- a/.changeset/bright-llamas-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"codehike": minor ---- - -Add support for `!name(start)` and `!name(end)` comment markers as an alternative way to define multi-line code annotation ranges. 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",