Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions src/core/pandoc/pandoc-attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
50 changes: 23 additions & 27 deletions src/project/types/book/book-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
parsePandocTitle,
partitionMarkdown,
} from "../../../core/pandoc/pandoc-partition.ts";
import { pandocQuotedAttrValue } from "../../../core/pandoc/pandoc-attr.ts";

import {
kAbstract,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/book-title-block-attr.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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,
);
},
);
Loading