Repository navigation
Add crossref.numbering: external; sanction the Lua functions external front ends already call as globals - #14913
Closed
gordonwoodhull wants to merge 6 commits into
Closed
gordonwoodhull wants to merge 6 commits into
gordonwoodhull wants to merge 6 commits into
Conversation
Splits the previously conflated enable-crossref flag into two questions: whether captions/refs should still be decorated with numbers (crossref_present, for an external numberer) vs. whether quarto itself should compute and assign those numbers (assign_crossref_numbers). The single existing assignment-group gate in main.lua (the `if enableCrossRef then` around the crossref filter group) now uses assign_crossref_numbers(), the opposite-polarity predicate from crossref_present(). Render-decoration gate sites are left for a follow-up task. Predicates live in a new require-able module (modules/crossref_numbering.lua) so they can be unit tested with a mocked param(), per tests/unit-lua/crossref-numbering.test.lua.
…7 order-nil guard
Converts the four render-decoration gate sites (A2/A3 in
decorate_caption_with_crossref/full_caption_prefix, A4/A5 in the ipynb
FloatRefTarget renderer pair, A6 in decorate_callout_title_with_crossref)
from reading param("enable-crossref", true) directly to calling
_quarto.modules.crossref_numbering.crossref_present(), added in the prior
task. layout/ipynb.lua's PanelLayout pair is left reading the param
directly (documented in place): both branches dispatch to the same
render_ipynb_layout callback, so it's a verified no-op either way.
Adds A7: callout_title_prefix was missing the order-nil guard its
float-side analog (float_title_prefix) already has. The site is
unreachable upstream today, but crossref-numbering: external (landing in
a later task) can reach render-decoration with no order assigned, so the
guard makes that state degrade (warn, no prefix) instead of crash.
Also fixes a latent bug in captureRenderCommand's debug capability
(QUARTO_CAPTURE_RENDER_COMMAND): it assumed every format's defaults YAML
references a template, which docx's does not, crashing before the
feature could be used at all. Needed to write T3.5, which exercises the
A7 guard by capturing a real render's pandoc invocation and replaying it
with crossref-numbering patched to "external".
Task 4 of the pandoc-hybrid-P3 implementation: enable-crossref: false is only reachable via the manuscript ipynb-source-notebook path for supporting notebooks (src/command/render/filters.ts:534). The existing render-manuscript.test.ts only asserted output file existence, so a regression in the A4/A5 FloatRefTarget renderer pair (floatreftarget.lua) that routes the source-notebook path through the generic placeholder renderer instead would go unnoticed (confirmed by Task 3's review and independently reproduced here). Add a log-message check for the placeholder renderer's warning and a content assertion on notebooks/seismic-monitoring-stations.out.ipynb's #fig-stations image, which pins that the image keeps its identifier and caption undecorated (no crossref number) rather than falling back to the placeholder. A two-commit byte-identity comparison (dev-only, not committed) confirmed the docx default path (A1/A2) and this manuscript ipynb-false path are unchanged across 83d48d8..8061f24; see the Task 4 report.
…k 8) Adds the TypeScript half that Tasks 2-4's Lua crossref_present()/ assign_crossref_numbers() predicates were reading via param() but nothing ever wrote: a crossref.numbering YAML key (enum quarto/external, default quarto), its document-crossref.yml schema entry (required since the crossref object is closed: true), and the metadata read in crossrefFilterParams that emits crossref-numbering into the filter params blob only when the key is set. Adds T8.1 (schema validation, pass/fail on the enum), T8.2 (a new numbering-external.qmd fixture rendered to docx, asserting the caption loses its Quarto-assigned prefix and stderr carries the Lua "order is missing" warning), and T8.3 (existing docx.test.ts as the absent-key regression guard).
…o.utils
P5's Route-N Lua shim reconstructs Q1 custom nodes outside the normal
filter pipeline and calls these eight functions by name today, as
undefended bare globals. Sanction them following the existing precedent
at customnodes/floatreftarget.lua:239 (decorate_caption_with_crossref).
- crossref/format.lua: subrefNumber, refPrefix, refDelim, refHyperlink,
refNumberOption
- crossref/options.lua: crossrefOption
- crossref/equations.lua: renderEquation
- common/pandoc.lua: nbspString (on quarto.utils, not quarto.doc.crossref,
since it's a generic helper, not crossref-specific)
titlePrefix is not exposed: per the plan brief it is a ninth function
outside the frozen list of eight, routed through by refPrefix/
float_title_prefix, and exposing it is left to a future PR author's call.
Adds tests/unit-lua/crossref-exports.test.lua (T5.1): eight individually
named assertNotNil assertions, one per export, rather than a loop over a
list defined in the test, so a rename that updates both the export and a
locally-defined list can't mask a break in the contract P5's shim relies
on. format.lua/options.lua/equations.lua require() cleanly in isolation;
common/pandoc.lua cannot be require()d under its module name "pandoc"
because pandoc's Lua environment preloads a module of that same name, so
this test loads it via dofile() on its absolute path instead, extending
LUA_PATH (in lua-unit.test.ts) to include src/resources/pandoc/datadir so
its own require("readqmd") resolves.
Final-review fix round for the pandoc-hybrid-P3 crossref-numbering patch (no second wave per policy). Task 2 gated the whole quarto_crossref_filters group on assign_crossref_numbers(), which also skips crossrefMetaInject()'s LaTeX preamble (codelisting/theorem environments) and resolveRefs() (which stops Typst's native @id citation syntax from colliding with quarto's @id crossref syntax). Task 8 then made crossref-numbering: external reachable from user YAML for any format, so a document with a labeled float or theorem now crashes deep inside the LaTeX/Typst compiler instead of failing cleanly. - main.lua: fail fast at the same gate, before Pandoc's writer ever sees the malformed output, when external mode is combined with a LaTeX-family or typst target. Reproduced both crashes first (LaTeX Error: Environment codelisting undefined; Typst "document does not contain a bibliography"), then verified the guard replaces both with a clear diagnostic and no output file. - crossref/format.lua: refNumberOption's appendix branch derefs crossref.startAppendix, which is nil under external mode (only ever set inside the skipped group). Guard it so a caller invoking the Task-5 export directly degrades to non-appendix numbering instead of crashing. Added a unit test (crossref-exports.test.lua) that reproduces the crash without the guard and passes with it. - document-crossref.yml (+ regenerated editor-tooling artifacts): document the docx/odt/pptx-only support for `numbering: external`. - news/changelog-1.11.md: add the missing YAML-key and Lua API entries. - lua-types/quarto/{doc,utils}.lua: declare the eight Task-5 exports. - Rewrite the eight Route-N export comments for an upstream audience instead of repeating private vocabulary (Q2/Route-N/Q1) verbatim. - callout-order-guard.test.ts: replace hand-rolled existsSyncOrFail with deno_ral's existsSync. - numbering-schema.test.ts: give the schema test its own fixture (numbering-valid.qmd) instead of sharing numbering-external.qmd's output path with numbering-external.test.ts. - numbering-external-unsupported-format.test.ts: new smoke test asserting the clean diagnostic (not the compiler crash) for --to latex and --to typst.
Collaborator
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
gordonwoodhull
marked this pull request as draft
September 19, 2026 23:07
Member
Author
|
This is going to need a more comprehensive approach, since q2 needs a few things to work differently, and it only needs to run part of the Lua filter pipeline. I hope to bring ideas to discuss soon. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
crossref.numbering: external, a document-level opt-out that skips Quarto's owncross-reference numbering, index, and
@ref-resolution passes, for front ends thatcompute their own numbers and just want Quarto's Lua filters to render whatever number
is already on the node. Currently supported for
docx,odt, andpptx;latex,pdf,beamer, andtypstraise a diagnostic rather than the (previously silent)malformed output those formats produced when this option reached them.
Also sanctions eight Lua functions —
subrefNumber,refPrefix,refDelim,refHyperlink,refNumberOption,crossrefOption,renderEquation, andnbspString— that external front ends already call as undefended bare globals whenreconstructing Quarto's crossref/equation custom nodes outside the normal filter
pipeline. There is existing precedent for this
(
quarto.doc.crossref.decorate_caption_with_crossref,customnodes/floatreftarget.lua:239); this PR extends the same pattern to the eightfunctions actually in use.
Across six commits:
crossref_present()/assign_crossref_numbers()predicates — two newpredicates in a new module,
src/resources/filters/modules/crossref_numbering.lua,answering "should captions/refs still be decorated?" and "should Quarto compute its
own numbers?" as a function of
enable-crossrefand the newcrossref-numberingparam. The existing gates across the crossref filter stage
(
customnodes/floatreftarget.lua,layout/ipynb.lua,modules/callouts.lua) nowcall these instead of checking
enable-crossrefdirectly.enable-crossref: falsescoping + byte-identity verification. See the scopingstatement below.
crossref.numbering: externalreachable from user YAML: schema entry(
src/resources/schema/document-crossref.yml, enum[quarto, external], defaultquarto), thekCrossrefNumberingconstant (src/config/constants.ts), and theTypeScript read that puts it on the filter-params blob (
crossrefFilterParamsinsrc/command/render/crossref.ts). Without this, the option is only reachable by aconsumer that builds the params blob itself, not by a
quarto-cliuser settingYAML front matter.
Fail fast on
latex/pdf/beamer/typst. The predicate in (1) gates theentire
quarto_crossref_filtersgroup, which also runscrossrefMetaInject's LaTeX preamble setup (thecodelisting/theorem environmentdefinitions LaTeX output needs) and
resolveRefs's Typst@id-collision guard(Typst has its own native
@idcitation syntax that collides with Quarto's crossref@idsyntax unless resolved first). Neither of those is reachable underenable-crossref: falsetoday, so skipping them was never previously observable —but
crossref.numbering: externalmakes the same gate reachable from YAML for anyformat, and a document with a labeled float or theorem then failed deep inside the
LaTeX or Typst compiler instead of failing cleanly (reproduced:
LaTeX Error: Environment codelisting undefined; Typst"document does not contain a bibliography"). Fixed by failing fast at the same gate, before Pandoc's writer eversees the malformed intermediate output, naming the unsupported format in the
diagnostic. New test:
numbering-external-unsupported-format.test.ts.Route-N sanctioned exports: the eight functions named above, exposed as
quarto.doc.crossref.*(all but one) andquarto.utils.nbspString(nbspStringisa generic string helper, not crossref-specific):
src/resources/filters/crossref/format.lua:subrefNumber,refPrefix,refDelim,refHyperlink,refNumberOptionsrc/resources/filters/crossref/options.lua:crossrefOptionsrc/resources/filters/crossref/equations.lua:renderEquationsrc/resources/filters/common/pandoc.lua:nbspStringtitlePrefix(whichrefPrefix/float_title_prefixroute through) is notexposed here — it sits outside this list and exposing it is left to whoever needs
it next. While exposing these, found and fixed a real bug:
refNumberOption'sappendix branch dereferences
crossref.startAppendix, which isnilunder externalmode (it's only ever set inside the group (1) skips) — a direct caller of this
export would crash. Guarded it to degrade to non-appendix numbering instead;
crossref-exports.test.luareproduces the crash without the guard and passes withit. Also added
lua-typesdeclarations for all eight exports(
lua-types/quarto/{doc,utils}.lua) and a changelog entry (news/changelog-1.11.md)— both missing from the original patch, added here.
Files changed (upstream quarto-cli, all commits)
src/resources/filters/modules/crossref_numbering.lua(new)src/resources/filters/modules/import_all.luasrc/resources/filters/main.luasrc/resources/filters/customnodes/floatreftarget.luasrc/resources/filters/layout/ipynb.luasrc/resources/filters/modules/callouts.luasrc/resources/filters/crossref/format.luasrc/resources/filters/crossref/options.luasrc/resources/filters/crossref/equations.luasrc/resources/filters/common/pandoc.luasrc/resources/lua-types/quarto/doc.luasrc/resources/lua-types/quarto/utils.luasrc/resources/schema/document-crossref.ymlsrc/config/constants.tssrc/command/render/crossref.tssrc/command/render/pandoc.ts(unrelated one-line robustness fix picked up inpassing: guard
captureRenderCommand's defaults-YAML rewrite for formats, likedocx, whose defaults don't reference a template)
src/resources/editor/tools/*(regenerated schema/YAML-intelligence artifacts)news/changelog-1.11.mdtests/docs/crossrefs/*.qmd(new fixtures, includingnumbering-valid.qmd),tests/smoke/crossref/*.test.ts(new + extended, includingnumbering-external-unsupported-format.test.ts),tests/smoke/manuscript/render-manuscript.test.ts(extended),tests/smoke/lua-unit/lua-unit.test.ts(registers two new Lua unit test files),tests/unit-lua/crossref-numbering.test.lua(new),tests/unit-lua/crossref-exports.test.lua(new)enable-crossref: falsescoping statementenable-crossref: falseis not user-settable: it is set internally, and only, atsrc/command/render/filters.ts:534, whenformat.render["produce-source-notebook"]is true. That flag is in turn only set for manuscript supporting notebooks (
.qmdfiles that are not the manuscript's main article — see
src/project/types/manuscript/manuscript.ts:474-476), when they are rendered to their.out.ipynbsource-notebook form. No other code path inquarto-clisets or readsthis flag as false. So "bit-for-bit on
enable-crossref: false" means, concretely andonly: the manuscript ipynb-source-notebook outputs (
notebooks/*.out.ipynb/article.out.ipynb, produced for supporting notebooks in a manuscript project) arebyte-identical, modulo the notebook's own per-cell random UUIDs, before and after this
patch. It is not a claim about any other render path, format, or project type — none
of which can reach
enable-crossref: falsetoday.Verified by a two-commit byte-identity procedure (comparand commits
83d48d8e8pre-patch,
8061f246apost-patch):word/document.xmlandword/numbering.xmlfrom adocx crossref render are byte-identical; the manuscript supporting notebook's
.out.ipynbis identical after normalizing out Jupyter's per-cell random UUID (shownto vary run-to-run on the same commit, independent of this patch). No new warnings
appeared in either render's log. This is a dev-only verification procedure, not
committed as a CI script.
A note on this patch's origin
This PR grew out of work on Quarto 2 (a from-scratch Rust reimplementation currently
in development) — specifically its "Route-N" path, which reconstructs a subset of
Quarto's Pandoc custom AST nodes and re-dispatches them into these filters rather than
reimplementing crossref/equation rendering independently. That context explains why
these particular eight functions and not others, and why
crossref.numbering: externalexists at all: Route-N needs a way to tell Quarto's filters "render thisnumber, don't recompute it." Nothing in this PR is Q2-specific — the YAML key, the
schema, and the exports are ordinary
quarto-clisurface, useful to any front end withthe same need.
Testing
tests/unit-lua/crossref-numbering.test.lua(10 cases): the 2×2×unknown-valuecross-product of
crossref_present()/assign_crossref_numbers().tests/unit-lua/crossref-exports.test.lua(8 cases, plus the appendix-nil-derefregression added in the fix round): one
assertNotNilper exported name,individually named (not a loop over a locally-defined list) so a rename that updates
both the export and the list can't mask a break in the contract external consumers
depend on.
tests/smoke/crossref/numbering-schema.test.ts:crossref.numbering: externalpasses schema validation; an invalid value fails naming the allowed values.
tests/smoke/crossref/numbering-external.test.ts: full TS → filter-params → Luaparam()path — a docx render withcrossref.numbering: externalkeeps the captiontext but carries no Quarto-assigned "Figure N" prefix, and Lua's numbering pass logs
the resulting missing
orderfield.tests/smoke/crossref/numbering-external-unsupported-format.test.ts:--to latexand
--to typstundercrossref.numbering: externalproduce the new diagnostic, nota compiler crash.
tests/smoke/crossref/callout-order-guard.test.ts,tests/smoke/crossref/docx.test.ts:regression coverage for the gate conversions in (1).
tests/smoke/manuscript/render-manuscript.test.ts: extended to assert the manuscriptsupporting-notebook render does not fall through to the generic placeholder
FloatRefTarget renderer, and that the notebook's figure keeps its identifier/caption
undecorated (no crossref number) — closes a blind spot in the pre-existing test
(which only checked output file existence and a log-level filter that doesn't catch
Lua's INFO-level
warn()output).cd tests && ./run-fast-tests.sh -k <path>per file).package/dist/bin/quarto(no args) typechecks clean.Not included in this PR
titlePrefixas a ninth export._quarto.modulesmigration cleanup forcrossref/(tracked separately asquarto-dev/quarto-cli#14702; this PR's changes predate that migration reachingcrossref/, and are cheap to make now, expensive after).