@@ -120,6 +134,13 @@ export function CodeExampleGroup({
export function CodeBlock({ example }: { example: { lang: string; code: string } }) {
return (
+
);
@@ -192,7 +213,7 @@ function CodeExampleFilename({ filename }: { filename: string }) {
return
{filename}
;
}
-const highlighter = await createHighlighter({
+let highlighter = await createHighlighter({
themes: [theme],
langs: [
atApplyInjection as any,
diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx
new file mode 100644
index 000000000..c825910c0
--- /dev/null
+++ b/src/components/copy-button.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import clsx from "clsx";
+import { useState } from "react";
+
+export function CopyButton({ value, className }: { value: string; className?: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ if (copied) return;
+
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/multi-cursor/animation.tsx b/src/components/multi-cursor/animation.tsx
index f63b498f5..8bd1bb62d 100644
--- a/src/components/multi-cursor/animation.tsx
+++ b/src/components/multi-cursor/animation.tsx
@@ -2,6 +2,7 @@
import clsx from "clsx";
import { createContext, useContext, useEffect, useState } from "react";
+import { CopyButton } from "../copy-button";
export interface AnimationContextBag {
/** Where the cursor is at */
@@ -250,3 +251,17 @@ export function TypingAnimation(props: Record
) {
));
}
+
+export function AnimationCopyButton({ code }: { code: string }) {
+ let { className } = useContext(AnimationContext);
+
+ return (
+
+ );
+}
diff --git a/src/components/multi-cursor/example.tsx b/src/components/multi-cursor/example.tsx
index fb8f09f73..1689192d8 100644
--- a/src/components/multi-cursor/example.tsx
+++ b/src/components/multi-cursor/example.tsx
@@ -2,7 +2,8 @@ import clsx from "clsx";
import { ThemedToken } from "shiki";
import { html } from "../code-example";
import { HighlightedCode } from "../highlight";
-import { TypingAnimation } from "./animation";
+import { AnimationCopyButton, TypingAnimation } from "./animation";
+import { stripShikiComments } from "../shiki";
export function MultiCursorCode() {
let code = html`
@@ -24,62 +25,67 @@ export function MultiCursorCode() {
`;
return (
-
+
+ {
- let tokens: ThemedToken[] = [];
+ return tokens.map((line, idx) => {
+ let tokens: ThemedToken[] = [];
- for (let token of line) {
- let classIndex = token.content.indexOf("__CLASS__");
+ for (let token of line) {
+ let classIndex = token.content.indexOf("__CLASS__");
- if (classIndex === -1) {
- tokens.push(token);
- continue;
- }
+ if (classIndex === -1) {
+ tokens.push(token);
+ continue;
+ }
- let before = token.content.slice(0, classIndex);
- let after = token.content.slice(classIndex + 9);
+ let before = token.content.slice(0, classIndex);
+ let after = token.content.slice(classIndex + 9);
- if (before) {
- tokens.push({ ...token, content: before });
- }
+ if (before) {
+ tokens.push({ ...token, content: before });
+ }
- tokens.push({
- ...token,
- content: "__CLASS__",
- htmlAttrs: {
- index: `${count++}`,
- },
- });
+ tokens.push({
+ ...token,
+ content: "__CLASS__",
+ htmlAttrs: {
+ index: `${count++}`,
+ },
+ });
- if (after) {
- tokens.push({ ...token, content: after });
+ if (after) {
+ tokens.push({ ...token, content: after });
+ }
}
- }
- return tokens;
- });
+ return tokens;
+ });
+ },
},
- },
- ]}
- components={{
- span: (props) => {
- if (props.children === "__CLASS__") {
- return ;
- }
+ ]}
+ components={{
+ span: (props) => {
+ if (props.children === "__CLASS__") {
+ return ;
+ }
- return ;
- },
- }}
- />
+ return ;
+ },
+ }}
+ />
+ >
);
}
diff --git a/src/components/shiki.test.ts b/src/components/shiki.test.ts
new file mode 100644
index 000000000..90369ef4e
--- /dev/null
+++ b/src/components/shiki.test.ts
@@ -0,0 +1,112 @@
+// Note: can run these tests with `bun test`
+
+import { describe, test } from "node:test";
+import { stripShikiComments } from "./shiki";
+import dedent from "dedent";
+
+describe("comment removal", () => {
+ test("at end of line", (t) => {
+ let input = dedent`
+ keep me # [!code highlight]
+ keep me /* [!code highlight] */
+ keep me // [!code highlight]
+ keep me
+ `;
+
+ t.assert.equal(
+ stripShikiComments(input),
+ dedent`
+ keep me \n\
+ keep me \n\
+ keep me \n\
+ keep me \
+ `,
+ );
+ });
+
+ test("on separate lines", (t) => {
+ let input = dedent`
+ # [!code highlight]
+ /* [!code highlight] */
+ // [!code highlight]
+
+
+ `;
+
+ t.assert.equal(
+ stripShikiComments(input),
+ dedent`
+
+ `,
+ );
+ });
+});
+
+// [!code --] and [!code --:N] handling
+describe("line removal", () => {
+ test("at end of line", (t) => {
+ let input = dedent`
+ keep me
+ remove me 1 # [!code --]
+ keep me
+ `;
+
+ t.assert.equal(
+ stripShikiComments(input),
+ dedent`
+ keep me
+ keep me
+ `,
+ );
+ });
+
+ test("on separate lines", (t) => {
+ let input = dedent`
+ keep me
+ # [!code --:3]
+ remove me 1
+ remove me 2
+ keep me
+ `;
+
+ t.assert.equal(
+ stripShikiComments(input),
+ dedent`
+ keep me
+ keep me
+ `,
+ );
+ });
+
+ test("an invalid number still removes the line its on", (t) => {
+ let input = dedent`
+ keep me
+ remove me # [!code --:foo]
+ keep me
+ `;
+
+ t.assert.equal(
+ stripShikiComments(input),
+ dedent`
+ keep me
+ keep me
+ `,
+ );
+ });
+
+ test("an invalid number is ignored on separate lines", (t) => {
+ let input = dedent`
+ keep me
+ # [!code --:foo]
+ keep me
+ `;
+
+ t.assert.equal(
+ stripShikiComments(input),
+ dedent`
+ keep me
+ keep me
+ `,
+ );
+ });
+});
diff --git a/src/components/shiki.ts b/src/components/shiki.ts
new file mode 100644
index 000000000..0729f112d
--- /dev/null
+++ b/src/components/shiki.ts
@@ -0,0 +1,58 @@
+const commentPattern = /\/\*\s*(?=\[!)(.*?)\s*\*\/\s*$|\s*$|(?:#|\/\/)\s*(?=\[!)(.*)\s*$/g;
+const controlPattern = /^\[!code\s+([^:]+)(?::(.*))?\]$/;
+
+export function stripShikiComments(code: string): string {
+ if (!code.includes("[!code ")) return code;
+
+ let lines = code.split("\n");
+ let result = "";
+
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i];
+ let removed = false;
+ let changed = false;
+
+ for (let c of line.matchAll(commentPattern)) {
+ let content = c[1] ?? c[2] ?? c[3];
+
+ let match = content.match(controlPattern);
+ if (!match) continue;
+
+ let kind = match[1];
+ let params = match[2];
+
+ // If we see a `[!code --]` or `[!code --:N]` directive it means we need
+ // to remove N lines starting at the current line
+ if (kind === "--") {
+ // Remove the line containing the `[!code --]` directive
+ removed = true;
+
+ // Remove the remaining N-1 lines after the current line (if specified)
+ let count = parseInt(params ?? "1", 10) - 1;
+ if (isNaN(count)) continue;
+ i += count;
+
+ break;
+ }
+
+ // Remove the comment from the current line
+ //
+ // NOTE: This has an implicit assumption that the line MUST end with a
+ // control comment. Processing multiple comments on one line would
+ // mangle the code. This is enforced by the regex patterns above.
+ line = line.slice(0, c.index) + line.slice(c.index + c[0].length);
+ changed = true;
+ }
+
+ // The current line was removed so we can skip it
+ if (removed) continue;
+
+ // This line only contained control comments which have been removed
+ if (changed && line.trim() === "") continue;
+
+ result += line;
+ result += "\n";
+ }
+
+ return result.trim();
+}