From ec19a10b05ce350dabb393a579ff66fc8f703b90 Mon Sep 17 00:00:00 2001 From: Jacob Larkin Date: Sat, 10 Jan 2026 21:25:03 -0700 Subject: [PATCH] add output-suffix option to customize output filename suffix --- src/command/render/output-tex.ts | 4 +++- src/command/render/output-typst.ts | 8 ++++--- src/command/render/output.ts | 10 ++++++--- src/command/render/render-contexts.ts | 4 +++- src/config/constants.ts | 2 ++ src/config/types.ts | 2 ++ src/core/render.ts | 15 ++++++++++++- src/resources/schema/document-render.yml | 7 +++++++ .../2026/01/10/13111-output-suffix.qmd | 21 +++++++++++++++++++ 9 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tests/docs/smoke-all/2026/01/10/13111-output-suffix.qmd diff --git a/src/command/render/output-tex.ts b/src/command/render/output-tex.ts index c93b22b9d50..abb57b4e23c 100644 --- a/src/command/render/output-tex.ts +++ b/src/command/render/output-tex.ts @@ -15,6 +15,7 @@ import { kKeepTex, kOutputExt, kOutputFile, + kOutputSuffix, kTargetFormat, } from "../../config/constants.ts"; import { Format } from "../../config/types.ts"; @@ -61,7 +62,8 @@ export function texToPdfOutputRecipe( }`; } - const texStem = texSafeFilename(`${inputStem}${fixupInputName}`); + const suffix = format.render[kOutputSuffix] || ""; + const texStem = texSafeFilename(`${inputStem}${suffix}${fixupInputName}`); // calculate output and args for pandoc (this is an intermediate file // which we will then compile to a pdf and rename to .tex) diff --git a/src/command/render/output-typst.ts b/src/command/render/output-typst.ts index 83a5b3f9cac..ca691bf1394 100644 --- a/src/command/render/output-typst.ts +++ b/src/command/render/output-typst.ts @@ -12,6 +12,7 @@ import { kKeepTyp, kOutputExt, kOutputFile, + kOutputSuffix, kVariant, } from "../../config/constants.ts"; import { Format } from "../../config/types.ts"; @@ -45,7 +46,8 @@ export function typstPdfOutputRecipe( // calculate output and args for pandoc (this is an intermediate file // which we will then compile to a pdf and rename to .typ) const [inputDir, inputStem] = dirAndStem(input); - const output = inputStem + ".typ"; + const suffix = format.render[kOutputSuffix] || ""; + const output = inputStem + suffix + ".typ"; let args = options.pandocArgs || []; const pandoc = { ...format.pandoc }; if (options.flags?.output) { @@ -62,7 +64,7 @@ export function typstPdfOutputRecipe( // run typst await validateRequiredTypstVersion(); - const pdfOutput = join(inputDir, inputStem + ".pdf"); + const pdfOutput = join(inputDir, inputStem + suffix + ".pdf"); const typstOptions: TypstCompileOptions = { quiet: options.flags?.quiet, fontPaths: asArray(format.metadata?.[kFontPaths]) as string[], @@ -111,7 +113,7 @@ export function typstPdfOutputRecipe( ? finalOutput === kStdOut ? undefined : normalizeOutputPath(input, finalOutput) - : normalizeOutputPath(input, join(inputDir, inputStem + ".pdf")); + : normalizeOutputPath(input, join(inputDir, inputStem + suffix + ".pdf")); // return recipe const recipe: OutputRecipe = { diff --git a/src/command/render/output.ts b/src/command/render/output.ts index 9ae7e057e7a..f9c6596c049 100644 --- a/src/command/render/output.ts +++ b/src/command/render/output.ts @@ -25,6 +25,7 @@ import { import { kOutputExt, kOutputFile, + kOutputSuffix, kPreserveYaml, kVariant, } from "../../config/constants.ts"; @@ -184,18 +185,21 @@ export function outputRecipe( const deriveAutoOutput = () => { // no output specified: derive an output path from the extension + // Get the suffix if specified + const suffix = format.render[kOutputSuffix] || ""; + // derive new output file - let output = inputStem + "." + ext; + let output = `${inputStem}${suffix}.${ext}`; // special case for .md to .md, need to append the writer to create a // non-conflicting filename if (extname(input) === ".md" && ext === "md") { - output = `${inputStem}-${format.identifier["base-format"]}.md`; + output = `${inputStem}${suffix}-${format.identifier["base-format"]}.md`; } // special case if the source will overwrite the destination (note: this // behavior can be customized with a custom output-ext) if (output === basename(context.target.source)) { - output = inputStem + `.${kOutExt}.` + ext; + output = `${inputStem}${suffix}.${kOutExt}.${ext}`; } // assign output diff --git a/src/command/render/render-contexts.ts b/src/command/render/render-contexts.ts index 7ad6555ae45..bd0ff982d3d 100644 --- a/src/command/render/render-contexts.ts +++ b/src/command/render/render-contexts.ts @@ -49,6 +49,7 @@ import { kMetadataFormat, kOutputExt, kOutputFile, + kOutputSuffix, kServer, kTargetFormat, kWarning, @@ -330,7 +331,8 @@ export async function renderFormats( // resolve output-file if (!format.pandoc[kOutputFile]) { const [_dir, stem] = dirAndStem(file); - format.pandoc[kOutputFile] = `${stem}.${format.render[kOutputExt]}`; + const suffix = format.render[kOutputSuffix] || ""; + format.pandoc[kOutputFile] = `${stem}${suffix}.${format.render[kOutputExt]}`; } // provide engine format.execute[kEngine] = context.engine.name; diff --git a/src/config/constants.ts b/src/config/constants.ts index 56e75b1d823..197d0a3e818 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -90,6 +90,7 @@ export const kKeepIpynb = "keep-ipynb"; export const kKeepSource = "keep-source"; export const kVariant = "variant"; export const kOutputExt = "output-ext"; +export const kOutputSuffix = "output-suffix"; export const kOutputDivs = "output-divs"; export const kPageWidth = "page-width"; export const kFigAlign = "fig-align"; @@ -191,6 +192,7 @@ export const kRenderDefaultsKeys = [ kClearHiddenClasses, kVariant, kOutputExt, + kOutputSuffix, kOutputDivs, kPreferHtml, kPageWidth, diff --git a/src/config/types.ts b/src/config/types.ts index 09daafe951d..a005d105a76 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -172,6 +172,7 @@ import { kOutputDivs, kOutputExt, kOutputFile, + kOutputSuffix, kPageWidth, kPdfEngine, kPdfEngineOpt, @@ -469,6 +470,7 @@ export interface FormatRender { [kOutputDivs]?: boolean; [kVariant]?: string; [kOutputExt]?: string; + [kOutputSuffix]?: string; [kPageWidth]?: number; [kFigAlign]?: "left" | "right" | "center" | "default"; [kFigPos]?: string | null; diff --git a/src/core/render.ts b/src/core/render.ts index 313445eaed3..766321d294d 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -4,7 +4,7 @@ * Copyright (C) 2020-2022 Posit Software, PBC */ -import { kOutputExt, kOutputFile, kServer } from "../config/constants.ts"; +import { kOutputExt, kOutputFile, kOutputSuffix, kServer } from "../config/constants.ts"; import { Format, Metadata } from "../config/types.ts"; import { kJupyterEngine, kKnitrEngine } from "../execute/types.ts"; import { dirAndStem } from "./path.ts"; @@ -47,6 +47,19 @@ export function isServerShinyKnitr( export function formatOutputFile(format: Format) { let outputFile = format.pandoc[kOutputFile]; if (outputFile) { + // Apply output-suffix if specified (insert before extension) + const suffix = format.render[kOutputSuffix]; + if (suffix) { + const ext = extname(outputFile); + if (ext) { + const stem = outputFile.slice(0, -ext.length); + outputFile = `${stem}${suffix}${ext}`; + } else { + // No extension, just append suffix + outputFile = `${outputFile}${suffix}`; + } + } + if (format.render[kOutputExt]) { // Don't append the output extension if the same output // extension is already present. If you update this logic, diff --git a/src/resources/schema/document-render.yml b/src/resources/schema/document-render.yml index dd7fc9fa6fa..5cc83106e0d 100644 --- a/src/resources/schema/document-render.yml +++ b/src/resources/schema/document-render.yml @@ -18,6 +18,13 @@ description: | Extension to use for generated output file +- name: output-suffix + schema: string + description: | + Suffix to append to the output filename stem (before the extension). + For example, with `output-suffix: "-slides"`, `presentation.qmd` + becomes `presentation-slides.html`. + - name: template disabled: [$office-all, ipynb] schema: path diff --git a/tests/docs/smoke-all/2026/01/10/13111-output-suffix.qmd b/tests/docs/smoke-all/2026/01/10/13111-output-suffix.qmd new file mode 100644 index 00000000000..fc8e0cb233c --- /dev/null +++ b/tests/docs/smoke-all/2026/01/10/13111-output-suffix.qmd @@ -0,0 +1,21 @@ +--- +title: "Output Suffix Test" +format: + html: + output-suffix: "-readable" + revealjs: + output-suffix: "-slides" +--- + +# Test Document + +This document tests the `output-suffix` feature. + +## Purpose + +When rendered with `output-suffix: "-readable"`, this file should produce: +- `13111-output-suffix-readable.html` (from HTML format) +- `13111-output-suffix-slides.html` (from RevealJS format) + +This addresses GitHub issue #13111 where using compound extensions like +`.onepage.html` in `output-ext` caused double-extension bugs.