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.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,4 @@ All changes included in 1.10:
- ([#14582](https://github.com/quarto-dev/quarto-cli/issues/14582)): Fix format detection for extension formats (e.g. `acm-pdf`) in project preview, manuscript notebooks, MECA bundles, and website format ordering.
- ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves.
- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment
- ([#14669](https://github.com/quarto-dev/quarto-cli/issues/14669)): Fix markdown output being deleted when `output-file` has an `.html` extension and an html format is paired with a markdown format.
36 changes: 34 additions & 2 deletions src/command/render/render-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {
RenderFlags,
RenderOptions,
} from "./types.ts";
import { error, info } from "../../deno_ral/log.ts";
import { error, info, warning } from "../../deno_ral/log.ts";
import * as ld from "../../core/lodash.ts";
import { basename, dirname, join, relative } from "../../deno_ral/path.ts";
import { Format } from "../../config/types.ts";
Expand All @@ -71,6 +71,8 @@ import {
inputFilesDir,
isServerShiny,
isServerShinyKnitr,
keepMdCollidesWithFormatOutput,
projectedOutputFile,
} from "../../core/render.ts";
import {
normalizePath,
Expand Down Expand Up @@ -460,6 +462,20 @@ async function renderFileInternal(
files,
options,
);

// let each context know the projected outputs of every declared format,
// including formats not in this render (e.g. quarto render --to html):
// keep-md intermediate handling must not write to or delete a path that
// a format owns (e.g. output-file: index.html plus a markdown format
// yields index.html.md, which is also the conventional keep-md location
// for the html format) (#14669)
const formatOutputs = Object.values(contexts)
.map((context) =>
projectedOutputFile(context.target.input, context.format)
);
for (const context of Object.values(contexts)) {
context.siblingFormatOutputs = formatOutputs;
}
} catch (e) {
// bad YAML can cause failure before validation. We
// reconstruct the context as best we can and try to validate.
Expand Down Expand Up @@ -670,7 +686,23 @@ async function renderFileInternal(
// keep md if requested
const keepMd = executionEngineKeepMd(context);
if (keepMd && context.format.execute[kKeepMd]) {
Deno.writeTextFileSync(keepMd, executeResult.markdown.value);
if (
keepMdCollidesWithFormatOutput(
keepMd,
context.siblingFormatOutputs,
)
) {
warning(
`${
basename(context.target.input)
}: not saving the keep-md intermediate because its ` +
`conventional location (${
basename(keepMd)
}) is the output file of another format`,
);
} else {
Deno.writeTextFileSync(keepMd, executeResult.markdown.value);
}
}

// now get "unmapped" execute result back to send to pandoc
Expand Down
16 changes: 14 additions & 2 deletions src/command/render/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { Document, parseHtml } from "../../core/deno-dom.ts";

import { mergeConfigs } from "../../core/config.ts";
import { resourcePath } from "../../core/resources.ts";
import { inputFilesDir } from "../../core/render.ts";
import {
inputFilesDir,
keepMdCollidesWithFormatOutput,
} from "../../core/render.ts";
import {
normalizePath,
pathWithForwardSlashes,
Expand Down Expand Up @@ -374,14 +377,23 @@ export async function renderPandoc(
}

if (cleanup !== false) {
// the conventional keep-md location can coincide with the declared
// output of another format (e.g. output-file: index.html plus a
// markdown format yields index.html.md) -- never clean up a path
// that a format owns (#14669)
const keepMd = executionEngineKeepMd(context);
const keepMdIsFormatOutput = keepMdCollidesWithFormatOutput(
keepMd,
context.siblingFormatOutputs,
);
withTiming("render-cleanup", () =>
renderCleanup(
context.target.input,
finalOutput!,
format,
file.context.project,
cleanupSelfContained,
executionEngineKeepMd(context),
keepMdIsFormatOutput ? undefined : keepMd,
));
}

Expand Down
3 changes: 3 additions & 0 deletions src/command/render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export interface RenderContext {
libDir: string;
project: ProjectContext;
active: boolean;
// projected output paths of the input's other formats; keep-md intermediate
// handling must not write to or delete paths a format owns (#14669)
siblingFormatOutputs?: string[];
}

export interface RunPandocResult {
Expand Down
30 changes: 28 additions & 2 deletions src/core/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import { kOutputExt, kOutputFile, kServer } from "../config/constants.ts";
import { Format, Metadata } from "../config/types.ts";
import { kJupyterEngine, kKnitrEngine } from "../execute/types.ts";
import { dirAndStem } from "./path.ts";
import { extname } from "../deno_ral/path.ts";
import { dirAndStem, pathsEqual } from "./path.ts";
import { dirname, extname, join } from "../deno_ral/path.ts";

export function inputFilesDir(input: string) {
const [_, stem] = dirAndStem(input);
Expand Down Expand Up @@ -44,6 +44,32 @@ export function isServerShinyKnitr(
return isServerShiny(format) && engine === kKnitrEngine;
}

// the path a format's rendered output is written to before any project
// output-dir relocation: an explicit output-file (with the format's extension
// appended by formatOutputFile when it differs), else <input-stem>.<output-ext>.
// used to detect collisions between declared outputs and the keep-md
// intermediate naming convention (#14669); doesn't cover recipe special cases
// (--output flag, md-to-md rename, stdout) which can't produce colliding names
export function projectedOutputFile(input: string, format: Format): string {
const outputFile = formatOutputFile(format);
if (outputFile) {
return join(dirname(input), outputFile);
}
const [dir, stem] = dirAndStem(input);
return join(dir, `${stem}.${format.render[kOutputExt] || "html"}`);
}

// true when the keep-md intermediate location is a path another declared
// format owns, so it must not be written to or cleaned up (#14669)
export function keepMdCollidesWithFormatOutput(
keepMd: string | undefined,
siblingFormatOutputs: string[] | undefined,
): boolean {
return keepMd !== undefined &&
(siblingFormatOutputs?.some((output) => pathsEqual(output, keepMd)) ??
false);
}

export function formatOutputFile(format: Format) {
let outputFile = format.pandoc[kOutputFile];
if (outputFile) {
Expand Down
7 changes: 7 additions & 0 deletions tests/docs/output-file-collision/default/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/.quarto/
/index.html
/index.html.md
/index.commonmark.md
/index_files/
/site_libs/
**/*.quarto_ipynb
6 changes: 6 additions & 0 deletions tests/docs/output-file-collision/default/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
project:
type: default

format:
html: default
commonmark: default
6 changes: 6 additions & 0 deletions tests/docs/output-file-collision/default/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: Home
output-file: index.html
---

Hello.
6 changes: 6 additions & 0 deletions tests/docs/output-file-collision/keepmd/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/.quarto/
/_site/
/site_libs/
/index.html
/index.commonmark.md
**/*.quarto_ipynb
9 changes: 9 additions & 0 deletions tests/docs/output-file-collision/keepmd/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
project:
type: website

format:
html: default
commonmark: default

execute:
keep-md: true
6 changes: 6 additions & 0 deletions tests/docs/output-file-collision/keepmd/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: Home
output-file: index.html
---

Keep md content.
6 changes: 6 additions & 0 deletions tests/docs/output-file-collision/website/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/.quarto/
/_site/
/site_libs/
/index.html
/index.commonmark.md
**/*.quarto_ipynb
6 changes: 6 additions & 0 deletions tests/docs/output-file-collision/website/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
project:
type: website

format:
html: default
commonmark: default
6 changes: 6 additions & 0 deletions tests/docs/output-file-collision/website/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: Home
output-file: index.html
---

Hello.
136 changes: 136 additions & 0 deletions tests/smoke/render/render-output-file-collision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* render-output-file-collision.test.ts
*
* Regression tests for https://github.com/quarto-dev/quarto-cli/issues/14669:
* pairing an html format with a markdown format while output-file has an
* .html extension (e.g. output-file: index.html, as nbdev sets for llms.txt
* workflows) names the markdown output index.html.md — the same path as the
* keep-md intermediate convention for the html format. Render cleanup was
* deleting the markdown output, failing website renders at the output-move
* step and silently losing the file in default-type projects.
*
* Copyright (C) 2020-2026 Posit Software, PBC
*
*/
import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts";
import { join } from "../../../src/deno_ral/path.ts";
import { quarto } from "../../../src/quarto.ts";
import { docs } from "../../utils.ts";
import { testQuartoCmd } from "../../test.ts";
import {
ensureFileRegexMatches,
fileExists,
pathDoNotExists,
printsMessage,
} from "../../verify.ts";

const cleanup = (dir: string, paths: string[]) => {
return () => {
for (const path of paths) {
const target = join(dir, path);
if (existsSync(target)) {
safeRemoveSync(target, { recursive: true });
}
}
return Promise.resolve();
};
};

// paths a failed (pre-fix) render strands in the project root; clean them
// too so a regression doesn't leave debris behind
const websiteOutputs = [
"_site",
".quarto",
"index.html",
"site_libs",
"index.commonmark.md",
];

// website project: the render errored at the output-move step and left
// index.html + site_libs stranded in the project root
const websiteDir = docs("output-file-collision/website");
testQuartoCmd(
"render",
[websiteDir],
[
fileExists(join(websiteDir, "_site", "index.html")),
fileExists(join(websiteDir, "_site", "index.html.md")),
pathDoNotExists(join(websiteDir, "index.html")),
pathDoNotExists(join(websiteDir, "site_libs")),
],
{
setup: cleanup(websiteDir, websiteOutputs),
teardown: cleanup(websiteDir, websiteOutputs),
},
"#14669 website: output-file index.html + commonmark renders both outputs",
);

// default-type project: the render exited 0 but the markdown output was
// silently missing
const defaultDir = docs("output-file-collision/default");
const defaultOutputs = [
"index.html",
"index.html.md",
"index_files",
"site_libs",
"index.commonmark.md",
];
testQuartoCmd(
"render",
[defaultDir],
[
fileExists(join(defaultDir, "index.html")),
fileExists(join(defaultDir, "index.html.md")),
],
{
setup: cleanup(defaultDir, [...defaultOutputs, ".quarto"]),
teardown: cleanup(defaultDir, [...defaultOutputs, ".quarto"]),
},
"#14669 default project: markdown output is not silently dropped",
);

// partial render: a format that is declared but not being rendered still
// owns its output -- quarto render --to html must not delete the markdown
// twin a previous full render produced (outputs live in place in
// default-type projects)
testQuartoCmd(
"render",
[join(defaultDir, "index.qmd"), "--to", "html"],
[
fileExists(join(defaultDir, "index.html")),
fileExists(join(defaultDir, "index.html.md")),
],
{
setup: async () => {
await cleanup(defaultDir, [...defaultOutputs, ".quarto"])();
await quarto(["render", defaultDir]);
},
teardown: cleanup(defaultDir, [...defaultOutputs, ".quarto"]),
},
"#14669 partial render: --to html preserves the existing markdown output",
);

// keep-md: true wants to write its intermediate to the same path the
// markdown format owns: the intermediate write is skipped with a warning and
// the markdown output wins
const keepmdDir = docs("output-file-collision/keepmd");
testQuartoCmd(
"render",
[keepmdDir],
[
printsMessage({ level: "WARN", regex: /not saving the keep-md/ }),
fileExists(join(keepmdDir, "_site", "index.html.md")),
// the markdown twin holds the commonmark render of the document, not the
// executed-markdown intermediate (which carries a yaml header)
ensureFileRegexMatches(join(keepmdDir, "_site", "index.html.md"), [
/Keep md content\./,
], [
/^---$/,
]),
],
{
setup: cleanup(keepmdDir, websiteOutputs),
teardown: cleanup(keepmdDir, websiteOutputs),
},
"#14669 keep-md: intermediate write is skipped with a warning on collision",
);
Loading