Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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)
- ([#13872](https://github.com/quarto-dev/quarto-cli/pull/13872)): Add support for div-syntax on cross-referenced equations. (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
213 changes: 166 additions & 47 deletions src/resources/filters/crossref/equations.lua
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
-- equations.lua
-- Copyright (C) 2020-2022 Posit Software, PBC
-- Copyright (C) 2020-2026 Posit Software, PBC

-- process all equations
function equations()
return {
Para = process_equations,
Plain = process_equations
Plain = process_equations,
Div = process_equation_div
}
end

Expand All @@ -21,67 +22,56 @@ 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
elseif el.t == "Str" and startsWithEqLabel(el.text) 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 = attrText:match("{#(eq%-[^ }]+)")
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,46 @@ 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"
local alt_param = alt and (", alt: \"" .. alt .. "\"") or ""
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 +146,93 @@ end
function isDisplayMath(el)
return el.t == "Math" and el.mathtype == "DisplayMath"
end

-- Check if text starts with an equation label pattern {#eq-
function startsWithEqLabel(text)
return text and text:match("^{#eq%-")
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 collects and joins these elements into a single string
-- that can be parsed by parseRefAttr().
--
-- 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

-- Simple case: complete in one element (e.g., {#eq-label})
if collected:match("}$") then
return collected, consumed
end

-- Collect subsequent elements until closing brace
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
-- Pandoc parses quoted strings into Quoted elements
local quote = el.quotetype == "DoubleQuote" and '"' or "'"
collected = collected .. quote .. pandoc.utils.stringify(el.content) .. quote
consumed = consumed + 1
else
break
end
if collected:match("}$") then
break
end
end

-- Validate: must be a complete attribute block
if collected:match("^{#eq%-[^}]+}$") then
return collected, consumed
end

return nil, 0
end

-- Process equation divs with optional alt-text attribute.
-- Supports syntax: ::: {#eq-label alt="description"} $$ ... $$ :::
-- The alt attribute is only used for Typst output (accessibility).
function process_equation_div(divEl)
local label = divEl.attr.identifier
if not label or not label:match("^eq%-") then
return nil
end

-- Find display math inside the div
local eq = nil
_quarto.ast.walk(divEl, {
Math = function(el)
if el.mathtype == "DisplayMath" then
eq = el
end
end
})

if not eq then
return nil
end

local order = indexNextOrder("eq")
indexAddEntry(label, nil, order)

local alt = divEl.attr.attributes["alt"]
local eqInlines = renderEquation(eq, label, alt, order)

return pandoc.Para(eqInlines)
end
24 changes: 22 additions & 2 deletions src/resources/filters/crossref/refs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,28 @@ function extractRefLabel(type, text)
end

function refLabelPattern(type)
return "{#(" .. type .. "%-[^ }]+)}"
-- Captures the identifier (type-name) while allowing optional attributes
return "{#(" .. type .. "%-[^ }]+)[^}]*}"
end

-- Parse a Pandoc attribute block string into identifier and attributes.
-- Uses pandoc.read with a dummy header to leverage Pandoc's native parser.
--
-- Input: "{#eq-label alt=\"description\"}"
-- Output: "eq-label", {alt = "description"}
--
-- This is used to extract alt-text.
function parseRefAttr(text)
if not text then return nil, nil end

-- Wrap in a markdown header since Pandoc requires text before attributes
-- to parse them correctly without regular expressions.
local parsed = pandoc.read("## x " .. 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

function is_valid_ref_type(type)
Expand All @@ -210,4 +231,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