Skip to content

Commit d1ce415

Browse files
authored
Fix keep-md cleanup deleting the markdown output when output-file has an .html extension (#14671)
When a project pairs an html format with a markdown format and output-file carries an .html extension (e.g. output-file: index.html, as nbdev sets on every page for llms.txt workflows), the markdown twin's path (index.html.md) coincides with the keep-md intermediate convention for the html format. The html render's cleanup reclaimed that path as a stale intermediate and deleted the freshly-written markdown output — a hard error at the output-move step in website projects, a silent drop in default projects. keep-md handling now never writes to or deletes a path a declared format owns. This covers full multi-format renders, partial renders (quarto render --to html must not delete a twin a previous full render produced, since outputs live in place in default projects), and keep-md: true (the declared output wins; the intermediate write is skipped with a warning). Closes #14669.
1 parent cac9ff3 commit d1ce415

15 files changed

Lines changed: 274 additions & 6 deletions

File tree

news/changelog-1.10.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,4 @@ All changes included in 1.10:
115115
- ([#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.
116116
- ([#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.
117117
- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment
118+
- ([#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.

src/command/render/render-files.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import {
6262
RenderFlags,
6363
RenderOptions,
6464
} from "./types.ts";
65-
import { error, info } from "../../deno_ral/log.ts";
65+
import { error, info, warning } from "../../deno_ral/log.ts";
6666
import * as ld from "../../core/lodash.ts";
6767
import { basename, dirname, join, relative } from "../../deno_ral/path.ts";
6868
import { Format } from "../../config/types.ts";
@@ -71,6 +71,8 @@ import {
7171
inputFilesDir,
7272
isServerShiny,
7373
isServerShinyKnitr,
74+
keepMdCollidesWithFormatOutput,
75+
projectedOutputFile,
7476
} from "../../core/render.ts";
7577
import {
7678
normalizePath,
@@ -460,6 +462,20 @@ async function renderFileInternal(
460462
files,
461463
options,
462464
);
465+
466+
// let each context know the projected outputs of every declared format,
467+
// including formats not in this render (e.g. quarto render --to html):
468+
// keep-md intermediate handling must not write to or delete a path that
469+
// a format owns (e.g. output-file: index.html plus a markdown format
470+
// yields index.html.md, which is also the conventional keep-md location
471+
// for the html format) (#14669)
472+
const formatOutputs = Object.values(contexts)
473+
.map((context) =>
474+
projectedOutputFile(context.target.input, context.format)
475+
);
476+
for (const context of Object.values(contexts)) {
477+
context.siblingFormatOutputs = formatOutputs;
478+
}
463479
} catch (e) {
464480
// bad YAML can cause failure before validation. We
465481
// reconstruct the context as best we can and try to validate.
@@ -670,7 +686,23 @@ async function renderFileInternal(
670686
// keep md if requested
671687
const keepMd = executionEngineKeepMd(context);
672688
if (keepMd && context.format.execute[kKeepMd]) {
673-
Deno.writeTextFileSync(keepMd, executeResult.markdown.value);
689+
if (
690+
keepMdCollidesWithFormatOutput(
691+
keepMd,
692+
context.siblingFormatOutputs,
693+
)
694+
) {
695+
warning(
696+
`${
697+
basename(context.target.input)
698+
}: not saving the keep-md intermediate because its ` +
699+
`conventional location (${
700+
basename(keepMd)
701+
}) is the output file of another format`,
702+
);
703+
} else {
704+
Deno.writeTextFileSync(keepMd, executeResult.markdown.value);
705+
}
674706
}
675707

676708
// now get "unmapped" execute result back to send to pandoc

src/command/render/render.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { Document, parseHtml } from "../../core/deno-dom.ts";
1212

1313
import { mergeConfigs } from "../../core/config.ts";
1414
import { resourcePath } from "../../core/resources.ts";
15-
import { inputFilesDir } from "../../core/render.ts";
15+
import {
16+
inputFilesDir,
17+
keepMdCollidesWithFormatOutput,
18+
} from "../../core/render.ts";
1619
import {
1720
normalizePath,
1821
pathWithForwardSlashes,
@@ -374,14 +377,23 @@ export async function renderPandoc(
374377
}
375378

376379
if (cleanup !== false) {
380+
// the conventional keep-md location can coincide with the declared
381+
// output of another format (e.g. output-file: index.html plus a
382+
// markdown format yields index.html.md) -- never clean up a path
383+
// that a format owns (#14669)
384+
const keepMd = executionEngineKeepMd(context);
385+
const keepMdIsFormatOutput = keepMdCollidesWithFormatOutput(
386+
keepMd,
387+
context.siblingFormatOutputs,
388+
);
377389
withTiming("render-cleanup", () =>
378390
renderCleanup(
379391
context.target.input,
380392
finalOutput!,
381393
format,
382394
file.context.project,
383395
cleanupSelfContained,
384-
executionEngineKeepMd(context),
396+
keepMdIsFormatOutput ? undefined : keepMd,
385397
));
386398
}
387399

src/command/render/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export interface RenderContext {
6161
libDir: string;
6262
project: ProjectContext;
6363
active: boolean;
64+
// projected output paths of the input's other formats; keep-md intermediate
65+
// handling must not write to or delete paths a format owns (#14669)
66+
siblingFormatOutputs?: string[];
6467
}
6568

6669
export interface RunPandocResult {

src/core/render.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
import { kOutputExt, kOutputFile, kServer } from "../config/constants.ts";
88
import { Format, Metadata } from "../config/types.ts";
99
import { kJupyterEngine, kKnitrEngine } from "../execute/types.ts";
10-
import { dirAndStem } from "./path.ts";
11-
import { extname } from "../deno_ral/path.ts";
10+
import { dirAndStem, pathsEqual } from "./path.ts";
11+
import { dirname, extname, join } from "../deno_ral/path.ts";
1212

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

47+
// the path a format's rendered output is written to before any project
48+
// output-dir relocation: an explicit output-file (with the format's extension
49+
// appended by formatOutputFile when it differs), else <input-stem>.<output-ext>.
50+
// used to detect collisions between declared outputs and the keep-md
51+
// intermediate naming convention (#14669); doesn't cover recipe special cases
52+
// (--output flag, md-to-md rename, stdout) which can't produce colliding names
53+
export function projectedOutputFile(input: string, format: Format): string {
54+
const outputFile = formatOutputFile(format);
55+
if (outputFile) {
56+
return join(dirname(input), outputFile);
57+
}
58+
const [dir, stem] = dirAndStem(input);
59+
return join(dir, `${stem}.${format.render[kOutputExt] || "html"}`);
60+
}
61+
62+
// true when the keep-md intermediate location is a path another declared
63+
// format owns, so it must not be written to or cleaned up (#14669)
64+
export function keepMdCollidesWithFormatOutput(
65+
keepMd: string | undefined,
66+
siblingFormatOutputs: string[] | undefined,
67+
): boolean {
68+
return keepMd !== undefined &&
69+
(siblingFormatOutputs?.some((output) => pathsEqual(output, keepMd)) ??
70+
false);
71+
}
72+
4773
export function formatOutputFile(format: Format) {
4874
let outputFile = format.pandoc[kOutputFile];
4975
if (outputFile) {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/.quarto/
2+
/index.html
3+
/index.html.md
4+
/index.commonmark.md
5+
/index_files/
6+
/site_libs/
7+
**/*.quarto_ipynb
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
project:
2+
type: default
3+
4+
format:
5+
html: default
6+
commonmark: default
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
title: Home
3+
output-file: index.html
4+
---
5+
6+
Hello.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/.quarto/
2+
/_site/
3+
/site_libs/
4+
/index.html
5+
/index.commonmark.md
6+
**/*.quarto_ipynb
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
project:
2+
type: website
3+
4+
format:
5+
html: default
6+
commonmark: default
7+
8+
execute:
9+
keep-md: true

0 commit comments

Comments
 (0)