From 55a6e7f26b3e607f1249d470148ad31cab4f5163 Mon Sep 17 00:00:00 2001 From: Joan Puigcerver Date: Mon, 6 Apr 2026 13:58:57 +0200 Subject: [PATCH 1/2] download button for code blocks --- docs/reference/code-blocks.md | 65 +++++++++++++++++++ src/templates/assets/javascripts/_/index.ts | 2 + .../components/content/code/_/index.ts | 51 +++++++++++++++ .../javascripts/templates/clipboard/index.tsx | 37 ++++++++++- .../stylesheets/main/components/_code.scss | 6 ++ src/templates/base.html | 2 + src/templates/partials/languages/ca.html | 3 + src/templates/partials/languages/en.html | 2 + 8 files changed, 167 insertions(+), 1 deletion(-) diff --git a/docs/reference/code-blocks.md b/docs/reference/code-blocks.md index d7873f94fc..90e4c418cd 100644 --- a/docs/reference/code-blocks.md +++ b/docs/reference/code-blocks.md @@ -123,6 +123,71 @@ theme: [line highlighting]: #highlighting-specific-lines +### Code download button + + + + +Code blocks can include a button to download the content. There are two modes: +**blob download**, which saves the code block's content as a local file, and +**URL download**, which links to an external URL. The button is enabled +per code block using the `data-download` attribute via the [Attribute Lists] +extension: + +=== "Blob download" + + ```` markdown title="Code block with blob download button" + ``` { .py title="bubble_sort.py" data-download="1" } + def bubble_sort(items): + for i in range(len(items)): + for j in range(len(items) - 1 - i): + if items[j] > items[j + 1]: + items[j], items[j + 1] = items[j + 1], items[j] + ``` + ```` + + Setting `data-download="1"` (or `"true"`) renders a download button that + saves the code block's text content as a file directly in the browser. + +=== "URL download" + + ```` markdown title="Code block with URL download button" + ``` { .py title="buble_sort.py" data-download="https://example.com/bubble_sort.py" } + def bubble_sort(items): + for i in range(len(items)): + for j in range(len(items) - 1 - i): + if items[j] > items[j + 1]: + items[j], items[j + 1] = items[j + 1], items[j] + ``` + ```` + + Setting `data-download` to a URL renders a link button pointing to that + URL with the HTML `download` attribute, prompting the browser to save + rather than navigate. + +``` { .py title="bubble_sort.py" data-download="1" } +def bubble_sort(items): + for i in range(len(items)): + for j in range(len(items) - 1 - i): + if items[j] > items[j + 1]: + items[j], items[j + 1] = items[j + 1], items[j] +``` + +The filename suggested to the browser is resolved in the following order of priority: + +=== "Blob download" + + 1. `data-download-filename` attribute on the code block + 2. `title` option on the code block + 3. Fallback: `download.txt` + +=== "URL download" + + 1. `data-download-filename` attribute on the code block + 2. `title` option on the code block + 3. Last path segment of the URL + 4. Fallback: `download.txt` + ### Code annotations diff --git a/src/templates/assets/javascripts/_/index.ts b/src/templates/assets/javascripts/_/index.ts index e694cdb88d..c2b61047a2 100644 --- a/src/templates/assets/javascripts/_/index.ts +++ b/src/templates/assets/javascripts/_/index.ts @@ -64,6 +64,8 @@ export type Flag = export type Translation = | "clipboard.copy" /* Copy to clipboard */ | "clipboard.copied" /* Copied to clipboard */ + | "code.download" /* Download code block */ + | "code.select" /* Toggle line selection */ | "search.result.placeholder" /* Type to start searching */ | "search.result.none" /* No matching documents */ | "search.result.one" /* 1 matching document */ diff --git a/src/templates/assets/javascripts/components/content/code/_/index.ts b/src/templates/assets/javascripts/components/content/code/_/index.ts index a4e3815be8..a970f1fc54 100644 --- a/src/templates/assets/javascripts/components/content/code/_/index.ts +++ b/src/templates/assets/javascripts/components/content/code/_/index.ts @@ -66,8 +66,10 @@ import { mountInlineTooltip2 } from "~/components/tooltip2" import { + renderBlobDownloadButton, renderClipboardButton, renderCodeBlockNavigation, + renderDownloadButton, renderSelectionButton } from "~/templates" @@ -467,6 +469,55 @@ export function mountCodeBlock( } } + /* Render download button if data-download attribute is present */ + const downloadAttr = + container?.dataset.download || + parent.dataset.download || + el.dataset.download + + if (downloadAttr) { + const filenameAttr = + container?.dataset.downloadFilename || + parent.dataset.downloadFilename || + container?.querySelector(".filename")?.textContent?.trim() || + undefined + + if (downloadAttr === "1" || downloadAttr === "true") { + + /* Blob download - derive filename from title or data attribute */ + const filename = filenameAttr ?? "download.txt" + const button = renderBlobDownloadButton() + buttons.push(button) + if (feature("content.tooltips")) + content$.push(mountInlineTooltip2(button, { viewport$ })) + + /* On click: read code block text, create blob and trigger download */ + fromEvent(button, "click") + .pipe(takeUntil(done$)) + .subscribe(() => { + const code = el.textContent ?? "" + const blob = new Blob([code], { type: "text/plain" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + }) + + } else { + + /* URL download - link to external URL with suggested filename */ + const filename = filenameAttr ?? downloadAttr.split("/").pop() ?? "download.txt" + const button = renderDownloadButton(downloadAttr, filename) + buttons.push(button) + if (feature("content.tooltips")) + content$.push(mountInlineTooltip2(button, { viewport$ })) + } + } + /* Render button for Clipboard.js integration */ if (ClipboardJS.isSupported()) { if (el.closest(".copy") || ( diff --git a/src/templates/assets/javascripts/templates/clipboard/index.tsx b/src/templates/assets/javascripts/templates/clipboard/index.tsx index 5fc0ada2e2..a25dc08f9a 100644 --- a/src/templates/assets/javascripts/templates/clipboard/index.tsx +++ b/src/templates/assets/javascripts/templates/clipboard/index.tsx @@ -49,12 +49,47 @@ export function renderSelectionButton(): HTMLElement { return ( ) } +/** + * Render a download button that links to an external URL + * + * @param url - Download URL + * @param filename - Suggested filename for the download + * + * @returns Element + */ +export function renderDownloadButton(url: string, filename: string): HTMLElement { + return ( + + ) +} + +/** + * Render a download button that saves code block content as a blob + * + * @returns Element + */ +export function renderBlobDownloadButton(): HTMLElement { + return ( + + ) +} + export function renderCodeBlockNavigation() { return ( diff --git a/src/templates/assets/stylesheets/main/components/_code.scss b/src/templates/assets/stylesheets/main/components/_code.scss index b8fbe55690..7326618dc4 100644 --- a/src/templates/assets/stylesheets/main/components/_code.scss +++ b/src/templates/assets/stylesheets/main/components/_code.scss @@ -28,6 +28,7 @@ :root { --md-code-select-icon: svg-load("material/focus-field.svg"); --md-code-copy-icon: svg-load("material/content-copy.svg"); + --md-code-download-icon: svg-load("material/download.svg"); // --md-code-wrap-icon: svg-load("material/wrap-disabled.svg"); } @@ -111,5 +112,10 @@ &[data-md-type="copy"]::after { mask-image: var(--md-code-copy-icon); } + + // Code block button icon for downloading + &[data-md-type="download"]::after { + mask-image: var(--md-code-download-icon); + } } } diff --git a/src/templates/base.html b/src/templates/base.html index fd049bed86..5e9afa88cd 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -427,6 +427,8 @@ "translations": { "clipboard.copy": lang.t("clipboard.copy"), "clipboard.copied": lang.t("clipboard.copied"), + "code.download": lang.t("code.download"), + "code.select": lang.t("code.select"), "search.result.placeholder": lang.t("search.result.placeholder"), "search.result.none": lang.t("search.result.none"), "search.result.one": lang.t("search.result.one"), diff --git a/src/templates/partials/languages/ca.html b/src/templates/partials/languages/ca.html index 54f9e69205..505a19654a 100644 --- a/src/templates/partials/languages/ca.html +++ b/src/templates/partials/languages/ca.html @@ -28,6 +28,7 @@ "action.view": "Visualitza el codi font", "announce.dismiss": "No ho tornis a mostrar", "blog.archive": "Arxiva", + "blog.authors": "Autors", "blog.categories": "Categories", "blog.categories.in": "a", "blog.continue": "Continua llegint", @@ -37,6 +38,8 @@ "blog.references": "Enllaços relacionats", "clipboard.copy": "Còpia al porta-retalls", "clipboard.copied": "Copiat al porta-retalls", + "code.download": "Descarrega", + "code.select": "Commuta la selecció de línies", "consent.accept": "Accepta", "consent.manage": "Gestiona la configuració", "consent.reject": "Rebutja", diff --git a/src/templates/partials/languages/en.html b/src/templates/partials/languages/en.html index 9e38f8258c..16150b8159 100644 --- a/src/templates/partials/languages/en.html +++ b/src/templates/partials/languages/en.html @@ -39,6 +39,8 @@ "blog.references": "Related links", "clipboard.copy": "Copy to clipboard", "clipboard.copied": "Copied to clipboard", + "code.download": "Download", + "code.select": "Toggle line selection", "consent.accept": "Accept", "consent.manage": "Manage settings", "consent.reject": "Reject", From 73ca694f90d92e6b97892646707cde3d7254d76c Mon Sep 17 00:00:00 2001 From: Joan Puigcerver Date: Thu, 9 Apr 2026 22:41:20 +0200 Subject: [PATCH 2/2] changed download attributes behaviour and title priority chain --- docs/reference/code-blocks.md | 48 ++++++++++++------- .../components/content/code/_/index.ts | 36 ++++++++++---- .../javascripts/templates/clipboard/index.tsx | 9 ++-- 3 files changed, 60 insertions(+), 33 deletions(-) diff --git a/docs/reference/code-blocks.md b/docs/reference/code-blocks.md index 90e4c418cd..e3bb7c03b8 100644 --- a/docs/reference/code-blocks.md +++ b/docs/reference/code-blocks.md @@ -125,19 +125,18 @@ theme: ### Code download button - Code blocks can include a button to download the content. There are two modes: **blob download**, which saves the code block's content as a local file, and -**URL download**, which links to an external URL. The button is enabled -per code block using the `data-download` attribute via the [Attribute Lists] -extension: +**URL download**, which links to a URL (absolute or relative to the site). The +button is enabled per code block using the `data-download` attribute via the +[Attribute Lists] extension: === "Blob download" ```` markdown title="Code block with blob download button" - ``` { .py title="bubble_sort.py" data-download="1" } + ``` { .py title="bubble_sort.py" data-download="blob" } def bubble_sort(items): for i in range(len(items)): for j in range(len(items) - 1 - i): @@ -146,13 +145,13 @@ extension: ``` ```` - Setting `data-download="1"` (or `"true"`) renders a download button that - saves the code block's text content as a file directly in the browser. + Setting `data-download="blob"` renders a button that saves the code + block's text content as a file directly in the browser. === "URL download" ```` markdown title="Code block with URL download button" - ``` { .py title="buble_sort.py" data-download="https://example.com/bubble_sort.py" } + ``` { .py data-download="https://example.com/bubble_sort.py" } def bubble_sort(items): for i in range(len(items)): for j in range(len(items) - 1 - i): @@ -162,10 +161,16 @@ extension: ```` Setting `data-download` to a URL renders a link button pointing to that - URL with the HTML `download` attribute, prompting the browser to save - rather than navigate. + URL with the HTML `download` attribute. Both absolute and relative URLs + are accepted. -``` { .py title="bubble_sort.py" data-download="1" } + !!! warning "Cross-origin URLs" + + Browsers only honour the `download` attribute for **same-origin** URLs. + Cross-origin URLs will open in a new tab instead of triggering a save + dialog — this is an intentional browser security restriction. + +``` { .py title="bubble_sort.py" data-download="blob" } def bubble_sort(items): for i in range(len(items)): for j in range(len(items) - 1 - i): @@ -173,20 +178,27 @@ def bubble_sort(items): items[j], items[j + 1] = items[j + 1], items[j] ``` -The filename suggested to the browser is resolved in the following order of priority: + +#### Suggested filename + +The filename suggested to the browser is resolved in the following order of +priority. The `title` option is normalized to a safe filename: the last path +segment is taken, lowercased, and any characters other than letters, digits, +dots, hyphens, and underscores are replaced by hyphens. === "Blob download" - 1. `data-download-filename` attribute on the code block - 2. `title` option on the code block - 3. Fallback: `download.txt` + 1. `data-filename` attribute on the code block + 2. `title` option on the code block (normalized) + 3. Fallback: `download.` (e.g. `download.python`), or `download.txt` if no language === "URL download" - 1. `data-download-filename` attribute on the code block - 2. `title` option on the code block + 1. `data-filename` attribute on the code block + 2. `title` option on the code block (normalized) 3. Last path segment of the URL - 4. Fallback: `download.txt` + 4. Fallback: `download.` (e.g. `download.python`), or `download.txt` if no language + ### Code annotations diff --git a/src/templates/assets/javascripts/components/content/code/_/index.ts b/src/templates/assets/javascripts/components/content/code/_/index.ts index a970f1fc54..cc4589ee23 100644 --- a/src/templates/assets/javascripts/components/content/code/_/index.ts +++ b/src/templates/assets/javascripts/components/content/code/_/index.ts @@ -477,15 +477,32 @@ export function mountCodeBlock( if (downloadAttr) { const filenameAttr = - container?.dataset.downloadFilename || - parent.dataset.downloadFilename || - container?.querySelector(".filename")?.textContent?.trim() || + container?.dataset.filename || + parent.dataset.filename || undefined - if (downloadAttr === "1" || downloadAttr === "true") { - - /* Blob download - derive filename from title or data attribute */ - const filename = filenameAttr ?? "download.txt" + /* Normalize title to a safe filename: last path segment, lowercased, + special characters replaced by hyphens */ + const titleText = + container?.querySelector(".filename")?.textContent?.trim() + const titleFilename = titleText + ? titleText.split("/").pop()! + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || undefined + : undefined + + /* Derive fallback filename from code block language class */ + const langClass = Array.from(container?.classList ?? []) + .find(c => c.startsWith("language-")) + const fallback = langClass + ? `download.${langClass.slice("language-".length)}` + : "download.txt" + + if (downloadAttr === "blob") { + + /* Blob download - derive filename from attribute, title, or fallback */ + const filename = filenameAttr ?? titleFilename ?? fallback const button = renderBlobDownloadButton() buttons.push(button) if (feature("content.tooltips")) @@ -509,8 +526,9 @@ export function mountCodeBlock( } else { - /* URL download - link to external URL with suggested filename */ - const filename = filenameAttr ?? downloadAttr.split("/").pop() ?? "download.txt" + /* URL download - absolute or relative URL within the same site */ + const urlSegment = downloadAttr.split("/").pop() ?? undefined + const filename = filenameAttr ?? titleFilename ?? urlSegment ?? fallback const button = renderDownloadButton(downloadAttr, filename) buttons.push(button) if (feature("content.tooltips")) diff --git a/src/templates/assets/javascripts/templates/clipboard/index.tsx b/src/templates/assets/javascripts/templates/clipboard/index.tsx index a25dc08f9a..a54851459d 100644 --- a/src/templates/assets/javascripts/templates/clipboard/index.tsx +++ b/src/templates/assets/javascripts/templates/clipboard/index.tsx @@ -56,20 +56,17 @@ export function renderSelectionButton(): HTMLElement { } /** - * Render a download button that links to an external URL - * - * @param url - Download URL - * @param filename - Suggested filename for the download + * Render a download button that fetches a URL and saves it as a file * * @returns Element */ export function renderDownloadButton(url: string, filename: string): HTMLElement { return ( )