From 0d1c3a449b809aa3cbd44b2210f3506ab3bf8e67 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 22 Sep 2026 17:01:14 +0200 Subject: [PATCH 1/2] Escape the book title-block template path in its pandoc attribute The book renderer hands the title-block template to book.lua by embedding the template's absolute path in a pandoc attribute on a generated code cell (template='...'). Pandoc's markdown reader resolves backslash escapes inside quoted attribute values, so a Windows path whose next segment starts with an ASCII punctuation character silently loses that separator when the generated markdown is read back: D:\a\_temp\...\share becomes D:\a_temp\...\share, the Lua side's io.open finds nothing, and the whole render aborts with "Error compiling template". Nothing exotic is needed to hit this - any install or checkout under a parent directory named _build, .local, -dev and so on reproduces it. The nightly Windows smoke leg started failing on the book fixtures because the runner unpacks quarto under D:\a\_temp. Escaping the value where it is produced, rather than converting the path to forward slashes, also covers a path containing a quote character. The regression test drives the generated markdown back through pandoc and compares the parsed attribute against the path that went in, so it exercises the actual round trip on every platform rather than asserting on the escaping itself. --- src/core/pandoc/pandoc-attr.ts | 9 ++++ src/project/types/book/book-render.ts | 50 ++++++++---------- tests/unit/book-title-block-attr.test.ts | 67 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 27 deletions(-) create mode 100644 tests/unit/book-title-block-attr.test.ts diff --git a/src/core/pandoc/pandoc-attr.ts b/src/core/pandoc/pandoc-attr.ts index 84a29516eb3..5e589f088d4 100644 --- a/src/core/pandoc/pandoc-attr.ts +++ b/src/core/pandoc/pandoc-attr.ts @@ -7,6 +7,15 @@ import { PandocAttr } from "./types.ts"; +// Pandoc's markdown reader resolves backslash escapes inside a quoted +// attribute value, so a value written into an attribute list has to escape +// backslashes and the quote character to survive being read back. Windows +// paths are the case that bites: a directory whose name starts with +// punctuation (D:\a\_temp) otherwise loses its separator. +export function pandocQuotedAttrValue(value: string): string { + return `'${value.replaceAll(/['\\]/g, "\\$&")}'`; +} + export function pandocAttrParseText(attr: string): PandocAttr | null { attr = attr.trim(); diff --git a/src/project/types/book/book-render.ts b/src/project/types/book/book-render.ts index f38e27b74ce..eb5a2a60bde 100644 --- a/src/project/types/book/book-render.ts +++ b/src/project/types/book/book-render.ts @@ -14,6 +14,7 @@ import { parsePandocTitle, partitionMarkdown, } from "../../../core/pandoc/pandoc-partition.ts"; +import { pandocQuotedAttrValue } from "../../../core/pandoc/pandoc-attr.ts"; import { kAbstract, @@ -469,35 +470,10 @@ async function mergeExecutedFiles( return createMarkdownTitle(titleText, titleAttr); }; - // If there is front matter for this chapter, this will generate a code - // cell that will be rendered a LUA filter (the code cell will provide the - // path to the template that should be used as well as the front matter - // to use when rendering) - const resolveTitleBlockMarkdown = (yaml?: Metadata) => { - if (yaml) { - const titleBlockPath = resourcePath( - "projects/book/pandoc/title-block.md", - ); - - const titleAttr = `template='${titleBlockPath}'`; - const frontMatter = `---\n${ - stringify(yaml, { indent: 2 }) - }\n---\n`; - - const titleBlockMd = "```````{.quarto-title-block " + - titleAttr + "}\n" + - frontMatter + - "\n```````\n\n"; - - return titleBlockMd; - } else { - return ""; - } - }; - // Compose the markdown for this chapter const titleMarkdown = resolveTitleMarkdown(partitioned); - const titleBlockMarkdown = resolveTitleBlockMarkdown( + const titleBlockMarkdown = bookTitleBlockMarkdown( + resourcePath("projects/book/pandoc/title-block.md"), partitioned.yaml, ); const bodyMarkdown = partitioned.yaml?.title @@ -692,6 +668,26 @@ function cleanupExecutedFile( ); } +// If there is front matter for a chapter, this generates a code cell that will +// be rendered by a LUA filter (the code cell provides the path to the template +// that should be used as well as the front matter to use when rendering) +export function bookTitleBlockMarkdown( + templatePath: string, + yaml?: Metadata, +) { + if (yaml) { + const titleAttr = `template=${pandocQuotedAttrValue(templatePath)}`; + const frontMatter = `---\n${stringify(yaml, { indent: 2 })}\n---\n`; + + return "```````{.quarto-title-block " + + titleAttr + "}\n" + + frontMatter + + "\n```````\n\n"; + } else { + return ""; + } +} + function bookItemMetadata( project: ProjectContext, item: BookRenderItem, diff --git a/tests/unit/book-title-block-attr.test.ts b/tests/unit/book-title-block-attr.test.ts new file mode 100644 index 00000000000..146961e7a95 --- /dev/null +++ b/tests/unit/book-title-block-attr.test.ts @@ -0,0 +1,67 @@ +/* + * book-title-block-attr.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assertEquals } from "testing/asserts"; +import { execProcess } from "../../src/core/process.ts"; +import { pandocBinaryPath } from "../../src/core/resources.ts"; +import { bookTitleBlockMarkdown } from "../../src/project/types/book/book-render.ts"; + +// Pandoc's markdown reader consumes a backslash that precedes an ASCII +// punctuation character inside a quoted attribute value, so a Windows path +// with a directory whose name starts with punctuation loses that separator +// unless the value is escaped when the attribute is generated. +const kTemplatePath = + "D:\\a\\_temp\\quarto-under-test\\share\\projects\\book\\pandoc\\title-block.md"; + +async function templateAttrAfterPandocRoundTrip( + markdown: string, +): Promise { + const result = await execProcess( + { + cmd: pandocBinaryPath(), + args: ["--from", "markdown", "--to", "json"], + stdout: "piped", + stderr: "piped", + }, + markdown, + ); + assertEquals(result.code, 0, `pandoc failed: ${result.stderr}`); + + // deno-lint-ignore no-explicit-any + const doc = JSON.parse(result.stdout!) as any; + // deno-lint-ignore no-explicit-any + const titleBlock = doc.blocks.find((block: any) => + block.t === "CodeBlock" && block.c[0][1].includes("quarto-title-block") + ); + assertEquals( + titleBlock !== undefined, + true, + "no quarto-title-block code cell in the generated markdown", + ); + const template = titleBlock.c[0][2].find( + (keyvalue: [string, string]) => keyvalue[0] === "template", + ); + assertEquals( + template !== undefined, + true, + "quarto-title-block code cell has no template attribute", + ); + return template[1]; +} + +unitTest( + "book title block - template path survives pandoc attribute parsing", + async () => { + const markdown = bookTitleBlockMarkdown(kTemplatePath, { + title: "A Chapter", + }); + assertEquals( + await templateAttrAfterPandocRoundTrip(markdown), + kTemplatePath, + ); + }, +); From 3ac49ff0aad145fc1307cc805db65da030febdf4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 22 Sep 2026 17:05:52 +0200 Subject: [PATCH 2/2] Add changelog entry --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 6e99e9d014b..d1908391ef1 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -43,6 +43,7 @@ All changes included in 1.11: - ([#10114](https://github.com/quarto-dev/quarto-cli/issues/10114)): Support `announcement` under the `book` key, which previously had no effect. - ([#14276](https://github.com/quarto-dev/quarto-cli/issues/14276)): Support `llms-txt` under the `book` key, which previously had no effect. - ([#14879](https://github.com/quarto-dev/quarto-cli/issues/14879)): Support `plausible-analytics`, `back-to-top-navigation`, and `image-alt` under the `book` key, which previously had no effect. +- ([#14929](https://github.com/quarto-dev/quarto-cli/pull/14929)): Fix `Error compiling template` when rendering a book to `pdf`, `docx` or `epub` on Windows with Quarto located under a directory whose name starts with `_`, `.`, `-` or another punctuation character. ## Commands