From e25b422c634b3260da35ba9eee56037f2c09172c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 23 Jun 2026 17:58:17 +0200 Subject: [PATCH 1/2] test(book): add failing test for crossref appendix-title in PDF (#11772) Documents the bug before the fix: in a PDF book, an appendix cross-reference ignores `crossref.appendix-title` and falls back to the default "Appendix" prefix, whereas HTML honors it. Only the `language: crossref-apx-prefix` key currently drives the PDF prefix. The test asserts the custom prefix ("Whatever") reaches the appendix cross-reference in the merged book .tex. It is RED on current main and turns green once the cross-reference path consults the crossref option. Test block lives on index.qmd because the smoke-all harness only resolves the merged book .tex when the test file is the book index. --- .../2026/06/23/issue-11772/.gitignore | 2 ++ .../2026/06/23/issue-11772/_quarto.yml | 20 +++++++++++++++++++ .../2026/06/23/issue-11772/index.qmd | 19 ++++++++++++++++++ .../2026/06/23/issue-11772/intro.qmd | 3 +++ .../2026/06/23/issue-11772/summary.qmd | 3 +++ 5 files changed, 47 insertions(+) create mode 100644 tests/docs/smoke-all/2026/06/23/issue-11772/.gitignore create mode 100644 tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml create mode 100644 tests/docs/smoke-all/2026/06/23/issue-11772/index.qmd create mode 100644 tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd create mode 100644 tests/docs/smoke-all/2026/06/23/issue-11772/summary.qmd diff --git a/tests/docs/smoke-all/2026/06/23/issue-11772/.gitignore b/tests/docs/smoke-all/2026/06/23/issue-11772/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/23/issue-11772/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml b/tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml new file mode 100644 index 00000000000..aed040de82b --- /dev/null +++ b/tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml @@ -0,0 +1,20 @@ +project: + type: book + +book: + title: "issue-11772" + author: "Norah Jones" + chapters: + - index.qmd + - intro.qmd + appendices: + - summary.qmd + +format: + pdf: + documentclass: scrreprt + keep-tex: true + +crossref: + appendix-title: "Whatever" + appendix-delim: ":" diff --git a/tests/docs/smoke-all/2026/06/23/issue-11772/index.qmd b/tests/docs/smoke-all/2026/06/23/issue-11772/index.qmd new file mode 100644 index 00000000000..726a6cad132 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/23/issue-11772/index.qmd @@ -0,0 +1,19 @@ +--- +format: + pdf: + keep-tex: true +_quarto: + tests: + pdf: + # crossref.appendix-title ("Whatever") must drive the appendix + # cross-reference prefix in PDF, like it does in HTML output. + # Currently the prefix falls back to the default "Appendix" + # (only language.crossref-apx-prefix is honored) — issue #11772. + ensureLatexFileRegexMatches: + - ['See Whatever~\\ref\{sec-summary\}'] + - ['See Appendix~\\ref\{sec-summary\}'] +--- + +# Preface {.unnumbered} + +This is a book testing crossref appendix options in PDF (issue #11772). diff --git a/tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd b/tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd new file mode 100644 index 00000000000..553e020ba78 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd @@ -0,0 +1,3 @@ +# Introduction + +See @sec-summary. diff --git a/tests/docs/smoke-all/2026/06/23/issue-11772/summary.qmd b/tests/docs/smoke-all/2026/06/23/issue-11772/summary.qmd new file mode 100644 index 00000000000..8e2c15d0532 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/23/issue-11772/summary.qmd @@ -0,0 +1,3 @@ +# Summary {#sec-summary} + +In summary, this book has no content whatsoever. From a1e3ef4d0a2d278f7273f4242528d0d8ecdcbe8f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 23 Jun 2026 18:47:20 +0200 Subject: [PATCH 2/2] fix(crossref): apply appendix-title to appendix cross-references (#11772) crossref.appendix-title and crossref.appendix-delim styled the appendix chapter heading (via formatChapterTitle) but the appendix cross-reference prefix was built solely from language.crossref-apx-prefix, so @sec-refs to an appendix rendered the default "Appendix N" regardless of appendix-title. mcanouil's language: crossref-apx-prefix workaround only papered over this. Two independent code paths build that prefix, both now prefer crossref.appendix-title (falling back to the language string), mirroring formatChapterTitle's precedence: - Lua refPrefix (crossref/format.lua): PDF, and HTML same-file references. - TS formatCrossref (book-crossrefs.ts): HTML cross-file references resolved at the book post-render stage. The HTML cross-file path can only be exercised after a full project render, so its smoke-all test uses render-project: true; the project pre-render only builds declared formats, so the fixture declares format: html. Documented this render-project behavior in the testing rules and llm-docs. The PDF/LaTeX appendix chapter heading itself (not the cross-reference) still ignores appendix-title/appendix-delim; that needs appendixname/KOMA work and is tracked separately in #14616. --- .claude/rules/testing/smoke-all-tests.md | 17 +++++++++++++ llm-docs/testing-patterns.md | 17 +++++++++++++ news/changelog-1.10.md | 4 ++++ src/project/types/book/book-crossrefs.ts | 24 ++++++++++++++----- src/resources/filters/crossref/format.lua | 8 +++++++ .../2026/06/23/issue-11772/_quarto.yml | 1 + .../2026/06/23/issue-11772/intro.qmd | 14 +++++++++++ 7 files changed, 79 insertions(+), 6 deletions(-) diff --git a/.claude/rules/testing/smoke-all-tests.md b/.claude/rules/testing/smoke-all-tests.md index ea80419bcdf..7900aaeb19b 100644 --- a/.claude/rules/testing/smoke-all-tests.md +++ b/.claude/rules/testing/smoke-all-tests.md @@ -119,6 +119,23 @@ _quarto: Valid OS values: `linux`, `darwin`, `windows` +## Project Rendering (cross-file resolution) + +Each `_quarto.tests` block renders only its own input file (`quarto render --to `). In a multi-file project (book/website) that leaves cross-file references resolved at the project post-render stage (e.g. an appendix cross-reference pointing into another chapter) unresolved. + +Set `render-project: true` as a sibling of `tests:` (under `_quarto`) to render the whole project first: + +```yaml +_quarto: + render-project: true + tests: + html: + ensureFileRegexMatches: + - ['>Whatever A<'] +``` + +Gotcha: the project pre-render runs `quarto render ` with **no `--to`**, so it builds only the formats **declared in `_quarto.yml`**. To test a format's cross-file output, that format must be declared in the project config — otherwise the pre-render skips it and the per-file render alone cannot resolve cross-file refs. Example: `tests/docs/smoke-all/2026/06/23/issue-11772` (book must declare `format: html` for the HTML appendix cross-reference test). + ## Pattern Specificity Avoid patterns that match template boilerplate instead of document content: diff --git a/llm-docs/testing-patterns.md b/llm-docs/testing-patterns.md index 2ddcfa0c364..86968475b5f 100644 --- a/llm-docs/testing-patterns.md +++ b/llm-docs/testing-patterns.md @@ -394,6 +394,23 @@ Run test **without fix** first to verify it fails, then verify it passes with fi Smoke-all tests embed test specifications directly in `.qmd` files using `_quarto.tests` metadata. See `.claude/rules/testing/smoke-all-tests.md` for full documentation. +### Project pre-render for cross-file resolution + +A `_quarto.tests` block renders only its own input file (`quarto render --to `). In a multi-file project (book/website), cross-file references that are resolved at the project post-render stage — e.g. an appendix cross-reference in one chapter pointing at another chapter — stay unresolved under a single-file render. + +Set `render-project: true` as a sibling of `tests:` to render the whole project first: + +```yaml +_quarto: + render-project: true + tests: + html: + ensureFileRegexMatches: + - ['>Whatever A<'] +``` + +The harness runs `quarto render ` (in `smoke-all.test.ts`) before the per-file render. **Gotcha:** that pre-render has no `--to`, so it builds only the formats declared in `_quarto.yml`. To test a format's cross-file output, declare that format in the project config — otherwise the pre-render skips it and the per-file render alone cannot resolve cross-file references. Reference fixture: `tests/docs/smoke-all/2026/06/23/issue-11772` (the book declares `format: html` so the HTML appendix cross-reference resolves; the per-file HTML render then reuses the project crossref index). + ### YAML String Escaping for Regex **Critical rule:** In YAML single-quoted strings, `'\('` and `"\\("` are equivalent - both produce a literal `\(` in the regex. diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 7cf310f1b2b..50156fb6f3c 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -50,6 +50,10 @@ All changes included in 1.10: - ([#14562](https://github.com/quarto-dev/quarto-cli/issues/14562)): Fix a heading inside `content-hidden when-format="llms-txt"` (visible in HTML) losing its `
` wrapper and `id` in a website with `llms-txt` enabled, which broke its table-of-contents link, anchors, and cross-references. - ([#14563](https://github.com/quarto-dev/quarto-cli/issues/14563)): Fix a fatal error when a shortcode is used inside conditional content (e.g. `content-visible when-format="llms-txt"`) in a website with `llms-txt` enabled. +### Books + +- ([#11772](https://github.com/quarto-dev/quarto-cli/issues/11772)): Fix `crossref.appendix-title` not being applied to appendix cross-references in PDF output. Appendix cross-references now use the configured title (e.g. `See Whatever A`) instead of always showing the default `Appendix` prefix. + ## Commands ### `quarto preview` diff --git a/src/project/types/book/book-crossrefs.ts b/src/project/types/book/book-crossrefs.ts index 6b2d3fc6284..cc120e0f555 100644 --- a/src/project/types/book/book-crossrefs.ts +++ b/src/project/types/book/book-crossrefs.ts @@ -18,6 +18,8 @@ import { import { pathWithForwardSlashes } from "../../../core/path.ts"; import { + kCrossref, + kCrossrefAppendixTitle, kCrossrefApxPrefix, kCrossrefChapterId, kCrossrefChapters, @@ -37,7 +39,7 @@ import { WebsiteProjectOutputFile } from "../website/website.ts"; import { inputTargetIndex } from "../../project-index.ts"; import { bookConfigRenderItems } from "./book-config.ts"; import { isMultiFileBookFormat } from "./book-shared.ts"; -import { Format } from "../../../config/types.ts"; +import { Format, Metadata } from "../../../config/types.ts"; export async function bookCrossrefsPostRender( context: ProjectContext, @@ -305,11 +307,21 @@ function formatCrossref( // if this is a section we need a prefix const refNumber = numberOption(entry.order, options, type); if (type === "sec" && !noPrefix) { - const prefix = (options[kCrossrefChapters] && isChapterRef(entry)) - ? options[kCrossrefChaptersAppendix] - ? language[kCrossrefApxPrefix] - : language[kCrossrefChPrefix] - : language[kCrossrefSecPrefix]; + let prefix; + if (options[kCrossrefChapters] && isChapterRef(entry)) { + if (options[kCrossrefChaptersAppendix]) { + // appendix cross-references prefer crossref.appendix-title (the same + // option that titles the appendix chapter heading) so the reference + // text matches the heading + const crossref = format.metadata?.[kCrossref] as Metadata | undefined; + prefix = (crossref?.[kCrossrefAppendixTitle] as string) ?? + language[kCrossrefApxPrefix]; + } else { + prefix = language[kCrossrefChPrefix]; + } + } else { + prefix = language[kCrossrefSecPrefix]; + } const crossref = prefix + " " + refNumber; return crossref; } else { diff --git a/src/resources/filters/crossref/format.lua b/src/resources/filters/crossref/format.lua index 6c6908b285b..7f5d7dfc0cf 100644 --- a/src/resources/filters/crossref/format.lua +++ b/src/resources/filters/crossref/format.lua @@ -77,6 +77,14 @@ function refPrefix(type, upper) end default = stringToInlines(default) local prefix = crossrefOption(opt, default) + -- appendix cross-references prefer crossref.appendix-title (the same option that + -- titles the appendix chapter heading) so the reference text matches the heading + if type == "apx" then + local appendixTitle = crossrefOption("appendix-title") + if appendixTitle ~= nil then + prefix = appendixTitle + end + end if upper then local el = pandoc.Plain(prefix) local firstStr = true diff --git a/tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml b/tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml index aed040de82b..a5929d6f7d7 100644 --- a/tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml +++ b/tests/docs/smoke-all/2026/06/23/issue-11772/_quarto.yml @@ -11,6 +11,7 @@ book: - summary.qmd format: + html: default pdf: documentclass: scrreprt keep-tex: true diff --git a/tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd b/tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd index 553e020ba78..f55602f21e4 100644 --- a/tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd +++ b/tests/docs/smoke-all/2026/06/23/issue-11772/intro.qmd @@ -1,3 +1,17 @@ +--- +_quarto: + render-project: true + tests: + html: + # crossref.appendix-title ("Whatever") must drive the appendix + # cross-reference prefix in HTML too. This cross-file reference from a + # numbered chapter is resolved by the book post-render step, which + # previously only honored language.crossref-apx-prefix — issue #11772. + ensureFileRegexMatches: + - ['>Whatever A<'] + - ['>Appendix A<'] +--- + # Introduction See @sec-summary.