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 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, + ); + }, +);