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/_extensions/mcanouil/atelier/_extension.yml b/docs/_extensions/mcanouil/atelier/_extension.yml index f69aabb..7a7d88e 100644 --- a/docs/_extensions/mcanouil/atelier/_extension.yml +++ b/docs/_extensions/mcanouil/atelier/_extension.yml @@ -1,8 +1,8 @@ title: Atelier author: Mickaël Canouil -version: 0.3.0 +version: 0.5.0 quarto-required: '>=1.9.36' -source: mcanouil/quarto-atelier@0.3.0 +source: mcanouil/quarto-atelier@0.5.0 source-type: github contributes: formats: @@ -32,6 +32,8 @@ contributes: sidebar-width: 300px body-width: 1000px margin-width: 300px + filters: + - social-metadata.lua include-before-body: - file: html/scripts/skip-link.html include-after-body: @@ -50,7 +52,10 @@ contributes: - issue repo-link-target: _blank repo-link-rel: noopener noreferrer - open-graph: true + open-graph: + locale: en_GB + twitter-card: + card-style: summary_large_image page-navigation: true back-to-top-navigation: true llms-txt: true diff --git a/docs/_extensions/mcanouil/atelier/_modules/logging.lua b/docs/_extensions/mcanouil/atelier/_modules/logging.lua new file mode 100644 index 0000000..a538809 --- /dev/null +++ b/docs/_extensions/mcanouil/atelier/_modules/logging.lua @@ -0,0 +1,62 @@ +--- MC Logging - Formatted log output for Quarto Lua filters and shortcodes +--- @module "logging" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @version 1.0.0 + +local M = {} + +-- ============================================================================ +-- LOGGING UTILITIES +-- ============================================================================ + +--- Format and log an error message with extension prefix. +--- Provides standardised error messages with consistent formatting across extensions. +--- Format: [extension-name] Message with details. +--- +--- @param extension_name string The name of the extension (e.g., "external", "lua-env") +--- @param message string The error message to display +--- @usage M.log_error("external", "Could not open file 'example.md'.") +function M.log_error(extension_name, message) + quarto.log.error('[' .. extension_name .. '] ' .. message) +end + +--- Format and log a warning message with extension prefix. +--- Provides standardised warning messages with consistent formatting across extensions. +--- Format: [extension-name] Message with details. +--- +--- @param extension_name string The name of the extension (e.g., "external", "lua-env") +--- @param message string The warning message to display +--- @usage M.log_warning("lua-env", "No variable name provided.") +function M.log_warning(extension_name, message) + quarto.log.warning('[' .. extension_name .. '] ' .. message) +end + +--- Format and log an output message with extension prefix. +--- Provides standardised informational messages with consistent formatting across extensions. +--- Format: [extension-name] Message with details. +--- +--- @param extension_name string The name of the extension (e.g., "lua-env") +--- @param message string The informational message to display +--- @usage M.log_output("lua-env", "Exported metadata to: output.json") +function M.log_output(extension_name, message) + quarto.log.output('[' .. extension_name .. '] ' .. message) +end + +--- Format and log a debug message with extension prefix. +--- Provides standardised debug messages with consistent formatting across extensions. +--- Format: [extension-name] Message with details. +--- +--- @param extension_name string The name of the extension (e.g., "lua-env") +--- @param message string The debug message to display +--- @usage M.log_debug("lua-env", "Variable 'x' has value: 42") +function M.log_debug(extension_name, message) + quarto.log.debug('[' .. extension_name .. '] ' .. message) +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/docs/_extensions/mcanouil/atelier/_modules/metadata.lua b/docs/_extensions/mcanouil/atelier/_modules/metadata.lua new file mode 100644 index 0000000..bab2087 --- /dev/null +++ b/docs/_extensions/mcanouil/atelier/_modules/metadata.lua @@ -0,0 +1,182 @@ +--- MC Metadata - Extension configuration and metadata access for Quarto Lua filters and shortcodes +--- @module "metadata" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @version 1.0.0 + +local M = {} + +--- Load a sibling module from the same directory as this file. +--- @param filename string The sibling module filename (e.g., 'string.lua') +--- @return table The loaded module +local function load_sibling(filename) + local source = debug.getinfo(1, 'S').source:sub(2) + local dir = source:match('(.*[/\\])') or '' + return require((dir .. filename):gsub('%.lua$', '')) +end + +--- Load required modules +local str = load_sibling('string.lua') +local log = load_sibling('logging.lua') + +-- ============================================================================ +-- METADATA UTILITIES +-- ============================================================================ + +--- Get configuration from extensions.name namespace. +--- @param meta table Document metadata +--- @param extension_name string The extension name (e.g., "github", "iconify") +--- @return any The value/table or nil +--- @usage local value = M.get_extension_config(meta, 'section-outline') +function M.get_extension_config(meta, extension_name) + local config_ext = meta.extensions and meta.extensions[extension_name] + if not config_ext then return nil end + return config_ext +end + +--- Extract metadata value from document meta using nested structure. +--- Supports the extensions.{extension-name}.{key} pattern. +--- @param meta table The document metadata table +--- @param extension_name string The extension name (e.g., "github", "iconify") +--- @param key string The metadata key to retrieve +--- @return string|nil The metadata value as a string, or nil if not found +--- @usage local repo = M.get_metadata_value(meta, "github", "repository-name") +function M.get_metadata_value(meta, extension_name, key) + if meta['extensions'] and meta['extensions'][extension_name] and meta['extensions'][extension_name][key] then + return str.stringify(meta['extensions'][extension_name][key]) + end + return nil +end + +--- Check for deprecated top-level configuration and emit warning +--- @param meta table The document metadata table +--- @param extension_name string The extension name +--- @param key string|nil The configuration key being accessed (nil to check entire extension config) +--- @param deprecation_warning_shown boolean Flag to track if warning has been shown +--- @return any|nil The value from deprecated config, or nil if not found +--- @return boolean Updated deprecation warning flag +function M.check_deprecated_config(meta, extension_name, key, deprecation_warning_shown) + -- Handle array-based configuration (when key is nil) + if key == nil then + if not str.is_empty(meta[extension_name]) then + if not deprecation_warning_shown then + log.log_warning( + extension_name, + 'Top-level "' .. extension_name .. '" configuration is deprecated. ' .. + 'Please use:\n' .. + 'extensions:\n' .. + ' ' .. extension_name .. ':\n' .. + ' - (configuration array)' + ) + deprecation_warning_shown = true + end + return meta[extension_name], deprecation_warning_shown + end + return nil, deprecation_warning_shown + end + + -- Handle key-value configuration (original behaviour) + if not str.is_empty(meta[extension_name]) and not str.is_empty(meta[extension_name][key]) then + if not deprecation_warning_shown then + log.log_warning( + extension_name, + 'Top-level "' .. extension_name .. '" configuration is deprecated. ' .. + 'Please use:\n' .. + 'extensions:\n' .. + ' ' .. extension_name .. ':\n' .. + ' ' .. key .. ': value' + ) + deprecation_warning_shown = true + end + return str.stringify(meta[extension_name][key]), deprecation_warning_shown + end + return nil, deprecation_warning_shown +end + +-- ============================================================================ +-- ENHANCED METADATA/CONFIGURATION UTILITIES +-- ============================================================================ + +--- Get option value with fallback hierarchy: args -> extensions.{extension}.{key} -> defaults. +--- Provides a standardised way to read configuration values with multiple fallback levels. +--- Priority: 1. Named arguments (kwargs), 2. Document metadata, 3. Default values. +--- +--- @param spec table Configuration spec with fields: extension (string), key (string), args (table|nil), meta (table|nil), default (any|nil) +--- @return any The resolved option value (type depends on what's stored in config) +--- @usage local duration = M.get_option_with_fallbacks({extension = 'animate', key = 'duration', args = kwargs, meta = meta, default = '3s'}) +function M.get_option_with_fallbacks(spec) + -- Validate required fields + if not spec.extension or not spec.key then + error("Configuration spec must include 'extension' and 'key' fields") + end + + --- @type string The extension name + local extension = spec.extension + --- @type string The configuration key + local key = spec.key + --- @type table|nil Named arguments table + local args = spec.args + --- @type table|nil Document metadata + local meta = spec.meta + --- @type any Default value if not found elsewhere + local default = spec.default + + -- Priority 1: Check named arguments (kwargs) + if args and args[key] then + local arg_value = str.stringify(args[key]) + if not str.is_empty(arg_value) then + return arg_value + end + end + + -- Priority 2: Check metadata extensions.{extension}.{key} + if meta then + local meta_value = M.get_metadata_value(meta, extension, key) + if not str.is_empty(meta_value) then + return meta_value + end + end + + -- Priority 3: Return default value + return default +end + +--- Get multiple option values at once with fallback hierarchy. +--- Batch version of get_option_with_fallbacks for retrieving multiple configuration values. +--- Returns a table mapping each key to its resolved value. +--- +--- @param spec table Configuration spec with fields: extension (string), keys (table), args (table|nil), meta (table|nil), defaults (table|nil) +--- @return table Table mapping each key to its resolved value +--- @usage local opts = M.get_options({extension = 'animate', keys = {'duration', 'delay'}, args = kwargs, meta = meta, defaults = {duration = '3s', delay = '2s'}}) +function M.get_options(spec) + -- Validate required fields + if not spec.extension or not spec.keys then + error("Configuration spec must include 'extension' and 'keys' fields") + end + + --- @type table Result table + local result = {} + + --- @type table Default values table + local defaults = spec.defaults or {} + + -- Get each key using the single-option fallback logic + for _, key in ipairs(spec.keys) do + result[key] = M.get_option_with_fallbacks({ + extension = spec.extension, + key = key, + args = spec.args, + meta = spec.meta, + default = defaults[key] + }) + end + + return result +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/docs/_extensions/mcanouil/atelier/_modules/string.lua b/docs/_extensions/mcanouil/atelier/_modules/string.lua new file mode 100644 index 0000000..8f89e0b --- /dev/null +++ b/docs/_extensions/mcanouil/atelier/_modules/string.lua @@ -0,0 +1,198 @@ +--- MC String - String manipulation and escaping for Quarto Lua filters and shortcodes +--- @module "string" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @version 1.0.0 + +local M = {} + +-- ============================================================================ +-- STRING UTILITIES +-- ============================================================================ + +--- Pandoc utility function for converting values to strings +--- @type function +M.stringify = pandoc.utils.stringify + +--- Check if a string is empty or nil. +--- Utility function to determine if a value is empty or nil, +--- which is useful for parameter validation throughout the module. +--- @param s string|nil|table The value to check for emptiness +--- @return boolean True if the value is nil or empty, false otherwise +--- @usage local result = M.is_empty("") -- returns true +--- @usage local result = M.is_empty(nil) -- returns true +--- @usage local result = M.is_empty("hello") -- returns false +function M.is_empty(s) + return s == nil or s == '' +end + +--- Escape special pattern characters in a string for Lua pattern matching +--- @param s string The string to escape +--- @return string The escaped string +--- @usage local escaped = M.escape_pattern("user/repo#123") +function M.escape_pattern(s) + local escaped = s:gsub('([%^%$%(%)%%%.%[%]%*%+%-%?])', '%%%1') + return escaped +end + +--- Split a string by a separator +--- @param str string The string to split +--- @param sep string The separator pattern +--- @return table Array of string fields +--- @usage local parts = M.split("a.b.c", ".") +function M.split(str, sep) + local fields = {} + local pattern = string.format('([^%s]+)', sep) + str:gsub(pattern, function(c) fields[#fields + 1] = c end) + return fields +end + +--- Trim leading and trailing whitespace from a string +--- @param str string The string to trim +--- @return string The trimmed string +--- @usage local trimmed = M.trim(" hello world ") -- returns "hello world" +function M.trim(str) + if str == nil then return '' end + return str:match('^%s*(.-)%s*$') +end + +--- Convert any value to a string, handling Pandoc objects and empty values. +--- Returns nil for empty or nil values, otherwise returns a string representation. +--- @param val any The value to convert +--- @return string|nil The string value or nil if empty +--- @usage local str = M.to_string(kwargs.value) +function M.to_string(val) + if not val then return nil end + if type(val) == 'string' then + return val ~= '' and val or nil + end + -- Handle Pandoc objects + if pandoc and pandoc.utils and pandoc.utils.stringify then + local str = pandoc.utils.stringify(val) + return str ~= '' and str or nil + end + local str = tostring(val) + return str ~= '' and str or nil +end + +-- ============================================================================ +-- ESCAPE UTILITIES +-- ============================================================================ + +--- Escape special LaTeX characters in text. +--- @param text string The text to escape +--- @return string The escaped text safe for LaTeX +function M.escape_latex(text) + text = string.gsub(text, '\\', '\\textbackslash{}') + text = string.gsub(text, '%{', '\\{') + text = string.gsub(text, '%}', '\\}') + text = string.gsub(text, '%$', '\\$') + text = string.gsub(text, '%&', '\\&') + text = string.gsub(text, '%%', '\\%%') + text = string.gsub(text, '%#', '\\#') + text = string.gsub(text, '%^', '\\textasciicircum{}') + text = string.gsub(text, '%_', '\\_') + text = string.gsub(text, '~', '\\textasciitilde{}') + return text +end + +--- Escape special Typst characters in text. +--- @param text string The text to escape +--- @return string The escaped text safe for Typst +function M.escape_typst(text) + text = string.gsub(text, '%#', '\\#') + return text +end + +--- Escape characters for Typst string literals (inside `"..."`). +--- @param text string The text to escape +--- @return string The escaped text safe for Typst string literals +function M.escape_typst_string(text) + return text:gsub('\\', '\\\\'):gsub('"', '\\"') +end + +--- Escape special Lua pattern characters for use in string.gsub. +--- @param text string The text containing characters to escape +--- @return string The escaped text safe for Lua patterns +function M.escape_lua_pattern(text) + text = string.gsub(text, '%%', '%%%%') + text = string.gsub(text, '%^', '%%^') + text = string.gsub(text, '%$', '%%$') + text = string.gsub(text, '%(', '%%(') + text = string.gsub(text, '%)', '%%)') + text = string.gsub(text, '%.', '%%.') + text = string.gsub(text, '%[', '%%[') + text = string.gsub(text, '%]', '%%]') + text = string.gsub(text, '%*', '%%*') + text = string.gsub(text, '%+', '%%+') + text = string.gsub(text, '%-', '%%-') + text = string.gsub(text, '%?', '%%?') + return text +end + +--- Escape special HTML characters in text. +--- Escapes &, <, >, ", and ' to prevent XSS and ensure valid HTML. +--- @param text string The text to escape +--- @return string Escaped text safe for use in HTML +--- @usage local escaped = M.escape_html('Hello ') +function M.escape_html(text) + if text == nil then return '' end + if type(text) ~= 'string' then text = tostring(text) end + local result = text + :gsub('&', '&') + :gsub('<', '<') + :gsub('>', '>') + :gsub('"', '"') + :gsub("'", ''') + return result +end + +--- Escape special HTML attribute characters. +--- Escapes characters that could break attribute values. +--- @param value string The attribute value to escape +--- @return string Escaped value safe for use in HTML attributes +--- @usage local escaped = M.escape_attribute('Hello "World"') +function M.escape_attribute(value) + if value == nil then return '' end + if type(value) ~= 'string' then value = tostring(value) end + local result = value + :gsub('&', '&') + :gsub('"', '"') + :gsub('<', '<') + :gsub('>', '>') + return result +end + +--- Escape text for different formats. +--- @param text string The text to escape +--- @param format string The format to escape for (e.g., "latex", "typst", "lua") +--- @return string The escaped text +function M.escape_text(text, format) + local escape_functions = { + latex = M.escape_latex, + typst = M.escape_typst, + lua = M.escape_lua_pattern + } + + local escape = escape_functions[format] + if escape then + return escape(text) + else + error('Unsupported escape format: ' .. format) + end +end + +--- Converts a string to a valid HTML id by lowercasing and replacing spaces. +--- @param text string The text to convert +--- @return string The HTML id +function M.ascii_id(text) + local id = text:lower():gsub('[^a-z0-9 ]', ''):gsub(' +', '-') + return id +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/docs/_extensions/mcanouil/atelier/social-metadata.lua b/docs/_extensions/mcanouil/atelier/social-metadata.lua new file mode 100644 index 0000000..dedb521 --- /dev/null +++ b/docs/_extensions/mcanouil/atelier/social-metadata.lua @@ -0,0 +1,219 @@ +--- Atelier - Social Metadata Filter +--- @module "social-metadata" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @brief Emit the head tags Quarto's website machinery leaves out. +--- @description Quarto's `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, `` +--- needs the pandoc `description-meta` variable that Quarto never populates, +--- and the icon and manifest links beyond `rel="icon"` have no configuration +--- key. This filter fills those gaps and nothing else. + +--- Extension name constant +local EXTENSION_NAME = 'atelier' + +--- Load modules +local str = require(quarto.utils.resolve_path('_modules/string.lua'):gsub('%.lua$', '')) +local log = require(quarto.utils.resolve_path('_modules/logging.lua'):gsub('%.lua$', '')) +local meta_mod = require(quarto.utils.resolve_path('_modules/metadata.lua'):gsub('%.lua$', '')) + +--- Link tags built from the configured paths, in head order. +--- `option` names the `extensions.atelier` key that supplies the `href`; +--- `type` and `sizes` are optional and emitted only when present. +--- @type table +local ICON_LINKS = { + { option = 'icon', rel = 'icon', type = 'image/svg+xml' }, + { option = 'apple-touch-icon', rel = 'apple-touch-icon', sizes = '180x180' }, + { option = 'manifest', rel = 'manifest' }, +} + +--- Colour schemes read from the `theme-color` option, in head order. +--- @type string[] +local THEME_COLOUR_SCHEMES = { 'light', 'dark' } + +--- Stem of the page Quarto renders for missing paths. It is served from any +--- URL depth, so it must not claim a canonical URL of its own. +--- @type string +local NOT_FOUND_STEM = '404' + +--- Stem of a directory index, served from its directory rather than from the +--- file itself. +--- @type string +local INDEX_STEM = 'index' + +--- 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 + +--- Whether the page is the project's 404 page. +--- @param relative_input string The input path relative to the project root +--- @return boolean +local function is_not_found_page(relative_input) + return relative_input:match('^' .. NOT_FOUND_STEM .. '%.%w+$') ~= nil +end + +--- The page path as the site serves it, relative to the site root. +--- A directory index is served from its directory rather than from +--- `index.html`, and that is the URL a visitor lands on and the one a scraper +--- de-duplicates against, so `index.qmd` maps to the site root and +--- `/index.qmd` to `/`. +--- @param relative_input string The input path relative to the project root +--- @return string The served path, empty at the site root +local function served_path(relative_input) + local stem = relative_input:gsub('%.%w+$', '') + if stem == INDEX_STEM then + return '' + end + local directory = stem:match('^(.*)/' .. INDEX_STEM .. '$') + if directory then + return directory .. '/' + end + return stem .. '.html' +end + +--- Build the canonical URL for the page. +--- Uses `extensions.atelier.site-url`, because Quarto keeps the `website` +--- block out of the metadata it hands to Lua filters. Anchor the two together +--- in `_quarto.yml` so the URL is written once. +--- @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) + local site_url = meta_mod.get_metadata_value(meta, EXTENSION_NAME, 'site-url') + if str.is_empty(site_url) then + return nil + end + return (site_url:gsub('/+$', '')) .. '/' .. served_path(relative_input) +end + +--- Render one `` tag. +--- The `href` is written exactly as configured, relative to the site root. +--- Quarto's website resource resolver rewrites every `link[href]` it finds in +--- the rendered page, prefixing the page's own offset to the project root, so +--- adding one here too would double it on any page below the root. The 404 +--- page is rewritten to a site-absolute path by the same pass. +--- @param link table One entry of `ICON_LINKS` +--- @param href string The configured path, relative to the site root +--- @return string +local function link_tag(link, href) + 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"', str.escape_attribute(href))) + return '' +end + +--- Give pandoc a `description-meta` so it emits ``. +--- Falls back to `subtitle`, which most pages carry; `description` wins when +--- both are set. Left alone when the document sets it itself. +--- @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(str.stringify(source)) + end +end + +--- Collect the icon and manifest link tags for the page. +--- @param config table|nil The `extensions.atelier` configuration table +--- @return table +local function icon_tags(config) + local tags = {} + if not config then + return tags + end + for _, link in ipairs(ICON_LINKS) do + local href = config[link.option] and str.stringify(config[link.option]) + if not str.is_empty(href) then + table.insert(tags, link_tag(link, href)) + end + end + return tags +end + +--- Collect the `theme-color` tags for the page. +--- @param config table|nil The `extensions.atelier` configuration table +--- @return table +local function theme_colour_tags(config) + local tags = {} + local colours = config and config['theme-color'] + if not colours then + return tags + end + for _, scheme in ipairs(THEME_COLOUR_SCHEMES) do + local colour = colours[scheme] and str.stringify(colours[scheme]) + if not str.is_empty(colour) then + table.insert( + tags, + string.format( + '', + str.escape_attribute(colour), + scheme + ) + ) + end + end + return tags +end + +--- @param meta table The document metadata +--- @return table|nil +local function social_metadata(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 + log.log_warning(EXTENSION_NAME, 'No project context; skipping the social metadata head tags.') + return meta + end + + local config = meta_mod.get_extension_config(meta, EXTENSION_NAME) + local tags = { '' } + + if not is_not_found_page(relative_input) then + local url = canonical_url(meta, relative_input) + if url then + local escaped = str.escape_attribute(url) + table.insert(tags, string.format('', escaped)) + table.insert(tags, string.format('', escaped)) + end + end + + for _, tag in ipairs(theme_colour_tags(config)) do + table.insert(tags, tag) + end + + for _, tag in ipairs(icon_tags(config)) do + table.insert(tags, tag) + end + + quarto.doc.include_text('in-header', table.concat(tags, '\n')) + + return meta +end + +return { + { Meta = social_metadata } +} diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 66cd0f8..caf060d 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -6,12 +6,29 @@ 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. The card inherits into both Open Graph and the + # Twitter card, which atelier switches on; no `creator` or `site` handles, + # since 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." page-footer: right: | Licence: [MIT](https://github.com/mcanouil/quarto-codespaces?tab=MIT-1-ov-file#readme). @@ -55,6 +72,18 @@ filters: path: gitlink extensions: + atelier: + # Quarto keeps the `website` block out of the metadata it hands to Lua + # filters, so atelier's social-metadata filter cannot read + # `website.site-url` to build the canonical URL. The anchor above + # republishes the same scalar here, without a second copy of the string. + site-url: *site-url + icon: assets/icons/icon.svg + apple-touch-icon: assets/icons/apple-touch-icon.png + manifest: site.webmanifest + theme-color: + light: "#F5F7FA" + dark: "#0B1220" gitlink: # Widget only: the platform and repository come from `website.repo-url`, # and in-text reference rewriting stays off. diff --git a/docs/assets/filters/devcontainer-configurations.lua b/docs/assets/filters/devcontainer-configurations.lua index 9614f83..0982ec0 100644 --- a/docs/assets/filters/devcontainer-configurations.lua +++ b/docs/assets/filters/devcontainer-configurations.lua @@ -1,22 +1,30 @@ ---[[ -Generate the configuration reference from the repository itself. - -The `.devcontainer/` directory gains a new `quarto-` configuration -every time Quarto releases a minor version, and every place that lists those -versions by hand drifts the moment one is added. This filter reads the -directory at render time and fills in: - - - `::: {#devcontainer-configurations}`, replaced by the configuration table; - - `[]{.version-range}`, replaced by "`` … ``"; - - `[]{.version-latest}`, replaced by the highest version. - -Both spans accept a `suffix` attribute, appended inside the code spans, so -`[]{.version-range suffix="-noble"}` renders "`1.0-noble` … `1.10-noble`". -]] - +--- @module devcontainer-configurations +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- +--- Generate the configuration reference from the repository itself. +--- +--- The `.devcontainer/` directory gains a new `quarto-` +--- configuration every time Quarto releases a minor version, and every place +--- that lists those versions by hand drifts the moment one is added. This +--- filter reads the directory at render time and fills in: +--- +--- - `::: {#devcontainer-configurations}`, replaced by the configuration +--- table; +--- - `[]{.version-range}`, replaced by "`` … ``"; +--- - `[]{.version-latest}`, replaced by the highest version. +--- +--- Both spans accept a `suffix` attribute, appended inside the code spans, so +--- `[]{.version-range suffix="-noble"}` renders "`1.0-noble` … `1.10-noble`". + +--- @type string The image referenced by every shipped configuration local IMAGE = "`quarto-codespaces:latest`" --- Compare two dotted version strings numerically, so 1.10 sorts after 1.9. +--- @param a string The left version, as `.` +--- @param b string The right version, as `.` +--- @return boolean local function version_less_than(a, b) local a_major, a_minor = a:match("^(%d+)%.(%d+)$") local b_major, b_minor = b:match("^(%d+)%.(%d+)$") diff --git a/docs/assets/icons/README.md b/docs/assets/icons/README.md new file mode 100644 index 0000000..9d393c7 --- /dev/null +++ b/docs/assets/icons/README.md @@ -0,0 +1,49 @@ +# 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 -strip /tmp/icon-32-flat.png +magick /tmp/icon-32-flat.png -define icon:format=png ../../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 +``` + +The favicon is flattened to an opaque PNG before the `.ico` is written. +Writing the `.ico` straight from the transparent render makes ImageMagick store an uncompressed 32-bit bitmap, 4,286 bytes for the same 32x32 pixels; going through the flat PNG lets it pick a palette instead, at 2,238 bytes and byte-for-byte identical output. + +| 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`, which is how this site's card happens to be built rather than a prescribed approach. diff --git a/docs/assets/icons/apple-touch-icon.png b/docs/assets/icons/apple-touch-icon.png new file mode 100644 index 0000000..d98126f Binary files /dev/null and b/docs/assets/icons/apple-touch-icon.png differ diff --git a/docs/assets/icons/icon-192.png b/docs/assets/icons/icon-192.png new file mode 100644 index 0000000..e2e9691 Binary files /dev/null and b/docs/assets/icons/icon-192.png differ diff --git a/docs/assets/icons/icon-512.png b/docs/assets/icons/icon-512.png new file mode 100644 index 0000000..60a0829 Binary files /dev/null and b/docs/assets/icons/icon-512.png differ diff --git a/docs/assets/icons/icon.svg b/docs/assets/icons/icon.svg new file mode 100644 index 0000000..db237c4 --- /dev/null +++ b/docs/assets/icons/icon.svg @@ -0,0 +1,18 @@ + + Quarto Codespaces + + + + diff --git a/docs/assets/icons/raster-dark.css b/docs/assets/icons/raster-dark.css new file mode 100644 index 0000000..489831c --- /dev/null +++ b/docs/assets/icons/raster-dark.css @@ -0,0 +1,15 @@ +/* Force the dark-scheme colours when rasterising icon.svg. + + librsvg does not evaluate `prefers-color-scheme`, so it would render the + light variant, an ink outline that disappears once flattened onto the + midnight background the PNG and ICO files use. `rsvg-convert --stylesheet` + applies this as a user stylesheet, hence `!important` to beat the author + rules inside the SVG. */ + +.page { + stroke: #DCE3EC !important; +} + +.caret { + stroke: #E8A33D !important; +} diff --git a/docs/assets/social/og-image.png b/docs/assets/social/og-image.png new file mode 100644 index 0000000..6283bcd Binary files /dev/null and b/docs/assets/social/og-image.png differ diff --git a/docs/assets/social/og-image.typ b/docs/assets/social/og-image.typ new file mode 100644 index 0000000..fb0bc84 --- /dev/null +++ b/docs/assets/social/og-image.typ @@ -0,0 +1,52 @@ +// Social preview card for this site, 1200x630. +// +// This is one way to produce a card, not a house style or a template to copy. +// It is committed so that this site's own card can be regenerated from source +// rather than being an orphaned binary; anything that emits a 1200x630 PNG +// would do just as well. +// +// Rendered to og-image.png from `docs/` with: +// +// typst compile --root . --format png --ppi 72 \ +// assets/social/og-image.typ assets/social/og-image.png +// magick assets/social/og-image.png -alpha off -strip \ +// -define png:compression-level=9 assets/social/og-image.png +// +// Typst writes an alpha channel that is opaque everywhere, since the page has +// a solid fill. Dropping it is lossless and takes the file from 40 KB to 39 KB. +// +// `--root .` is what lets the template reach the mark in assets/icons/; Typst +// otherwise sandboxes it to its own directory. +// +// The page is 1200pt by 630pt and the render is 72 ppi, so one point is one +// pixel and the output is exactly 1200x630. +// +// The mark comes in as icon-512.png rather than icon.svg because Typst renders +// SVG through resvg, which does not evaluate `prefers-color-scheme` and would +// draw the light variant: an ink outline, invisible on midnight. That PNG is +// already the dark variant on an opaque midnight square, so it sits seamlessly +// on the page fill. +// +// Colours and fonts come from ../../_brand.yml. Space Grotesk ships no static +// 600 face, so the title uses Bold, the closest heavier match for display type. + +#let midnight = rgb("#0B1220") +#let mist = rgb("#DCE3EC") +#let mist-muted = rgb("#8EA0B8") + +#set page(width: 1200pt, height: 630pt, margin: 80pt, fill: midnight) +#set text(fill: mist) + +#align(horizon)[ + #stack( + dir: ttb, + spacing: 44pt, + image("/assets/icons/icon-512.png", width: 128pt), + text(font: "Space Grotesk", weight: 700, size: 92pt, tracking: -1.5pt)[ + Quarto Codespaces + ], + text(font: "IBM Plex Sans", weight: 300, size: 34pt, fill: mist-muted)[ + Quarto, R, Python, and Julia, ready to render. + ], + ) +] diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..7f7b607 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/site.webmanifest b/docs/site.webmanifest new file mode 100644 index 0000000..fc53bc2 --- /dev/null +++ b/docs/site.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Quarto Codespaces", + "short_name": "Codespaces", + "description": "GitHub Codespaces and Dev Containers with Quarto, R, Python, and Julia, ready to render.", + "lang": "en-GB", + "icons": [ + { + "src": "/quarto-codespaces/assets/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/quarto-codespaces/assets/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#0B1220", + "background_color": "#0B1220", + "display": "standalone", + "start_url": "/quarto-codespaces/" +}