diff --git a/src/content.config.ts b/src/content.config.ts
index 948c982..260e91a 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -9,6 +9,8 @@ function removeDupsAndLowerCase(array: string[]) {
return Array.from(distinctItems);
}
+const codeStyleSchema = z.enum(["default", "mac", "terminal"]).optional();
+
const post = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/post" }),
schema: ({ image }) =>
@@ -32,6 +34,7 @@ const post = defineCollection({
.string()
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
+ codeStyle: codeStyleSchema,
}),
});
@@ -58,6 +61,7 @@ const project = defineCollection({
.string()
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
+ codeStyle: codeStyleSchema,
}),
});
diff --git a/src/layouts/BlogPost.astro b/src/layouts/BlogPost.astro
index 74fa7c8..ab0f81a 100644
--- a/src/layouts/BlogPost.astro
+++ b/src/layouts/BlogPost.astro
@@ -27,7 +27,7 @@ const idx = isProject ? -1 : sortedAll.findIndex((p) => p.id === post.id);
const newerPost = idx > 0 ? sortedAll[idx - 1] : undefined;
const olderPost = idx >= 0 && idx < sortedAll.length - 1 ? sortedAll[idx + 1] : undefined;
const {
- data: { coverImage, description, ogImage, publishDate, title, updatedDate, tags, draft },
+ data: { coverImage, description, ogImage, publishDate, title, updatedDate, tags, draft, codeStyle },
id: slug,
} = post;
const socialImage = ogImage ?? `/og-image/${slug}.png`;
@@ -126,7 +126,7 @@ const giscusThemes = import.meta.env.PROD
)}
-
+
@@ -529,6 +529,10 @@ const giscusThemes = import.meta.env.PROD
transition: background-color 0.35s ease, border-color 0.35s ease;
}
+ .post-article .code-wrapper {
+ position: relative;
+ margin: 28px 0;
+ }
.post-article pre {
background: var(--paper);
border: 1px solid var(--hairline);
@@ -541,6 +545,9 @@ const giscusThemes = import.meta.env.PROD
line-height: 1.7;
transition: background-color 0.35s ease, border-color 0.35s ease;
}
+ .post-article .code-wrapper > pre {
+ margin: 0;
+ }
.post-article pre code {
background: transparent;
border: 0;
@@ -552,6 +559,99 @@ const giscusThemes = import.meta.env.PROD
.post-article .expressive-code {
margin: 28px 0;
}
+ .post-article .copy-code-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: hsl(var(--theme-text) / 0.45);
+ background: var(--paper);
+ border: 1px solid var(--hairline);
+ border-radius: 3px;
+ padding: 3px 8px;
+ cursor: pointer;
+ opacity: 0;
+ line-height: 1.6;
+ transition: opacity 0.18s ease, color 0.18s ease, border-color 0.18s ease, background-color 0.35s ease;
+ }
+ .post-article .code-wrapper:hover .copy-code-btn,
+ .post-article .copy-code-btn:focus-visible {
+ opacity: 1;
+ }
+ .post-article .copy-code-btn:hover {
+ color: hsl(var(--theme-accent));
+ border-color: hsl(var(--theme-accent) / 0.5);
+ }
+ .post-article .copy-code-btn[data-copied="true"] {
+ color: hsl(var(--theme-accent));
+ opacity: 1;
+ }
+
+ /* Mac window style */
+ .post-article .mac-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 12px;
+ height: 36px;
+ background: var(--paper);
+ border: 1px solid var(--hairline);
+ border-bottom: 0;
+ border-radius: 4px 4px 0 0;
+ transition: background-color 0.35s ease, border-color 0.35s ease;
+ }
+ .post-article .mac-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+ .post-article .mac-dot:nth-child(1) { background: #FF5F57; }
+ .post-article .mac-dot:nth-child(2) { background: #FFBD2E; }
+ .post-article .mac-dot:nth-child(3) { background: #28CA41; }
+ .post-article .code-wrapper.mac-window > pre {
+ border-top: 0;
+ border-radius: 0 0 4px 4px;
+ }
+ /* Copy button lives in the title bar for mac-window blocks */
+ .post-article .mac-bar .copy-code-btn {
+ position: static;
+ margin-left: auto;
+ opacity: 0.45;
+ }
+ .post-article .code-wrapper.mac-window:hover .copy-code-btn,
+ .post-article .mac-bar .copy-code-btn:focus-visible {
+ opacity: 1;
+ }
+
+ /* Terminal style — dots | spacer | copy, with a distinct bar color */
+ .post-article .terminal-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 12px;
+ height: 36px;
+ background: var(--hairline);
+ border: 1px solid var(--hairline);
+ border-bottom: 0;
+ border-radius: 4px 4px 0 0;
+ transition: background-color 0.35s ease, border-color 0.35s ease;
+ }
+ .post-article .terminal-dots {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+ .post-article .code-wrapper.terminal > pre {
+ border-top: 0;
+ border-radius: 0 0 4px 4px;
+ }
+ .post-article .code-wrapper.terminal > .copy-code-btn {
+ top: calc(36px + 8px);
+ }
.post-article ul {
margin: 0 0 28px;
@@ -874,6 +974,67 @@ const giscusThemes = import.meta.env.PROD
lazyImgs.forEach((img) => io.observe(img));
}
+ const articleEl = document.querySelector(".post-article");
+ const postCodeStyle = articleEl?.dataset.codeStyle ?? "default";
+
+ document.querySelectorAll(".post-article pre").forEach((pre) => {
+ const metaParts = (pre.dataset.meta ?? "").split(/\s+/);
+ const isMac = postCodeStyle === "mac" || metaParts.includes("mac");
+ const isTerminal = postCodeStyle === "terminal" || metaParts.includes("terminal");
+
+ const wrapper = document.createElement("div");
+ wrapper.className =
+ "code-wrapper" + (isMac ? " mac-window" : isTerminal ? " terminal" : "");
+ pre.parentNode?.insertBefore(wrapper, pre);
+ wrapper.appendChild(pre);
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "copy-code-btn";
+ btn.setAttribute("aria-label", "Copy code");
+ btn.textContent = "copy";
+ btn.addEventListener("click", async () => {
+ const text = pre.querySelector("code")?.textContent ?? pre.textContent ?? "";
+ try {
+ await navigator.clipboard.writeText(text);
+ btn.textContent = "copied";
+ btn.dataset.copied = "true";
+ setTimeout(() => {
+ btn.textContent = "copy";
+ delete btn.dataset.copied;
+ }, 1500);
+ } catch {}
+ });
+
+ if (isMac) {
+ const bar = document.createElement("div");
+ bar.className = "mac-bar";
+ for (let i = 0; i < 3; i++) {
+ const dot = document.createElement("span");
+ dot.className = "mac-dot";
+ bar.appendChild(dot);
+ }
+ bar.appendChild(btn);
+ wrapper.insertBefore(bar, pre);
+ } else if (isTerminal) {
+ const bar = document.createElement("div");
+ bar.className = "terminal-bar";
+
+ const dotsEl = document.createElement("div");
+ dotsEl.className = "terminal-dots";
+ for (let i = 0; i < 3; i++) {
+ const dot = document.createElement("span");
+ dot.className = "mac-dot";
+ dotsEl.appendChild(dot);
+ }
+ bar.appendChild(dotsEl);
+ wrapper.insertBefore(bar, pre);
+ wrapper.appendChild(btn);
+ } else {
+ wrapper.appendChild(btn);
+ }
+ });
+
const slugify = (s: string) =>
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const usedIds = new Set();