Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/reference/code-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,83 @@ theme:

[line highlighting]: #highlighting-specific-lines

### Code download button

<!-- md:flag experimental -->

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 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="blob" }
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="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 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. Both absolute and relative URLs
are accepted.

!!! 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):
if items[j] > items[j + 1]:
items[j], items[j + 1] = items[j + 1], items[j]
```


#### 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-filename` attribute on the code block
2. `title` option on the code block (normalized)
3. Fallback: `download.<lang>` (e.g. `download.python`), or `download.txt` if no language

=== "URL download"

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.<lang>` (e.g. `download.python`), or `download.txt` if no language


### Code annotations

<!-- md:version 8.0.0 -->
Expand Down
2 changes: 2 additions & 0 deletions src/templates/assets/javascripts/_/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ import {
mountInlineTooltip2
} from "~/components/tooltip2"
import {
renderBlobDownloadButton,
renderClipboardButton,
renderCodeBlockNavigation,
renderDownloadButton,
renderSelectionButton
} from "~/templates"

Expand Down Expand Up @@ -467,6 +469,73 @@ 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.filename ||
parent.dataset.filename ||
undefined

/* Normalize title to a safe filename: last path segment, lowercased,
special characters replaced by hyphens */
const titleText =
container?.querySelector<HTMLElement>(".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"))
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 - 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"))
content$.push(mountInlineTooltip2(button, { viewport$ }))
}
}

/* Render button for Clipboard.js integration */
if (ClipboardJS.isSupported()) {
if (el.closest(".copy") || (
Expand Down
34 changes: 33 additions & 1 deletion src/templates/assets/javascripts/templates/clipboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,44 @@ export function renderSelectionButton(): HTMLElement {
return (
<button
class="md-code__button"
title="Toggle line selection"
title={translation("code.select")}
data-md-type="select"
></button>
)
}

/**
* 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 (
<a
href={url}
download={filename}
class="md-code__button"
title={translation("code.download")}
data-md-type="download"
></a>
)
}

/**
* Render a download button that saves code block content as a blob
*
* @returns Element
*/
export function renderBlobDownloadButton(): HTMLElement {
return (
<button
class="md-code__button"
title={translation("code.download")}
data-md-type="download"
></button>
)
}

export function renderCodeBlockNavigation() {
return (
<nav class="md-code__nav"></nav>
Expand Down
6 changes: 6 additions & 0 deletions src/templates/assets/stylesheets/main/components/_code.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down Expand Up @@ -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);
}
}
}
2 changes: 2 additions & 0 deletions src/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
3 changes: 3 additions & 0 deletions src/templates/partials/languages/ca.html
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/templates/partials/languages/en.html
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down