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,