diff --git a/src/globals.d.ts b/src/globals.d.ts
new file mode 100644
index 0000000..e3aad0b
--- /dev/null
+++ b/src/globals.d.ts
@@ -0,0 +1,3 @@
+interface Window {
+ addCleanup(callback: () => void): void;
+}
diff --git a/src/scripts/clipboard.inline.ts b/src/scripts/clipboard.inline.ts
new file mode 100644
index 0000000..6d59a9f
--- /dev/null
+++ b/src/scripts/clipboard.inline.ts
@@ -0,0 +1,39 @@
+const svgCopy =
+ '';
+const svgCheck =
+ '';
+
+document.addEventListener("nav", () => {
+ const els = document.getElementsByTagName("pre");
+ for (let i = 0; i < els.length; i++) {
+ const el = els[i];
+ if (!el) continue;
+ const codeBlock = el.getElementsByTagName("code")[0];
+ if (codeBlock) {
+ const source = (
+ codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
+ ).replace(/\n\n/g, "\n");
+ const button = document.createElement("button");
+ button.className = "clipboard-button";
+ button.type = "button";
+ button.innerHTML = svgCopy;
+ button.ariaLabel = "Copy source";
+ function onClick() {
+ navigator.clipboard.writeText(source).then(
+ () => {
+ button.blur();
+ button.innerHTML = svgCheck;
+ setTimeout(() => {
+ button.innerHTML = svgCopy;
+ button.style.borderColor = "";
+ }, 2000);
+ },
+ (error) => console.error(error),
+ );
+ }
+ button.addEventListener("click", onClick);
+ window.addCleanup(() => button.removeEventListener("click", onClick));
+ el.prepend(button);
+ }
+ }
+});
diff --git a/src/styles.d.ts b/src/styles.d.ts
new file mode 100644
index 0000000..0a31e5e
--- /dev/null
+++ b/src/styles.d.ts
@@ -0,0 +1,4 @@
+declare module "*.scss" {
+ const content: string;
+ export default content;
+}
diff --git a/src/styles/clipboard.scss b/src/styles/clipboard.scss
new file mode 100644
index 0000000..196b894
--- /dev/null
+++ b/src/styles/clipboard.scss
@@ -0,0 +1,36 @@
+.clipboard-button {
+ position: absolute;
+ display: flex;
+ float: right;
+ right: 0;
+ padding: 0.4rem;
+ margin: 0.3rem;
+ color: var(--gray);
+ border-color: var(--dark);
+ background-color: var(--light);
+ border: 1px solid;
+ border-radius: 5px;
+ opacity: 0;
+ transition: 0.2s;
+
+ & > svg {
+ fill: var(--light);
+ filter: contrast(0.3);
+ }
+
+ &:hover {
+ cursor: pointer;
+ border-color: var(--secondary);
+ }
+
+ &:focus {
+ outline: 0;
+ }
+}
+
+pre {
+ &:hover > .clipboard-button {
+ opacity: 1;
+ transition: 0.2s;
+ }
+}
diff --git a/src/transformer.ts b/src/transformer.ts
index bc3ca55..613dd94 100644
--- a/src/transformer.ts
+++ b/src/transformer.ts
@@ -1,6 +1,9 @@
import rehypePrettyCode from "rehype-pretty-code";
import type { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code";
-import type { QuartzTransformerPlugin } from "@quartz-community/types";
+import type { QuartzTransformerPlugin, JSResource, CSSResource } from "@quartz-community/types";
+// @ts-ignore
+import clipboardScript from "./scripts/clipboard.inline";
+import clipboardStyle from "./styles/clipboard.scss";
interface Theme extends Record {
light: CodeTheme;
@@ -10,6 +13,7 @@ interface Theme extends Record {
export interface SyntaxHighlightingOptions {
theme?: Theme;
keepBackground?: boolean;
+ clipboard?: boolean;
}
const defaultOptions: SyntaxHighlightingOptions = {
@@ -18,16 +22,37 @@ const defaultOptions: SyntaxHighlightingOptions = {
dark: "github-dark",
},
keepBackground: false,
+ clipboard: true,
};
export const SyntaxHighlighting: QuartzTransformerPlugin> = (
userOpts,
) => {
- const opts: CodeOptions = { ...defaultOptions, ...userOpts };
+ const opts = { ...defaultOptions, ...userOpts };
+ const { clipboard, ...codeOpts } = opts;
return {
name: "SyntaxHighlighting",
htmlPlugins() {
- return [[rehypePrettyCode, opts]];
+ return [[rehypePrettyCode, codeOpts as CodeOptions]];
+ },
+ externalResources() {
+ const js: JSResource[] = [];
+ const css: CSSResource[] = [];
+
+ if (clipboard) {
+ js.push({
+ script: clipboardScript,
+ loadTime: "afterDOMReady",
+ contentType: "inline",
+ });
+
+ css.push({
+ content: clipboardStyle,
+ inline: true,
+ });
+ }
+
+ return { js, css };
},
};
};
diff --git a/tsconfig.json b/tsconfig.json
index 4a3e6c9..8b9c93e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,7 +3,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
- "lib": ["ES2022", "DOM"],
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
"rootDir": ".",
"outDir": "dist",
"declaration": true,
diff --git a/tsup.config.ts b/tsup.config.ts
index 3b003aa..aef2c9e 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -12,4 +12,28 @@ export default defineConfig({
target: "es2022",
splitting: false,
outDir: "dist",
+ esbuildPlugins: [
+ {
+ name: "text-loader",
+ setup(build) {
+ build.onLoad({ filter: /\.scss$/ }, async (args) => {
+ const fs = await import("fs");
+ const text = await fs.promises.readFile(args.path, "utf8");
+ return {
+ contents: text,
+ loader: "text",
+ };
+ });
+
+ build.onLoad({ filter: /\.inline\.ts$/ }, async (args) => {
+ const fs = await import("fs");
+ const text = await fs.promises.readFile(args.path, "utf8");
+ return {
+ contents: text,
+ loader: "text",
+ };
+ });
+ },
+ },
+ ],
});