Skip to content
Open
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
3 changes: 3 additions & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ All changes included in 1.9:
- ([#13589](https://github.com/quarto-dev/quarto-cli/issues/13589)): Fix callouts with invalid ID prefixes crashing with "attempt to index a nil value". Callouts with unknown reference types now render as non-crossreferenceable callouts with a warning, ignoring the invalid ID.
- ([#13602](https://github.com/quarto-dev/quarto-cli/issues/13602)): Fix support for multiple files set in `bibliography` field in `biblio.typ` template partial.
- ([#13775](https://github.com/quarto-dev/quarto-cli/issues/13775)): Fix brand fonts not being applied when using `citeproc: true` with Typst format. Format detection now properly handles Pandoc format variants like `typst-citations`.
- ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add support for `alt` attribute on cross-referenced equations for improved accessibility. (author: @mcanouil)

### `pdf`

Expand All @@ -62,6 +63,7 @@ All changes included in 1.9:
- ([#13547](https://github.com/quarto-dev/quarto-cli/issues/13547))`cookie-content: { type: express }` is now the default. Previously it was `type: implied`. It now means this will block cookies until the user expressly agrees to allow them (or continue blocking them if the user doesn't agree).
- ([#13570](https://github.com/quarto-dev/quarto-cli/pull/13570)): Replace Twitter with Bluesky in default blog template and documentation examples. New blog projects now include Bluesky social links instead of Twitter.
- ([#13716](https://github.com/quarto-dev/quarto-cli/issues/13716)): Fix draft pages showing blank during preview when pre-render scripts are configured.
- ([#13847](https://github.com/quarto-dev/quarto-cli/pull/13847)): Open graph title with markdown is now processed correctly. (author: @mcanouil)

### `book`

Expand Down Expand Up @@ -104,3 +106,4 @@ All changes included in 1.9:
- ([#13575](https://github.com/quarto-dev/quarto-cli/pull/13575)): Improve CPU architecture detection/reporting in macOS to allow quarto to run in virtualized environments such as OpenAI's `codex`.
- ([#13656](https://github.com/quarto-dev/quarto-cli/issues/13656)): Fix R code cells with empty `lang: ""` option producing invalid markdown class attributes.
- ([#13832](https://github.com/quarto-dev/quarto-cli/pull/13832)): Fix `license.text` metadata not being accessible when using an inline license (`license: "text"`), and populate it with the license name for CC licenses instead of empty string. (author: @mcanouil)
- ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add `alt` attribute support for cross-referenced equations in Typst output for PDF UA-1 accessibility compliance. (author: @mcanouil)
2 changes: 1 addition & 1 deletion src/project/types/website/website-meta.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from a commit we already merged on main.
We'll sort out while merging to keep PR scoped and clean.

Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ function metaMarkdownPipeline(format: Format, extras: FormatExtras) {
if (renderedEl) {
// Update the document title
const el = doc.querySelector(
`meta[name="og:site_name"]`,
`meta[property="og:site_name"]`,
);
if (el) {
el.setAttribute("content", renderedEl.innerText);
Expand Down
220 changes: 174 additions & 46 deletions src/resources/filters/crossref/equations.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
-- equations.lua
-- Copyright (C) 2020-2022 Posit Software, PBC
-- Copyright (C) 2020-2026 Posit Software, PBC

-- process all equations
function equations()
Expand All @@ -21,67 +21,57 @@ function process_equations(blockEl)

local mathInlines = nil
local targetInlines = pandoc.Inlines{}
local skipUntil = 0

for i, el in ipairs(inlines) do

-- see if we need special handling for pending math, if
-- we do then track whether we should still process the
-- inline at the end of the loop
local processInline = true

-- Skip elements that were consumed as part of a multi-element attribute block
if i <= skipUntil then
processInline = false
goto continue
end
if mathInlines then
if el.t == "Space" then
mathInlines:insert(el)
processInline = false
elseif el.t == "Str" and refLabel("eq", el) then

-- add to the index
local label = refLabel("eq", el)
local order = indexNextOrder("eq")
indexAddEntry(label, nil, order)

-- get the equation
local eq = mathInlines[1]

-- write equation
if _quarto.format.isLatexOutput() then
targetInlines:insert(pandoc.RawInline("latex", "\\begin{equation}"))
targetInlines:insert(pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)))

-- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
-- which would result in two identical labels being emitted.
-- https://github.com/jgm/pandoc/issues/9045
-- https://github.com/lierdakil/pandoc-crossref/issues/402
targetInlines:insert(pandoc.RawInline("latex", "\\end{equation}"))

elseif _quarto.format.isTypstOutput() then
local is_block = eq.mathtype == "DisplayMath" and "true" or "false"
targetInlines:insert(pandoc.RawInline("typst",
"#math.equation(block: " .. is_block .. ", numbering: \"(1)\", " ..
"[ "))
targetInlines:insert(eq)
targetInlines:insert(pandoc.RawInline("typst", " ])<" .. label .. ">"))
else
local eqNumber = eqQquad
local mathMethod = param("html-math-method", nil)
if type(mathMethod) == "table" and mathMethod["method"] then
mathMethod = mathMethod["method"]
end
if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then
eqNumber = eqTag
-- Check "starts with" not complete match: Pandoc splits {#eq-label alt="..."} across elements
elseif el.t == "Str" and el.text:match("^{#eq%-") then
-- Collect attribute block: {#eq-label alt="..."} may span multiple elements
local attrText, consumed = collectAttrBlock(inlines, i)

if attrText then
-- Parse to extract label and optional attributes (e.g., alt for Typst)
local label, attributes = parseRefAttr(attrText)
if not label then
label = extractRefLabel("eq", attrText)
end
eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order)))
local span = pandoc.Span(eq, pandoc.Attr(label))
targetInlines:insert(span)
end

-- reset state
mathInlines = nil
processInline = false
local order = indexNextOrder("eq")
indexAddEntry(label, nil, order)

local eq = mathInlines[1]
local alt = attributes and attributes["alt"] or nil
local eqInlines = renderEquation(eq, label, alt, order)
targetInlines:extend(eqInlines)

-- Skip consumed elements and reset state
skipUntil = i + consumed - 1
mathInlines = nil
processInline = false
else
targetInlines:extend(mathInlines)
mathInlines = nil
end
else
targetInlines:extend(mathInlines)
mathInlines = nil
end
end
::continue::

-- process the inline unless it was already taken care of above
if processInline then
Expand All @@ -103,7 +93,54 @@ function process_equations(blockEl)
-- return the processed list
blockEl.content = targetInlines
return blockEl


end

-- Render equation output for all formats.
-- The alt parameter is only used for Typst output (accessibility).
function renderEquation(eq, label, alt, order)
local result = pandoc.Inlines{}

if _quarto.format.isLatexOutput() then
result:insert(pandoc.RawInline("latex", "\\begin{equation}"))
result:insert(pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)))

-- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
-- which would result in two identical labels being emitted.
-- https://github.com/jgm/pandoc/issues/9045
-- https://github.com/lierdakil/pandoc-crossref/issues/402
result:insert(pandoc.RawInline("latex", "\\end{equation}"))

elseif _quarto.format.isTypstOutput() then
local is_block = eq.mathtype == "DisplayMath" and "true" or "false"
-- Escape quotes in alt text for Typst string literal
-- First normalize curly quotes to straight quotes (Pandoc may apply smart quotes)
local alt_param = ""
if alt then
local escaped_alt = alt:gsub("“", '"'):gsub("”", '"')
escaped_alt = escaped_alt:gsub("‘", "'"):gsub("’", "'")
escaped_alt = escaped_alt:gsub('"', '\\"')
alt_param = ", alt: \"" .. escaped_alt .. "\""
end
result:insert(pandoc.RawInline("typst",
"#math.equation(block: " .. is_block .. ", numbering: \"(1)\"" .. alt_param .. ", [ "))
result:insert(eq)
result:insert(pandoc.RawInline("typst", " ])<" .. label .. ">"))

else
local eqNumber = eqQquad
local mathMethod = param("html-math-method", nil)
if type(mathMethod) == "table" and mathMethod["method"] then
mathMethod = mathMethod["method"]
end
if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then
eqNumber = eqTag
end
eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order)))
result:insert(pandoc.Span(eq, pandoc.Attr(label)))
end

return result
end

function eqTag(eq)
Expand All @@ -117,3 +154,94 @@ end
function isDisplayMath(el)
return el.t == "Math" and el.mathtype == "DisplayMath"
end


-- Collect a complete attribute block from inline elements.
--
-- Pandoc tokenises `{#eq-label alt="description"}` into multiple elements:
-- Str "{#eq-label", Space, Str "alt=", Quoted [...], Str "}"
--
-- This function reassembles these elements into a single string for parseRefAttr().
-- Quoted elements are reconstructed with escaped inner quotes to preserve the
-- original attribute syntax.
--
-- Returns: collected text (string), number of elements consumed (number)
function collectAttrBlock(inlines, startIndex)
local first = inlines[startIndex]
if not first or first.t ~= "Str" then
return nil, 0
end

local collected = first.text
local consumed = 1

if collected:match("}$") then
return collected, consumed
end

for j = startIndex + 1, #inlines do
local el = inlines[j]
if el.t == "Str" then
collected = collected .. el.text
consumed = consumed + 1
elseif el.t == "Space" then
collected = collected .. " "
consumed = consumed + 1
elseif el.t == "Quoted" then
local quote = el.quotetype == "DoubleQuote" and '"' or "'"
local content = pandoc.utils.stringify(el.content)
if el.quotetype == "DoubleQuote" then
content = content:gsub('"', '\\"')
else
content = content:gsub("'", "\\'")
end
collected = collected .. quote .. content .. quote
consumed = consumed + 1
else
break
end
if collected:match("}$") then
break
end
end

if collected:match("^{#eq%-[^}]+}$") then
return collected, consumed
end

return nil, 0
end


-- Parse a Pandoc attribute block string into identifier and attributes.
--
-- Uses pandoc.read() with a dummy header to leverage Pandoc's native attribute
-- parser, avoiding fragile regex-based parsing.
--
-- Single-quoted attributes (e.g., alt='text') must be converted to double quotes
-- because Pandoc's attribute syntax only supports double-quoted values.
-- The conversion uses a three-step process:
-- 1. Protect escaped single quotes (\') with a placeholder.
-- 2. Convert key='value' to key="value", escaping any internal double quotes.
-- 3. Restore any remaining placeholders to literal single quotes.
--
-- Returns: identifier (string), attributes (table)
function parseRefAttr(text)
if not text then return nil, nil end

local placeholder = "\x00ESC_SQUOTE\x00"
text = text:gsub("\\'", placeholder)
text = text:gsub("(%w+)='([^']*)'", function(key, value)
value = value:gsub(placeholder, "'")
value = value:gsub('"', '\\"')
return key .. '="' .. value .. '"'
end)
text = text:gsub(placeholder, "'")

local parsed = pandoc.read("## " .. text, "markdown")
if parsed and parsed.blocks[1] and parsed.blocks[1].attr then
local attr = parsed.blocks[1].attr
return attr.identifier, attr.attributes
end
return nil, nil
end
1 change: 0 additions & 1 deletion src/resources/filters/crossref/refs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,3 @@ function valid_ref_types()
table.insert(types, "sec")
return types
end

2 changes: 2 additions & 0 deletions tests/docs/smoke-all/2026/01/06/13847/.gitignore
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for this test folder. We'll remove from this PR before merging to get clean PR as it does not belong to this one.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
17 changes: 17 additions & 0 deletions tests/docs/smoke-all/2026/01/06/13847/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
project:
type: website

website:
title: "Quarto CLI {{< var version >}}"
open-graph: true
navbar:
left:
- href: index.qmd
text: Home
right:
- icon: github
href: https://github.com/

format:
html:
theme: cosmo
1 change: 1 addition & 0 deletions tests/docs/smoke-all/2026/01/06/13847/_variables.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
version: 1.0.0
13 changes: 13 additions & 0 deletions tests/docs/smoke-all/2026/01/06/13847/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
title: "PR 13847: open graph metadata"
_quarto:
render-project: true
tests:
html:
ensureHtmlElements:
-
- 'meta[property="og:site_name"][content*="Quarto CLI 1.0.0"]'
- []
---

This test check that the website title is correctly resolved when used in open graph site name, including when using a shortcode.
Loading