diff --git a/astro.config.ts b/astro.config.ts
index bafbd5b77a2..1578962e7fb 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -14,6 +14,8 @@ import { ccipRedirects } from "./src/config/redirects/ccip"
import trailingSlashMiddleware from "./src/integrations/trailing-slash-middleware"
import redirectsJson from "./src/features/redirects/redirects.json"
import { extractCanonicalUrlsWithLanguageVariants } from "./src/utils/sidebar"
+import remarkCodeFenceFilename from "./src/lib/markdown/remarkCodeFenceFilename"
+import rehypeCodeSampleFences from "./src/lib/markdown/rehypeCodeSampleFences"
config() // Load .env file
@@ -103,9 +105,13 @@ export default defineConfig({
return item
},
}),
- mdx(),
+ // Ensure our fence-meta parser runs for `.mdx` pages (in addition to `markdown.remarkPlugins`).
+ mdx({
+ remarkPlugins: [remarkCodeFenceFilename],
+ }),
],
markdown: {
+ remarkPlugins: [remarkCodeFenceFilename],
rehypePlugins: [
rehypeSlug, // Required for autolink to work properly
[
@@ -116,6 +122,7 @@ export default defineConfig({
],
// Wrap tables in div with overflow supported
[rehypeWrapAll, { selector: "table", wrapper: "div.overflow-wrapper" }],
+ rehypeCodeSampleFences,
] as RehypePlugins,
syntaxHighlight: "prism",
smartypants: false,
diff --git a/public/images/language-icons/go.svg b/public/images/language-icons/go.svg
new file mode 100644
index 00000000000..9bb5f643118
--- /dev/null
+++ b/public/images/language-icons/go.svg
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/public/images/language-icons/json.svg b/public/images/language-icons/json.svg
new file mode 100644
index 00000000000..b5c5ada53de
--- /dev/null
+++ b/public/images/language-icons/json.svg
@@ -0,0 +1,17 @@
+
diff --git a/public/images/language-icons/python.svg b/public/images/language-icons/python.svg
new file mode 100644
index 00000000000..5bd72165186
--- /dev/null
+++ b/public/images/language-icons/python.svg
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/public/images/language-icons/rust.svg b/public/images/language-icons/rust.svg
new file mode 100644
index 00000000000..630595afb26
--- /dev/null
+++ b/public/images/language-icons/rust.svg
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/public/images/language-icons/solidity.svg b/public/images/language-icons/solidity.svg
new file mode 100644
index 00000000000..86b9f4995b2
--- /dev/null
+++ b/public/images/language-icons/solidity.svg
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/public/images/language-icons/terminal.svg b/public/images/language-icons/terminal.svg
new file mode 100644
index 00000000000..4920153ef58
--- /dev/null
+++ b/public/images/language-icons/terminal.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/language-icons/toml.svg b/public/images/language-icons/toml.svg
new file mode 100644
index 00000000000..de69f153f63
--- /dev/null
+++ b/public/images/language-icons/toml.svg
@@ -0,0 +1,12 @@
+
+
+
\ No newline at end of file
diff --git a/public/images/language-icons/typescript.svg b/public/images/language-icons/typescript.svg
new file mode 100644
index 00000000000..025b352d841
--- /dev/null
+++ b/public/images/language-icons/typescript.svg
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/src/components/CodeSample/CodeSample.astro b/src/components/CodeSample/CodeSample.astro
index 73cef4f4cb2..6f4eb4d4153 100644
--- a/src/components/CodeSample/CodeSample.astro
+++ b/src/components/CodeSample/CodeSample.astro
@@ -1,20 +1,30 @@
---
-import { Prism } from "@astrojs/prism"
+import { runHighlighterWithAstro } from "@astrojs/prism/dist/highlighter"
import fs from "node:fs/promises"
import path from "node:path"
+import { getLanguageIconSrc, languageBadge } from "../../lib/codeSample/language.js"
+
export type Props = {
src: string
lang?: string
+ filename?: string
showButtonOnly?: boolean
optimize?: boolean
runs?: number
}
-const { src, lang, showButtonOnly, optimize, runs } = Astro.props as Props
+const { src, lang, filename, showButtonOnly, optimize, runs } = Astro.props as Props
const data = (await fs.readFile(path.join(process.cwd(), "public", src), "utf-8")).toString()
+const prismLang = lang ?? "solidity"
+const headerFilename = filename?.trim() || undefined
+const { classLanguage, html } = runHighlighterWithAstro(prismLang, data)
+const languageKey = prismLang.toLowerCase()
+const languageIconSrc = getLanguageIconSrc(languageKey)
+const preInnerHtml = `${html}`
+
const isSolidityFile = src.match(/\.sol/)
const isSample = isSolidityFile && (src.indexOf("samples/") === 0 || src.indexOf("/samples/") === 0)
@@ -29,7 +39,38 @@ const remixUrl = `https://remix.ethereum.org/#url=https://docs.chain.link/${clea
}`
---
-{!showButtonOnly &&
` for shared styling + copy-button behavior.
+ */
+export default function rehypeCodeSampleFences() {
+ return (tree: unknown) => {
+ visit(tree as any, "element", (node: any, index: number | undefined, parent: any) => {
+ if (!node || node.tagName !== "div") return
+
+ const classes = normalizeClassName(node.properties?.className)
+ if (!classes.includes("code-sample")) return
+
+ const filenameRaw = getProp(node, "data-filename")
+ const filename = typeof filenameRaw === "string" ? filenameRaw.trim() : ""
+ if (!filename) return
+
+ // Avoid double-inserting if this already has a header.
+ const hasHeader =
+ Array.isArray(node.children) &&
+ node.children.some(
+ (c: any) =>
+ c?.type === "element" &&
+ c?.tagName === "div" &&
+ normalizeClassName(c?.properties?.className).includes("code-sample__header")
+ )
+ if (hasHeader) return
+
+ const preEl =
+ Array.isArray(node.children) && node.children.find((c: any) => c?.type === "element" && c?.tagName === "pre")
+ if (!preEl) return
+
+ const languageRaw = getProp(node, "data-language") ?? getProp(preEl, "data-language")
+ const language = typeof languageRaw === "string" && languageRaw.trim() ? languageRaw.trim() : "text"
+ const languageKey = language.toLowerCase()
+ const iconSrc = getLanguageIconSrc(languageKey)
+
+ // Update the node to match CodeSample behavior
+ const existing = normalizeClassName(preEl.properties?.className)
+ preEl.properties = preEl.properties || {}
+ preEl.properties.className = Array.from(
+ new Set([...existing, "code-sample__pre", "code-sample__pre--with-header"])
+ )
+ preEl.properties["data-no-copy-button"] = ""
+
+ const langSpan: any = {
+ type: "element",
+ tagName: "span",
+ properties: {
+ className: ["code-sample__lang", ...(iconSrc ? ["code-sample__lang--icon"] : [])],
+ "aria-hidden": "true",
+ },
+ children: iconSrc
+ ? [
+ {
+ type: "element",
+ tagName: "img",
+ properties: { className: ["code-sample__lang-icon"], src: iconSrc, alt: "" },
+ children: [],
+ },
+ ]
+ : [{ type: "text", value: languageBadge(languageKey) }],
+ }
+
+ const filenameSpan: any = {
+ type: "element",
+ tagName: "span",
+ properties: { className: ["code-sample__filename"], title: filename },
+ children: [{ type: "text", value: filename }],
+ }
+
+ const headerLeft: any = {
+ type: "element",
+ tagName: "div",
+ properties: { className: ["code-sample__header-left"] },
+ children: [langSpan, filenameSpan],
+ }
+
+ const copyButton: any = {
+ type: "element",
+ tagName: "button",
+ properties: { type: "button", className: ["code-sample__copy-button"], "aria-label": "Copy code" },
+ children: [
+ {
+ type: "element",
+ tagName: "img",
+ properties: { src: "/assets/icons/copyIcon.svg", alt: "Copy code", width: "16", height: "16" },
+ children: [],
+ },
+ ],
+ }
+
+ const header: any = {
+ type: "element",
+ tagName: "div",
+ properties: { className: ["code-sample__header"] },
+ children: [headerLeft, copyButton],
+ }
+
+ const wrapper: any = {
+ type: "element",
+ tagName: "div",
+ properties: { className: ["code-sample"], "data-language": languageKey },
+ children: [header, preEl],
+ }
+
+ // Replace this placeholder wrapper with the full wrapper (keeping only the pre).
+ // (We intentionally don't preserve `data-filename` in output markup.)
+ if (parent && typeof index === "number") parent.children[index] = wrapper
+ })
+ }
+}
diff --git a/src/lib/markdown/remarkCodeFenceFilename.ts b/src/lib/markdown/remarkCodeFenceFilename.ts
new file mode 100644
index 00000000000..e1a26b40e93
--- /dev/null
+++ b/src/lib/markdown/remarkCodeFenceFilename.ts
@@ -0,0 +1,117 @@
+import { visit } from "unist-util-visit"
+
+import { getLanguageIconSrc, languageBadge } from "../codeSample/language.js"
+
+const FILENAME_RE = /(?:^|\s)filename\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s]+))/i
+
+/**
+ * Wrap fenced code blocks that include `filename="..."` meta in a lightweight
+ * `.code-sample` container so we can add a CodeSample-like header in rehype
+ * (after syntax highlighting), while keeping the filename metadata intact.
+ */
+export default function remarkCodeFenceFilename() {
+ return (tree: unknown) => {
+ visit(tree as any, "code", (node: any, index: number | undefined, parent: any) => {
+ if (!parent || typeof index !== "number") return
+
+ const meta = typeof node?.meta === "string" ? node.meta : ""
+ if (!meta) return
+
+ const match = meta.match(FILENAME_RE)
+ if (!match) return
+
+ const filename = String(match[1] || match[2] || match[3] || "").trim()
+ if (!filename) return
+
+ const language = typeof node.lang === "string" && node.lang.trim() ? node.lang.trim().toLowerCase() : "text"
+
+ const iconSrc = getLanguageIconSrc(language)
+ const badge = languageBadge(language)
+
+ const langChildren = iconSrc
+ ? [
+ {
+ type: "mdxJsxFlowElement",
+ name: "img",
+ attributes: [
+ { type: "mdxJsxAttribute", name: "class", value: "code-sample__lang-icon" },
+ { type: "mdxJsxAttribute", name: "src", value: iconSrc },
+ { type: "mdxJsxAttribute", name: "alt", value: "" },
+ ],
+ children: [],
+ },
+ ]
+ : [{ type: "text", value: badge }]
+
+ const header = {
+ type: "mdxJsxFlowElement",
+ name: "div",
+ attributes: [{ type: "mdxJsxAttribute", name: "class", value: "code-sample__header" }],
+ children: [
+ {
+ type: "mdxJsxFlowElement",
+ name: "div",
+ attributes: [{ type: "mdxJsxAttribute", name: "class", value: "code-sample__header-left" }],
+ children: [
+ {
+ type: "mdxJsxFlowElement",
+ name: "span",
+ attributes: [
+ {
+ type: "mdxJsxAttribute",
+ name: "class",
+ value: `code-sample__lang${iconSrc ? " code-sample__lang--icon" : ""}`,
+ },
+ { type: "mdxJsxAttribute", name: "aria-hidden", value: "true" },
+ ],
+ children: langChildren,
+ },
+ {
+ type: "mdxJsxFlowElement",
+ name: "span",
+ attributes: [
+ { type: "mdxJsxAttribute", name: "class", value: "code-sample__filename" },
+ { type: "mdxJsxAttribute", name: "title", value: filename },
+ ],
+ children: [{ type: "text", value: filename }],
+ },
+ ],
+ },
+ {
+ type: "mdxJsxFlowElement",
+ name: "button",
+ attributes: [
+ { type: "mdxJsxAttribute", name: "type", value: "button" },
+ { type: "mdxJsxAttribute", name: "class", value: "code-sample__copy-button" },
+ { type: "mdxJsxAttribute", name: "aria-label", value: "Copy code" },
+ ],
+ children: [
+ {
+ type: "mdxJsxFlowElement",
+ name: "img",
+ attributes: [
+ { type: "mdxJsxAttribute", name: "src", value: "/assets/icons/copyIcon.svg" },
+ { type: "mdxJsxAttribute", name: "alt", value: "Copy code" },
+ { type: "mdxJsxAttribute", name: "width", value: "16" },
+ { type: "mdxJsxAttribute", name: "height", value: "16" },
+ ],
+ children: [],
+ },
+ ],
+ },
+ ],
+ }
+
+ parent.children[index] = {
+ type: "mdxJsxFlowElement",
+ name: "div",
+ attributes: [
+ { type: "mdxJsxAttribute", name: "class", value: "code-sample" },
+ { type: "mdxJsxAttribute", name: "data-language", value: language },
+ { type: "mdxJsxAttribute", name: "data-filename", value: filename },
+ ],
+ children: [header, node],
+ }
+ })
+ }
+}
diff --git a/src/scripts/codeSampleCopy.ts b/src/scripts/codeSampleCopy.ts
new file mode 100644
index 00000000000..0f82e113132
--- /dev/null
+++ b/src/scripts/codeSampleCopy.ts
@@ -0,0 +1,39 @@
+function copyText(text: string): Promise {
+ if (navigator.clipboard && window.isSecureContext) {
+ return navigator.clipboard.writeText(text)
+ }
+
+ // Fallback for non-secure contexts (e.g. http://localhost)
+ const textarea = document.createElement("textarea")
+ textarea.value = text
+ textarea.setAttribute("readonly", "")
+ textarea.style.position = "absolute"
+ textarea.style.left = "-9999px"
+ document.body.appendChild(textarea)
+ textarea.select()
+ const ok = document.execCommand("copy")
+ document.body.removeChild(textarea)
+ return ok ? Promise.resolve() : Promise.reject(new Error("Copy failed"))
+}
+
+document.addEventListener("click", async (e) => {
+ const target = e.target as HTMLElement | null
+ const button = (target?.closest?.(".code-sample__copy-button") as HTMLButtonElement | null) ?? null
+ if (!button) return
+
+ const root = button.closest(".code-sample")
+ const codeEl = (root?.querySelector("pre code") as HTMLElement | null) ?? null
+ const text = codeEl ? codeEl.innerText : ""
+ if (!text) return
+
+ const oldHtml = button.innerHTML
+ try {
+ await copyText(text)
+ button.innerHTML = '
'
+ window.setTimeout(() => {
+ button.innerHTML = oldHtml
+ }, 1500)
+ } catch (_err) {
+ // no-op; keep original UI
+ }
+})
diff --git a/src/scripts/copyToClipboard/copyToClipboard.ts b/src/scripts/copyToClipboard/copyToClipboard.ts
index da7ffee25af..6a640eae21c 100644
--- a/src/scripts/copyToClipboard/copyToClipboard.ts
+++ b/src/scripts/copyToClipboard/copyToClipboard.ts
@@ -52,6 +52,11 @@ document.addEventListener("DOMContentLoaded", () => {
return
}
+ // Skip CodeSample-style blocks (they have their own header copy button)
+ if (codeBlock.closest(".code-sample")) {
+ return
+ }
+
const copyButtonContainer = document.createElement("div")
copyButtonContainer.className = styles.copyCodeButtonWrapper
diff --git a/src/scripts/index.ts b/src/scripts/index.ts
index 2b912568f04..7bb969536c7 100644
--- a/src/scripts/index.ts
+++ b/src/scripts/index.ts
@@ -1,4 +1,5 @@
import "./fix-remix-urls"
import "./fix-external-links"
+import "./codeSampleCopy"
import "./copyToClipboard/copyToClipboard"
import "./scroll-to-search"
diff --git a/src/styles/code-blocks.css b/src/styles/code-blocks.css
new file mode 100644
index 00000000000..652650faeee
--- /dev/null
+++ b/src/styles/code-blocks.css
@@ -0,0 +1,119 @@
+/* Shared styles for custom code blocks (CodeSample + future fenced blocks) */
+
+.code-sample {
+ margin: 1rem 0;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.code-sample__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 10px 14px 11px 14px;
+ background: #3a3a3a;
+ color: #e0e0e0;
+ font-family: var(
+ --font-mono,
+ ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ Monaco,
+ Consolas,
+ "Liberation Mono",
+ "Courier New",
+ monospace
+ );
+ font-size: 0.9rem;
+ line-height: 1;
+}
+
+.code-sample__header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.code-sample__lang {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 30px;
+ height: 24px;
+ padding: 0 6px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.1);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+.code-sample__lang-icon {
+ width: 24px;
+ height: 24px;
+ display: block;
+ object-fit: contain;
+}
+
+.code-sample__lang-icon,
+.code-sample__copy-button > img {
+ filter: brightness(0) invert(1);
+ opacity: 0.95;
+}
+
+/* When we render an icon, drop the "badge" background block. */
+.code-sample__lang--icon {
+ background: transparent;
+ padding: 0;
+ min-width: 24px;
+ border-radius: 0;
+}
+
+.code-sample__filename {
+ font-size: 0.95rem;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.code-sample__copy-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.08);
+ cursor: pointer;
+ flex: 0 0 auto;
+}
+
+.code-sample__copy-button:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.code-sample__copy-button:focus-visible {
+ outline: 2px solid rgba(255, 255, 255, 0.35);
+ outline-offset: 2px;
+}
+
+.code-sample__copy-button > img {
+ display: block;
+}
+
+.code-sample__pre--with-header {
+ margin: 0;
+ border-top-left-radius: 0 !important;
+ border-top-right-radius: 0 !important;
+}
+
+/* Fenced blocks wrapped via remark won't have CodeSample's pre classes. */
+.code-sample > pre {
+ margin: 0;
+ border-top-left-radius: 0 !important;
+ border-top-right-radius: 0 !important;
+}