From cd39169fd73f22b2a67f4a98cc34a131044ae2b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micka=C3=ABl=20Canouil?=
<8896044+mcanouil@users.noreply.github.com>
Date: Sun, 26 Jul 2026 20:54:06 +0200
Subject: [PATCH 1/6] feat(docs): add website icons and social preview metadata
The documentation site shipped no icon assets and only three Open Graph
tags, because the atelier project type sets `open-graph: true` and Quarto
emits nothing beyond title, description, and site name from that.
Add an icon set derived from one hand-authored SVG, a social preview card
rendered from a committed Typst template, a web app manifest, and a filter
covering the head tags Quarto has no configuration for: `og:type`, a
per-page `og:url` and canonical link, `` from the
pandoc `description-meta` variable Quarto never populates, and the icon,
manifest, and theme-color links.
The Twitter card and `og:locale` are configuration rather than markup:
naming `twitter-card` at all is what switches Quarto's provider on.
`website.site-url` is anchored and republished as top-level metadata
because Quarto keeps the `website` block out of what it hands to Lua
filters, so the filter cannot read it to build the canonical URL.
`project.resources` is required because Quarto copies nothing to the
output directory by convention, and a manifest names its icons in JSON,
which is not scanned for resource references.
404.qmd gains a description; it was the only page without one, since every
other page carries a subtitle that Quarto already uses as the fallback.
---
.gitignore | 2 +
docs/404.qmd | 1 +
docs/_quarto.yml | 31 ++++-
docs/assets/filters/social-metadata.lua | 176 ++++++++++++++++++++++++
docs/assets/icons/README.md | 45 ++++++
docs/assets/icons/apple-touch-icon.png | Bin 0 -> 1114 bytes
docs/assets/icons/icon-192.png | Bin 0 -> 1280 bytes
docs/assets/icons/icon-512.png | Bin 0 -> 3646 bytes
docs/assets/icons/icon.svg | 18 +++
docs/assets/icons/raster-dark.css | 15 ++
docs/assets/social/og-image.png | Bin 0 -> 40357 bytes
docs/assets/social/og-image.typ | 40 ++++++
docs/favicon.ico | Bin 0 -> 4286 bytes
docs/site.webmanifest | 22 +++
14 files changed, 349 insertions(+), 1 deletion(-)
create mode 100644 docs/assets/filters/social-metadata.lua
create mode 100644 docs/assets/icons/README.md
create mode 100644 docs/assets/icons/apple-touch-icon.png
create mode 100644 docs/assets/icons/icon-192.png
create mode 100644 docs/assets/icons/icon-512.png
create mode 100644 docs/assets/icons/icon.svg
create mode 100644 docs/assets/icons/raster-dark.css
create mode 100644 docs/assets/social/og-image.png
create mode 100644 docs/assets/social/og-image.typ
create mode 100644 docs/favicon.ico
create mode 100644 docs/site.webmanifest
diff --git a/.gitignore b/.gitignore
index 9a1776a..fdcca3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
.quarto/
_site/
+
+/.luarc.json
diff --git a/docs/404.qmd b/docs/404.qmd
index d9effd3..6433a50 100644
--- a/docs/404.qmd
+++ b/docs/404.qmd
@@ -1,5 +1,6 @@
---
title: "Page not found"
+description: "This page does not exist or has moved."
toc: false
sidebar: false
---
diff --git a/docs/_quarto.yml b/docs/_quarto.yml
index 66cd0f8..0c17437 100644
--- a/docs/_quarto.yml
+++ b/docs/_quarto.yml
@@ -6,12 +6,33 @@ project:
- reference/
- contributing.qmd
- 404.qmd
+ # Quarto copies nothing to the output directory by convention: only these
+ # globs, plus files it finds behind an `href` or `src` in the rendered HTML.
+ # The manifest names its icons in JSON, which is not scanned, so they are
+ # listed here.
+ resources:
+ - favicon.ico
+ - site.webmanifest
+ - assets/icons/*.png
+ - assets/social/og-image.png
website:
title: "Quarto Codespaces"
description: "GitHub Codespaces and Dev Containers with Quarto, R, Python, and Julia, ready to render."
- site-url: "https://m.canouil.dev/quarto-codespaces"
+ site-url: &site-url "https://m.canouil.dev/quarto-codespaces"
repo-url: https://github.com/mcanouil/quarto-codespaces
+ favicon: favicon.ico
+ # The leading slash marks a project-relative path, which Quarto turns into
+ # `/assets/social/og-image.png` and measures to fill the image
+ # width and height tags. Setting `twitter-card` at all is what switches the
+ # Twitter provider on; it then inherits title, description, image, and image
+ # alt from each page. No `creator` or `site`: there is no X account to name.
+ image: /assets/social/og-image.png
+ image-alt: "The Quarto Codespaces mark, a document outline with an amber chevron, beside the title Quarto Codespaces on a dark navy background."
+ open-graph:
+ locale: en_GB
+ twitter-card:
+ card-style: summary_large_image
page-footer:
right: |
Licence: [MIT](https://github.com/mcanouil/quarto-codespaces?tab=MIT-1-ov-file#readme).
@@ -31,6 +52,13 @@ metadata-files:
- _sidebar-getting-started.yml
- _sidebar-reference.yml
+# Quarto keeps the `website` block out of the metadata it hands to Lua filters,
+# so `assets/filters/social-metadata.lua` cannot read `website.site-url` to
+# build the canonical URL. The anchor above republishes the same scalar as
+# plain document metadata, which does reach the filter, without a second copy
+# of the string.
+site-url: *site-url
+
brand: _brand.yml
# The `atelier` project type sets `format: atelier-html`. Declaring `html`
@@ -51,6 +79,7 @@ format:
filters:
- assets/filters/devcontainer-configurations.lua
+ - assets/filters/social-metadata.lua
- at: post-quarto
path: gitlink
diff --git a/docs/assets/filters/social-metadata.lua b/docs/assets/filters/social-metadata.lua
new file mode 100644
index 0000000..2c6684d
--- /dev/null
+++ b/docs/assets/filters/social-metadata.lua
@@ -0,0 +1,176 @@
+--- @module social-metadata
+--- @license MIT
+--- @copyright 2026 Mickaël Canouil
+--- @author Mickaël Canouil
+---
+--- Emit the head tags Quarto's website machinery does not.
+---
+--- `website.open-graph` covers title, description, image, image dimensions,
+--- image alt, locale, and site name, and `website.twitter-card` covers the
+--- Twitter equivalents. Neither emits `og:type` or `og:url`, there is no
+--- canonical link, and `` needs the pandoc
+--- `description-meta` variable, which Quarto never populates. The icon and
+--- manifest links beyond `rel="icon"` have no configuration key either.
+---
+--- This filter fills exactly those gaps:
+---
+--- - `description-meta`, from `description` or `subtitle`, so every page
+--- gets a plain description tag;
+--- - `og:type`, `og:url`, and ``, built from the
+--- top-level `site-url`, in the same form as the generated sitemap;
+--- - the SVG icon, Apple touch icon, and manifest links, plus `theme-color`.
+---
+--- Paths are written relative to the page, so they resolve under
+--- `quarto preview` at the server root as well as under the deployed
+--- `/quarto-codespaces/` prefix. On `404.html` Quarto rewrites them to
+--- site-absolute paths itself, since a 404 is served from any depth.
+
+--- Icon and manifest links, in head order. `href` is relative to the site root.
+--- @type table>
+local ICON_LINKS = {
+ { rel = "icon", type = "image/svg+xml", href = "assets/icons/icon.svg" },
+ { rel = "apple-touch-icon", sizes = "180x180", href = "assets/icons/apple-touch-icon.png" },
+ { rel = "manifest", href = "site.webmanifest" },
+}
+
+--- Browser chrome colours, from the brand palette: frost and midnight.
+--- @type table>
+local THEME_COLOURS = {
+ { colour = "#F5F7FA", scheme = "light" },
+ { colour = "#0B1220", scheme = "dark" },
+}
+
+--- The 404 page is served for any missing path, so it must not claim a
+--- canonical URL of its own.
+--- @type table
+local NO_CANONICAL = { ["404.qmd"] = true }
+
+--- Escape a value for use inside a double-quoted HTML attribute.
+--- @param value string The raw attribute value
+--- @return string
+local function escape_attribute(value)
+ return value
+ :gsub("&", "&")
+ :gsub("<", "<")
+ :gsub(">", ">")
+ :gsub('"', """)
+end
+
+--- The input file, relative to the project root, with forward slashes.
+--- @return string|nil The relative path, or nil outside a project context
+local function project_relative_input()
+ local project = quarto.project.directory
+ local input = quarto.doc.input_file
+ if not project or not input then
+ return nil
+ end
+ return (pandoc.path.make_relative(input, project):gsub("\\", "/"))
+end
+
+--- The `../` steps that separate the page from the site root.
+--- Empty at the root, so hrefs stay free of a redundant `./`: Quarto prefixes
+--- the site path on `404.html`, and `/quarto-codespaces/./icon.svg` is ugly.
+--- @param relative_input string The input path relative to the project root
+--- @return string The offset, with a trailing slash, or an empty string
+local function offset_to_root(relative_input)
+ local steps = {}
+ for _ in relative_input:gmatch("/") do
+ table.insert(steps, "..")
+ end
+ if #steps == 0 then
+ return ""
+ end
+ return table.concat(steps, "/") .. "/"
+end
+
+--- Build the canonical URL for the page.
+--- Uses the top-level `site-url`, which `_quarto.yml` anchors to
+--- `website.site-url`; the `website` block itself never reaches Lua.
+--- @param meta table The document metadata
+--- @param relative_input string The input path relative to the project root
+--- @return string|nil The absolute URL, or nil when `site-url` is unset
+local function canonical_url(meta, relative_input)
+ if not meta["site-url"] then
+ return nil
+ end
+ local site_url = pandoc.utils.stringify(meta["site-url"]):gsub("/+$", "")
+ local page = relative_input:gsub("%.%w+$", ".html")
+ return site_url .. "/" .. page
+end
+
+--- Render one `` tag.
+--- @param link table One entry of `ICON_LINKS`
+--- @param offset string The offset to the site root, with a trailing slash
+--- @return string
+local function link_tag(link, offset)
+ local attributes = { string.format('rel="%s"', link.rel) }
+ if link.type then
+ table.insert(attributes, string.format('type="%s"', link.type))
+ end
+ if link.sizes then
+ table.insert(attributes, string.format('sizes="%s"', link.sizes))
+ end
+ table.insert(attributes, string.format('href="%s%s"', offset, link.href))
+ return ""
+end
+
+--- Give pandoc a `description-meta` so it emits ``.
+--- Every page here carries a subtitle; `description` wins when both are set.
+--- @param meta table The document metadata, modified in place
+--- @return nil
+local function set_description_meta(meta)
+ if meta["description-meta"] then
+ return
+ end
+ local source = meta.description or meta.subtitle
+ if source then
+ meta["description-meta"] = pandoc.MetaString(pandoc.utils.stringify(source))
+ end
+end
+
+--- @param meta table The document metadata
+--- @return table|nil
+function Meta(meta)
+ if not quarto.doc.is_format("html:js") then
+ return nil
+ end
+
+ set_description_meta(meta)
+
+ local relative_input = project_relative_input()
+ if not relative_input then
+ quarto.log.warning("[social-metadata] no project context; skipping the head tags")
+ return meta
+ end
+
+ local offset = offset_to_root(relative_input)
+ local tags = { '' }
+
+ if not NO_CANONICAL[relative_input] then
+ local url = canonical_url(meta, relative_input)
+ if url then
+ local escaped = escape_attribute(url)
+ table.insert(tags, string.format('', escaped))
+ table.insert(tags, string.format('', escaped))
+ end
+ end
+
+ for _, theme in ipairs(THEME_COLOURS) do
+ table.insert(
+ tags,
+ string.format(
+ '',
+ theme.colour,
+ theme.scheme
+ )
+ )
+ end
+
+ for _, link in ipairs(ICON_LINKS) do
+ table.insert(tags, link_tag(link, offset))
+ end
+
+ quarto.doc.include_text("in-header", table.concat(tags, "\n"))
+
+ return meta
+end
diff --git a/docs/assets/icons/README.md b/docs/assets/icons/README.md
new file mode 100644
index 0000000..649f475
--- /dev/null
+++ b/docs/assets/icons/README.md
@@ -0,0 +1,45 @@
+# Icons
+
+Everything here derives from `icon.svg`, the hand-authored master.
+Nothing is traced, upscaled, or model-generated, so the whole set is reproducible from source.
+
+`icon.svg` carries an inline `@media (prefers-color-scheme: dark)` rule, so browsers that load it directly flip it with the theme: an ink outline with an amber-deep chevron in the light scheme, mist and amber in the dark one.
+The colours come from `../../_brand.yml`.
+
+## Rasters
+
+The rasters sit on the midnight background, matching the navbar, which is dark in both schemes.
+librsvg does not evaluate `prefers-color-scheme`, so `raster-dark.css` forces the dark variant.
+
+Tools: `rsvg-convert` (librsvg) for the vector to raster step, `magick` (ImageMagick 7) for padding, flattening, and `.ico` assembly.
+Neither is a build dependency; the commands are run by hand when the master changes.
+
+Run from this directory.
+
+```sh
+for size in 32 144 154 410; do
+ rsvg-convert -s raster-dark.css -w "${size}" -h "${size}" icon.svg -o "/tmp/icon-${size}.png"
+done
+
+magick /tmp/icon-32.png -background '#0B1220' -flatten ../../favicon.ico
+magick /tmp/icon-144.png -background '#0B1220' -gravity center -extent 180x180 -flatten apple-touch-icon.png
+magick /tmp/icon-154.png -background '#0B1220' -gravity center -extent 192x192 -flatten icon-192.png
+magick /tmp/icon-410.png -background '#0B1220' -gravity center -extent 512x512 -flatten icon-512.png
+```
+
+| File | Size | Purpose |
+| ---------------------- | ------- | --------------------------------------------------- |
+| `icon.svg` | vector | `rel="icon"`, and the source for everything below |
+| `../../favicon.ico` | 32x32 | site root, for clients that ignore the SVG |
+| `apple-touch-icon.png` | 180x180 | iOS home screen, opaque, roughly 10% padding |
+| `icon-192.png` | 192x192 | `site.webmanifest` |
+| `icon-512.png` | 512x512 | `site.webmanifest`, and the mark on the social card |
+
+There is no maskable icon: this is a documentation site, not an installable application.
+
+`icon-512.png` is also what `../social/og-image.typ` places on the social card, since Typst renders SVG through resvg, which ignores the media query.
+
+## After a change
+
+Regenerate the rasters, then regenerate the social card, which embeds `icon-512.png`.
+See the header of `../social/og-image.typ`.
diff --git a/docs/assets/icons/apple-touch-icon.png b/docs/assets/icons/apple-touch-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..d98126f432a1c4a4cd0e3a9f38f7c19fe92e01bd
GIT binary patch
literal 1114
zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD8Ax&oe*=;X3dtTpz6=aiY77hwEes65fIlj
zw<9LGfL}}{yQuc&gO^+P98=S=I)3iDmZ6=FvEz;VFZLZhfBoL`lNWF4nmDc5w5O`E
zx461Z)4=xF*=y3W%ISW(a|(^u)|#gI>fD)a{bI3gRg_+HqJg8C`twD$8){9}^{gdj
zl%6fJwK7y|OE$1HP&;?^-t9-P>*DmQWAy9e^)2+(T9XWxtlpMeQg3Y${2u69#w2fd
z7lsa2Sq~tGy~NYkmHidFl#l{Xsr)kwppa^bYeY#(Vo9o1a#1RfVlXl=GSf9M*EKQ=
zF|@EUHnK7_(l#)#GBDV5;dU2_hTQy=%(P0}8V>g+n*lXwz-=hW%uOvWNz5(4t!K&%
zrqP;Ng(^?)iE)VVsKm^Q(|Gtdo_EzMp-$#+J>}ne
zF8-@?|2V0%Z>`4S%P%#SP6_hTgb;C=OLy>r#9r!^D->-S|p{Nus$PT@KpyB|6Z5N
zR{~7DdtE1ANicEmbql_-^}^xFm-CmaA2xYq^9{&T|=Z!_ysRU)8)2R?4>}a4t)3aJu8{E14I>O69gV&gHnfQ)hMka(DfJ
z{&klYi?2BT^J41Px4v~Hk+zwPzv|@vs@w1CJ-xVBdSh?@9ZIRs-9?OIsi?&{U$8~KDA{||+
be#QBd!IG(XV)u1mf@bh^^>bP0l+XkKy7%ND
literal 0
HcmV?d00001
diff --git a/docs/assets/icons/icon-192.png b/docs/assets/icons/icon-192.png
new file mode 100644
index 0000000000000000000000000000000000000000..e2e96919470b9362216ec478c5c2c88e381cc6db
GIT binary patch
literal 1280
zcmZ`&eN0n#6#czN3#A}biUL*PAr$JM`Y1@TA5ch(1&0=_8;ngWUrw=BUDcwqJc|m-
zPy|I%Q3`XYtgZ$`ky)(>Mj7gy0Tn@t%<>^j37Wz7kUf`d+5Yg4drs~-Ik~yXy$58=
zm(HLvr~rT&aZ+&-!It+$u_G`Ou2@VEk}ge30AObT0N7pt{vaf55P)qc0K*CZc;x_i
zY_4cZ6cEO!>|?pa$qn)L=foy__Ia9WP1cqbtI~o)qd7|?l{Ix;eZ$@TBTdci^3-+7
zYqC3gY=@2+XdX=Qvg8xh=Wn(>Oial