diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index e10a2250d0..5f4abd24f6 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -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. diff --git a/src/command/render/render-files.ts b/src/command/render/render-files.ts index 171cffec59..2c578c33c0 100644 --- a/src/command/render/render-files.ts +++ b/src/command/render/render-files.ts @@ -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"; @@ -71,6 +71,8 @@ import { inputFilesDir, isServerShiny, isServerShinyKnitr, + keepMdCollidesWithFormatOutput, + projectedOutputFile, } from "../../core/render.ts"; import { normalizePath, @@ -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. @@ -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 diff --git a/src/command/render/render.ts b/src/command/render/render.ts index 3da6c73f04..a7a8510b30 100644 --- a/src/command/render/render.ts +++ b/src/command/render/render.ts @@ -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, @@ -374,6 +377,15 @@ 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, @@ -381,7 +393,7 @@ export async function renderPandoc( format, file.context.project, cleanupSelfContained, - executionEngineKeepMd(context), + keepMdIsFormatOutput ? undefined : keepMd, )); } diff --git a/src/command/render/types.ts b/src/command/render/types.ts index cdc576ea53..2826cdf9f6 100644 --- a/src/command/render/types.ts +++ b/src/command/render/types.ts @@ -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 { diff --git a/src/core/render.ts b/src/core/render.ts index 313445eaed..3f41947a5a 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -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); @@ -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 .. +// 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) { diff --git a/tests/docs/output-file-collision/default/.gitignore b/tests/docs/output-file-collision/default/.gitignore new file mode 100644 index 0000000000..f9ce2b2db8 --- /dev/null +++ b/tests/docs/output-file-collision/default/.gitignore @@ -0,0 +1,7 @@ +/.quarto/ +/index.html +/index.html.md +/index.commonmark.md +/index_files/ +/site_libs/ +**/*.quarto_ipynb diff --git a/tests/docs/output-file-collision/default/_quarto.yml b/tests/docs/output-file-collision/default/_quarto.yml new file mode 100644 index 0000000000..a98fe5d28d --- /dev/null +++ b/tests/docs/output-file-collision/default/_quarto.yml @@ -0,0 +1,6 @@ +project: + type: default + +format: + html: default + commonmark: default diff --git a/tests/docs/output-file-collision/default/index.qmd b/tests/docs/output-file-collision/default/index.qmd new file mode 100644 index 0000000000..5cd735cefb --- /dev/null +++ b/tests/docs/output-file-collision/default/index.qmd @@ -0,0 +1,6 @@ +--- +title: Home +output-file: index.html +--- + +Hello. diff --git a/tests/docs/output-file-collision/keepmd/.gitignore b/tests/docs/output-file-collision/keepmd/.gitignore new file mode 100644 index 0000000000..b87288d2f4 --- /dev/null +++ b/tests/docs/output-file-collision/keepmd/.gitignore @@ -0,0 +1,6 @@ +/.quarto/ +/_site/ +/site_libs/ +/index.html +/index.commonmark.md +**/*.quarto_ipynb diff --git a/tests/docs/output-file-collision/keepmd/_quarto.yml b/tests/docs/output-file-collision/keepmd/_quarto.yml new file mode 100644 index 0000000000..3a5d272c7c --- /dev/null +++ b/tests/docs/output-file-collision/keepmd/_quarto.yml @@ -0,0 +1,9 @@ +project: + type: website + +format: + html: default + commonmark: default + +execute: + keep-md: true diff --git a/tests/docs/output-file-collision/keepmd/index.qmd b/tests/docs/output-file-collision/keepmd/index.qmd new file mode 100644 index 0000000000..5d906ee987 --- /dev/null +++ b/tests/docs/output-file-collision/keepmd/index.qmd @@ -0,0 +1,6 @@ +--- +title: Home +output-file: index.html +--- + +Keep md content. diff --git a/tests/docs/output-file-collision/website/.gitignore b/tests/docs/output-file-collision/website/.gitignore new file mode 100644 index 0000000000..b87288d2f4 --- /dev/null +++ b/tests/docs/output-file-collision/website/.gitignore @@ -0,0 +1,6 @@ +/.quarto/ +/_site/ +/site_libs/ +/index.html +/index.commonmark.md +**/*.quarto_ipynb diff --git a/tests/docs/output-file-collision/website/_quarto.yml b/tests/docs/output-file-collision/website/_quarto.yml new file mode 100644 index 0000000000..3c67c72b26 --- /dev/null +++ b/tests/docs/output-file-collision/website/_quarto.yml @@ -0,0 +1,6 @@ +project: + type: website + +format: + html: default + commonmark: default diff --git a/tests/docs/output-file-collision/website/index.qmd b/tests/docs/output-file-collision/website/index.qmd new file mode 100644 index 0000000000..5cd735cefb --- /dev/null +++ b/tests/docs/output-file-collision/website/index.qmd @@ -0,0 +1,6 @@ +--- +title: Home +output-file: index.html +--- + +Hello. diff --git a/tests/smoke/render/render-output-file-collision.test.ts b/tests/smoke/render/render-output-file-collision.test.ts new file mode 100644 index 0000000000..f360ed0d54 --- /dev/null +++ b/tests/smoke/render/render-output-file-collision.test.ts @@ -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", +);