From d8ac2571503b809d02bb08f6b1a6df1126fe9639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3zsa=20Zolt=C3=A1n?= <67325669+rozsazoltan@users.noreply.github.com> Date: Mon, 16 Jun 2025 10:46:20 +0200 Subject: [PATCH 01/12] unshiki function --- src/components/code-example.tsx | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/components/code-example.tsx b/src/components/code-example.tsx index 4fd1c86b2..3568fd17e 100644 --- a/src/components/code-example.tsx +++ b/src/components/code-example.tsx @@ -38,6 +38,55 @@ export function css(strings: TemplateStringsArray, ...args: any[]) { return { lang: "css", code: dedent(strings, ...args) }; } +export function unshiki(code: string): string { + const lines = code.split("\n"); + const result: string[] = []; + let skip = 0; + + const commentRegex = /\/\*.*?\*\/|\/\/.*||#.*/g; + const codeTagRegex = /\[!code\s+([^\]]+)\]/; + + for (let i = 0; i < lines.length; i++) { + // skip lines if a remove directive is active + if (skip > 0) { + skip--; + continue; + } + + let line = lines[i]; + const comments = [...line.matchAll(commentRegex)]; + + let removed = false; + + // process comments to detect [!code ...] directives + for (const c of comments) { + const match = c[0].match(codeTagRegex); + if (match) { + // check if directive to remove next N lines + const spec = match[1]; + const removeMatch = spec.match(/^--:(\d+)$/); + if (removeMatch) { + // set lines to skip + skip = parseInt(removeMatch[1], 10) - 1; + // current line removed (important if the line is not just a comment but also valid code) + removed = true; + break; + } + + // remove comment if it's not a remove directive + line = line.slice(0, c.index) + line.slice(c.index! + c[0].length); + } + } + + // add line if not removed and line is not empty or has no comments + if (!removed && (comments.length === 0 || line.trim() !== "")) { + result.push(line); + } + } + + return result.join('\n').trim(); +} + export async function CodeExample({ example, filename, From c395d86187abbd0d221745a2f6cc8c3673c6ebce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3zsa=20Zolt=C3=A1n?= <67325669+rozsazoltan@users.noreply.github.com> Date: Mon, 16 Jun 2025 10:46:42 +0200 Subject: [PATCH 02/12] copy btn component --- src/components/code-example.tsx | 7 +++- src/components/copy-button.tsx | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/components/copy-button.tsx diff --git a/src/components/code-example.tsx b/src/components/code-example.tsx index 3568fd17e..ed53ba1bc 100644 --- a/src/components/code-example.tsx +++ b/src/components/code-example.tsx @@ -8,6 +8,7 @@ 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"; @@ -97,8 +98,12 @@ export async function CodeExample({ className?: string; }) { return ( - + {filename ? : null} + ); diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx new file mode 100644 index 000000000..5e48677e3 --- /dev/null +++ b/src/components/copy-button.tsx @@ -0,0 +1,70 @@ +"use client"; + +import clsx from "clsx"; +import { useState } from "react"; +import { unshiki } from "./code-example"; + +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(unshiki(value)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + } + + return ( + + ) +} From 588863d68153f00cd651af8d0d838be05ce75d43 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 12:50:49 -0400 Subject: [PATCH 03/12] Cleanup --- src/components/code-example.tsx | 59 +++------------------------------ src/components/copy-button.tsx | 32 ++++++------------ src/components/shiki.ts | 48 +++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 77 deletions(-) create mode 100644 src/components/shiki.ts diff --git a/src/components/code-example.tsx b/src/components/code-example.tsx index ed53ba1bc..a248d5149 100644 --- a/src/components/code-example.tsx +++ b/src/components/code-example.tsx @@ -14,6 +14,7 @@ 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) }; @@ -39,55 +40,6 @@ export function css(strings: TemplateStringsArray, ...args: any[]) { return { lang: "css", code: dedent(strings, ...args) }; } -export function unshiki(code: string): string { - const lines = code.split("\n"); - const result: string[] = []; - let skip = 0; - - const commentRegex = /\/\*.*?\*\/|\/\/.*||#.*/g; - const codeTagRegex = /\[!code\s+([^\]]+)\]/; - - for (let i = 0; i < lines.length; i++) { - // skip lines if a remove directive is active - if (skip > 0) { - skip--; - continue; - } - - let line = lines[i]; - const comments = [...line.matchAll(commentRegex)]; - - let removed = false; - - // process comments to detect [!code ...] directives - for (const c of comments) { - const match = c[0].match(codeTagRegex); - if (match) { - // check if directive to remove next N lines - const spec = match[1]; - const removeMatch = spec.match(/^--:(\d+)$/); - if (removeMatch) { - // set lines to skip - skip = parseInt(removeMatch[1], 10) - 1; - // current line removed (important if the line is not just a comment but also valid code) - removed = true; - break; - } - - // remove comment if it's not a remove directive - line = line.slice(0, c.index) + line.slice(c.index! + c[0].length); - } - } - - // add line if not removed and line is not empty or has no comments - if (!removed && (comments.length === 0 || line.trim() !== "")) { - result.push(line); - } - } - - return result.join('\n').trim(); -} - export async function CodeExample({ example, filename, @@ -98,12 +50,9 @@ export async function CodeExample({ className?: string; }) { return ( - + {filename ? : null} - + ); @@ -246,7 +195,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 index 5e48677e3..5c2ab86c7 100644 --- a/src/components/copy-button.tsx +++ b/src/components/copy-button.tsx @@ -2,33 +2,26 @@ import clsx from "clsx"; import { useState } from "react"; -import { unshiki } from "./code-example"; -export function CopyButton({ - value, - className = "", -}: { - value: string; - className?: string; -}) { +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(unshiki(value)); + 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/shiki.ts b/src/components/shiki.ts new file mode 100644 index 000000000..57923481c --- /dev/null +++ b/src/components/shiki.ts @@ -0,0 +1,48 @@ +const commentRegex = /\/\*\s*.*?\s*\*\/|\/\/\s*.*||#\s*.*/g; +const codeTagRegex = /\[!code\s+([^\]]+)\]/; + +export function stripShikiComments(code: string): string { + let lines = code.split("\n"); + let result: string[] = []; + let skip = 0; + + for (let i = 0; i < lines.length; i++) { + // skip lines if a remove directive is active + if (skip > 0) { + skip--; + continue; + } + + let line = lines[i]; + let comments = [...line.matchAll(commentRegex)]; + + let removed = false; + + // process comments to detect [!code ...] directives + for (let c of comments) { + let match = c[0].match(codeTagRegex); + if (!match) continue; + + // check if directive to remove next N lines + let spec = match[1]; + let removeMatch = spec.match(/^--:(\d+)$/); + if (removeMatch) { + // set lines to skip + skip = parseInt(removeMatch[1], 10) - 1; + // current line removed (important if the line is not just a comment but also valid code) + removed = true; + break; + } + + // remove comment if it's not a remove directive + line = line.slice(0, c.index) + line.slice(c.index! + c[0].length); + } + + // add line if not removed and line is not empty or has no comments + if (!removed && (comments.length === 0 || line.trim() !== "")) { + result.push(line); + } + } + + return result.join("\n").trim(); +} From e99146d6b48ff13c0245a0375181073b113c2c3f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 13:38:39 -0400 Subject: [PATCH 04/12] Refactor --- src/components/shiki.ts | 70 ++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/src/components/shiki.ts b/src/components/shiki.ts index 57923481c..9e4e367f4 100644 --- a/src/components/shiki.ts +++ b/src/components/shiki.ts @@ -1,48 +1,60 @@ -const commentRegex = /\/\*\s*.*?\s*\*\/|\/\/\s*.*||#\s*.*/g; -const codeTagRegex = /\[!code\s+([^\]]+)\]/; +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: string[] = []; - let skip = 0; + let result = ""; for (let i = 0; i < lines.length; i++) { - // skip lines if a remove directive is active - if (skip > 0) { - skip--; - continue; - } - let line = lines[i]; - let comments = [...line.matchAll(commentRegex)]; - let removed = false; + let changed = false; + + for (let c of line.matchAll(commentPattern)) { + let content = c[1] ?? c[2] ?? c[3]; - // process comments to detect [!code ...] directives - for (let c of comments) { - let match = c[0].match(codeTagRegex); + let match = content.match(controlPattern); if (!match) continue; - // check if directive to remove next N lines - let spec = match[1]; - let removeMatch = spec.match(/^--:(\d+)$/); - if (removeMatch) { - // set lines to skip - skip = parseInt(removeMatch[1], 10) - 1; - // current line removed (important if the line is not just a comment but also valid code) + 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 === "--") { + if (!params) continue; + + // 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, 10) - 1; + if (isNaN(count)) continue; + i += count; + break; } - // remove comment if it's not a remove directive - line = line.slice(0, c.index) + line.slice(c.index! + c[0].length); + // 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; } - // add line if not removed and line is not empty or has no comments - if (!removed && (comments.length === 0 || line.trim() !== "")) { - result.push(line); - } + // 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.join("\n").trim(); + return result.trim(); } From bd89231392d060b75de1ee3fc2b420d58538db17 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 14:08:30 -0400 Subject: [PATCH 05/12] Remove lines marked with `[!code --]` --- src/components/shiki.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/shiki.ts b/src/components/shiki.ts index 9e4e367f4..0729f112d 100644 --- a/src/components/shiki.ts +++ b/src/components/shiki.ts @@ -24,13 +24,11 @@ export function stripShikiComments(code: string): string { // If we see a `[!code --]` or `[!code --:N]` directive it means we need // to remove N lines starting at the current line if (kind === "--") { - if (!params) continue; - // 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, 10) - 1; + let count = parseInt(params ?? "1", 10) - 1; if (isNaN(count)) continue; i += count; From 963cd24152fe7898e10e382f4a4936ba486bc08c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 14:06:46 -0400 Subject: [PATCH 06/12] Add test --- src/components/shiki.test.ts | 112 +++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/components/shiki.test.ts 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 + `, + ); + }); +}); From f88ee1989623c183b008b467a81e7c103fb3f4f3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 14:27:22 -0400 Subject: [PATCH 07/12] Tweak copy button styling --- src/components/copy-button.tsx | 56 ++++++++++++++++------------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx index 5c2ab86c7..e8d7438b3 100644 --- a/src/components/copy-button.tsx +++ b/src/components/copy-button.tsx @@ -21,38 +21,34 @@ export function CopyButton({ value, className }: { value: string; className?: st return ( ); } From 584ff3a6c44a943ffaf042aa7571f67a58330dff Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 14:30:49 -0400 Subject: [PATCH 08/12] more button tweaks --- src/components/copy-button.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx index e8d7438b3..f1a6cc2ab 100644 --- a/src/components/copy-button.tsx +++ b/src/components/copy-button.tsx @@ -21,15 +21,15 @@ export function CopyButton({ value, className }: { value: string; className?: st return ( ); } From 2dd99dd0695e923b4fa9ee84acab4dfce82aab7b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 16:16:40 -0400 Subject: [PATCH 10/12] Fix display of stacked code examples --- src/components/code-example.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/code-example.tsx b/src/components/code-example.tsx index 7fd361edc..8c0a11917 100644 --- a/src/components/code-example.tsx +++ b/src/components/code-example.tsx @@ -50,17 +50,19 @@ export async function CodeExample({ className?: string; }) { return ( - - {filename ? : null} - + +
+ {filename ? : null} + +
); @@ -72,6 +74,7 @@ export function CodeExampleWrapper({ className, children }: { className?: string
From d43119665f628cd099100617743854de1c27961e Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 16:16:55 -0400 Subject: [PATCH 11/12] Add copy button to tabbed code blocks --- src/components/code-example.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/code-example.tsx b/src/components/code-example.tsx index 8c0a11917..9db8f0951 100644 --- a/src/components/code-example.tsx +++ b/src/components/code-example.tsx @@ -109,7 +109,7 @@ export function CodeExampleGroup({
@@ -134,6 +134,13 @@ export function CodeExampleGroup({ export function CodeBlock({ example }: { example: { lang: string; code: string } }) { return ( + ); From 358e1a976f6c53900476938b42d503aa06085f76 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 8 Jul 2025 16:17:25 -0400 Subject: [PATCH 12/12] Add copy button to multi cursor animation Is this really necessary? No. But it is fun :D --- src/components/multi-cursor/animation.tsx | 15 ++++ src/components/multi-cursor/example.tsx | 100 ++++++++++++---------- 2 files changed, 68 insertions(+), 47 deletions(-) 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 ; + }, + }} + /> + ); }