diff --git a/src/components/code-example.tsx b/src/components/code-example.tsx index 4fd1c86b2..9db8f0951 100644 --- a/src/components/code-example.tsx +++ b/src/components/code-example.tsx @@ -8,11 +8,13 @@ import { clsx } from "clsx"; import dedent from "dedent"; import { createHighlighter } from "shiki"; import theme from "./syntax-highlighter/theme.json"; +import { CopyButton } from "./copy-button"; import { highlightClasses } from "./highlight-classes"; import atApplyInjection from "./syntax-highlighter/at-apply.json"; import atRulesInjection from "./syntax-highlighter/at-rules.json"; import themeFnInjection from "./syntax-highlighter/theme-fn.json"; +import { stripShikiComments } from "./shiki"; export function js(strings: TemplateStringsArray, ...args: any[]) { return { lang: "js", code: dedent(strings, ...args) }; @@ -49,7 +51,18 @@ export async function CodeExample({ }) { return ( - {filename ? : null} +
+ {filename ? : null} + +
); @@ -61,6 +74,7 @@ export function CodeExampleWrapper({ className, children }: { className?: string
@@ -95,7 +109,7 @@ export function CodeExampleGroup({
@@ -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(); +}