From b648109ca95aca7a050da22fe57d4debe7d07d60 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Wed, 15 Oct 2025 07:41:18 -0400 Subject: [PATCH 1/3] quarto use brand --- src/command/use/cmd.ts | 3 +- src/command/use/commands/brand.ts | 475 +++++++++++++ src/project/project-shared.ts | 9 +- tests/smoke/use-brand/basic-brand/_brand.yml | 4 + tests/smoke/use-brand/basic-brand/logo.png | Bin 0 -> 1862 bytes .../use-brand/multi-file-brand/_brand.yml | 4 + .../use-brand/multi-file-brand/favicon.png | Bin 0 -> 1169 bytes .../smoke/use-brand/multi-file-brand/logo.png | Bin 0 -> 1862 bytes tests/smoke/use-brand/nested-brand/_brand.yml | 4 + .../use-brand/nested-brand/images/header.png | Bin 0 -> 2209 bytes .../use-brand/nested-brand/images/logo.png | Bin 0 -> 1862 bytes tests/smoke/use/brand.test.ts | 652 ++++++++++++++++++ 12 files changed, 1147 insertions(+), 4 deletions(-) create mode 100644 src/command/use/commands/brand.ts create mode 100644 tests/smoke/use-brand/basic-brand/_brand.yml create mode 100644 tests/smoke/use-brand/basic-brand/logo.png create mode 100644 tests/smoke/use-brand/multi-file-brand/_brand.yml create mode 100644 tests/smoke/use-brand/multi-file-brand/favicon.png create mode 100644 tests/smoke/use-brand/multi-file-brand/logo.png create mode 100644 tests/smoke/use-brand/nested-brand/_brand.yml create mode 100644 tests/smoke/use-brand/nested-brand/images/header.png create mode 100644 tests/smoke/use-brand/nested-brand/images/logo.png create mode 100644 tests/smoke/use/brand.test.ts diff --git a/src/command/use/cmd.ts b/src/command/use/cmd.ts index c7089b15de8..6251f5bf02c 100644 --- a/src/command/use/cmd.ts +++ b/src/command/use/cmd.ts @@ -7,8 +7,9 @@ import { Command, ValidationError } from "cliffy/command/mod.ts"; import { useTemplateCommand } from "./commands/template.ts"; import { useBinderCommand } from "./commands/binder/binder.ts"; +import { useBrandCommand } from "./commands/brand.ts"; -const kUseCommands = [useTemplateCommand, useBinderCommand]; +const kUseCommands = [useTemplateCommand, useBinderCommand, useBrandCommand]; export const makeUseCommand = () => { const theCommand = new Command() diff --git a/src/command/use/commands/brand.ts b/src/command/use/commands/brand.ts new file mode 100644 index 00000000000..6dac4b0dd07 --- /dev/null +++ b/src/command/use/commands/brand.ts @@ -0,0 +1,475 @@ +/* + * brand.ts + * + * Copyright (C) 2021-2025 Posit Software, PBC + */ + +import { + ExtensionSource, + extensionSource, +} from "../../../extension/extension-host.ts"; +import { info } from "../../../deno_ral/log.ts"; +import { Confirm } from "cliffy/prompt/mod.ts"; +import { basename, dirname, join, relative } from "../../../deno_ral/path.ts"; +import { ensureDir, ensureDirSync, existsSync } from "../../../deno_ral/fs.ts"; +import { TempContext } from "../../../core/temp-types.ts"; +import { downloadWithProgress } from "../../../core/download.ts"; +import { withSpinner } from "../../../core/console.ts"; +import { unzip } from "../../../core/zip.ts"; +import { templateFiles } from "../../../extension/template.ts"; +import { Command } from "cliffy/command/mod.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts"; +import { createTempContext } from "../../../core/temp.ts"; +import { InternalError } from "../../../core/lib/error.ts"; +import { notebookContext } from "../../../render/notebook/notebook-context.ts"; +import { projectContext } from "../../../project/project-context.ts"; +import { afterConfirm } from "../../../tools/tools-console.ts"; + +const kRootTemplateName = "template.qmd"; + +export const useBrandCommand = new Command() + .name("brand") + .arguments("") + .description( + "Use a brand for this project.", + ) + .option( + "--force", + "Skip all prompts and confirmations", + ) + .option( + "--dry-run", + "Show what would happen without making changes", + ) + .example( + "Use a brand from Github", + "quarto use brand /", + ) + .action( + async ( + options: { force?: boolean; dryRun?: boolean }, + target: string, + ) => { + if (options.force && options.dryRun) { + throw new Error("Cannot use --force and --dry-run together"); + } + await initYamlIntelligenceResourcesFromFilesystem(); + const temp = createTempContext(); + try { + await useBrand(options, target, temp); + } finally { + temp.cleanup(); + } + }, + ); + +async function useBrand( + options: { force?: boolean; dryRun?: boolean }, + target: string, + tempContext: TempContext, +) { + // Print header for dry-run + if (options.dryRun) { + info("\nDry run - no changes will be made."); + } + + // Resolve brand host and trust + const source = await extensionSource(target); + // Is this source valid? + if (!source) { + info( + `Brand not found in local or remote sources`, + ); + return; + } + + // Check trust (skip for dry-run or force) + if (!options.dryRun && !options.force) { + const trusted = await isTrusted(source); + if (!trusted) { + return; + } + } + + // Resolve brand directory + const brandDir = await ensureBrandDirectory( + options.force === true, + options.dryRun === true, + ); + + // Extract and move the template into place + const stagedDir = await stageBrand(source, tempContext); + + // Filter the list to template files + const filesToCopy = templateFiles(stagedDir); + + // Confirm changes to brand directory (skip for dry-run or force) + if (!options.dryRun && !options.force) { + const filename = (typeof (source.resolvedTarget) === "string" + ? source.resolvedTarget + : source.resolvedFile) || "brand.zip"; + + const allowUse = await Confirm.prompt({ + message: `Proceed with using brand ${filename}?`, + default: true, + }); + if (!allowUse) { + return; + } + } + + if (!options.dryRun) { + info( + `\nPreparing brand files...`, + ); + } + + // Build set of source file paths for comparison + const sourceFiles = new Set( + filesToCopy + .filter((f) => !Deno.statSync(f).isDirectory) + .map((f) => relative(stagedDir, f)), + ); + + // Find extra files in target that aren't in source + const extraFiles = findExtraFiles(brandDir, sourceFiles); + + // Track files by action type + const wouldOverwrite: string[] = []; + const wouldCreate: string[] = []; + const wouldRemove: string[] = []; + const copyActions: Array<{ + file: string; + action: "create" | "overwrite"; + copy: () => Promise; + }> = []; + let removed: string[] = []; + + for (const fileToCopy of filesToCopy) { + const isDir = Deno.statSync(fileToCopy).isDirectory; + const rel = relative(stagedDir, fileToCopy); + if (isDir) { + continue; + } + // Compute the paths + const targetPath = join(brandDir, rel); + const displayName = rel; + const targetDir = dirname(targetPath); + const copyAction = { + file: displayName, + copy: async () => { + // Ensure the directory exists + await ensureDir(targetDir); + + // Copy the file into place + await Deno.copyFile(fileToCopy, targetPath); + }, + }; + + if (existsSync(targetPath)) { + // File exists - will be overwritten + if (options.dryRun) { + wouldOverwrite.push(displayName); + } else if (!options.force) { + // Prompt for overwrite + const proceed = await Confirm.prompt({ + message: `Overwrite file ${displayName}?`, + default: true, + }); + if (proceed) { + copyActions.push({ ...copyAction, action: "overwrite" }); + } else { + throw new Error( + `The file ${displayName} already exists and would be overwritten by this action.`, + ); + } + } else { + // Force mode - overwrite without prompting + copyActions.push({ ...copyAction, action: "overwrite" }); + } + } else { + // File doesn't exist - will be created + if (options.dryRun) { + wouldCreate.push(displayName); + } else { + copyActions.push({ ...copyAction, action: "create" }); + } + } + } + + // Output dry-run summary and return + if (options.dryRun) { + if (wouldOverwrite.length > 0) { + info(`\nWould overwrite:`); + for (const file of wouldOverwrite) { + info(` - ${file}`); + } + } + if (wouldCreate.length > 0) { + info(`\nWould create:`); + for (const file of wouldCreate) { + info(` - ${file}`); + } + } + if (extraFiles.length > 0) { + info(`\nWould remove:`); + for (const file of extraFiles) { + info(` - ${file}`); + } + } + return; + } + + // Copy the files + if (copyActions.length > 0) { + await withSpinner({ message: "Copying files..." }, async () => { + for (const copyAction of copyActions) { + await copyAction.copy(); + } + }); + } + + // Handle extra files in target (not in source) + if (extraFiles.length > 0) { + const removeExtras = async () => { + for (const file of extraFiles) { + await Deno.remove(join(brandDir, file)); + } + // Clean up empty directories + cleanupEmptyDirs(brandDir); + removed = extraFiles; + }; + + if (options.force) { + await removeExtras(); + } else { + // Show the files that would be removed + info(`\nExtra files not in source brand:`); + for (const file of extraFiles) { + info(` - ${file}`); + } + // Use afterConfirm pattern - declining doesn't cancel command + await afterConfirm( + `Remove these ${extraFiles.length} file(s)?`, + removeExtras, + ); + } + } + + // Output summary of changes + const overwritten = copyActions.filter((a) => a.action === "overwrite"); + const created = copyActions.filter((a) => a.action === "create"); + if (overwritten.length > 0) { + info(`\nOverwritten:`); + for (const a of overwritten) { + info(` - ${a.file}`); + } + } + if (created.length > 0) { + info(`\nCreated:`); + for (const a of created) { + info(` - ${a.file}`); + } + } + if (removed.length > 0) { + info(`\nRemoved:`); + for (const file of removed) { + info(` - ${file}`); + } + } +} + +async function stageBrand( + source: ExtensionSource, + tempContext: TempContext, +) { + if (source.type === "remote") { + // A temporary working directory + const workingDir = tempContext.createDir(); + + // Stages a remote file by downloading and unzipping it + const archiveDir = join(workingDir, "archive"); + ensureDirSync(archiveDir); + + // The filename + const filename = (typeof (source.resolvedTarget) === "string" + ? source.resolvedTarget + : source.resolvedFile) || "brand.zip"; + + // The tarball path + const toFile = join(archiveDir, filename); + + // Download the file + await downloadWithProgress(source.resolvedTarget, `Downloading`, toFile); + + // Unzip and remove zip + await unzipInPlace(toFile); + + // Try to find the correct sub directory + if (source.targetSubdir) { + const sourceSubDir = join(archiveDir, source.targetSubdir); + if (existsSync(sourceSubDir)) { + return sourceSubDir; + } + } + + // Couldn't find a source sub dir, see if there is only a single + // subfolder and if so use that + const dirEntries = Deno.readDirSync(archiveDir); + let count = 0; + let name; + let hasFiles = false; + for (const dirEntry of dirEntries) { + // ignore any files + if (dirEntry.isDirectory) { + name = dirEntry.name; + count++; + } else { + hasFiles = true; + } + } + // there is a lone subfolder - use that. + if (!hasFiles && count === 1 && name) { + return join(archiveDir, name); + } + + return archiveDir; + } else { + if (typeof source.resolvedTarget !== "string") { + throw new InternalError( + "Local resolved extension should always have a string target.", + ); + } + + if (Deno.statSync(source.resolvedTarget).isDirectory) { + // copy the contents of the directory, filtered by quartoignore + return source.resolvedTarget; + } else { + // A temporary working directory + const workingDir = tempContext.createDir(); + const targetFile = join(workingDir, basename(source.resolvedTarget)); + + // Copy the zip to the working dir + Deno.copyFileSync( + source.resolvedTarget, + targetFile, + ); + + await unzipInPlace(targetFile); + return workingDir; + } + } +} + +// Determines whether the user trusts the brand +async function isTrusted( + source: ExtensionSource, +): Promise { + if (source.type === "remote") { + // Write the preamble + const preamble = + `\nIf you do not trust the authors of the brand, we recommend that you do not install or use the brand.`; + info(preamble); + + // Ask for trust + const question = "Do you trust the authors of this brand"; + const confirmed: boolean = await Confirm.prompt({ + message: question, + default: true, + }); + return confirmed; + } else { + return true; + } +} + +async function ensureBrandDirectory(force: boolean, dryRun: boolean) { + const currentDir = Deno.cwd(); + const nbContext = notebookContext(); + const project = await projectContext(currentDir, nbContext); + if (!project) { + throw new Error(`Could not find project dir for ${currentDir}`); + } + const brandDir = join(project.dir, "_brand"); + if (!existsSync(brandDir)) { + if (dryRun) { + info(` Would create directory: _brand/`); + } else if (!force) { + // Prompt for confirmation + if ( + !await Confirm.prompt({ + message: `Create brand directory ${brandDir}?`, + default: true, + }) + ) { + throw new Error(`Could not create brand directory ${brandDir}`); + } + ensureDirSync(brandDir); + } else { + // Force mode - create without prompting + ensureDirSync(brandDir); + } + } + return brandDir; +} + +// Unpack and stage a zipped file +async function unzipInPlace(zipFile: string) { + // Unzip the file + await withSpinner( + { message: "Unzipping" }, + async () => { + // Unzip the archive + const result = await unzip(zipFile); + if (!result.success) { + throw new Error("Failed to unzip brand.\n" + result.stderr); + } + + // Remove the tar ball itself + await Deno.remove(zipFile); + + return Promise.resolve(); + }, + ); +} + +// Find files in target directory that aren't in source +function findExtraFiles( + targetDir: string, + sourceFiles: Set, +): string[] { + const extraFiles: string[] = []; + + function walkDir(dir: string, baseRel: string = "") { + if (!existsSync(dir)) return; + for (const entry of Deno.readDirSync(dir)) { + // Use join() for cross-platform path separator compatibility + // This matches the behavior of relative() used to build sourceFiles + const rel = baseRel ? join(baseRel, entry.name) : entry.name; + if (entry.isDirectory) { + walkDir(join(dir, entry.name), rel); + } else if (!sourceFiles.has(rel)) { + extraFiles.push(rel); + } + } + } + + walkDir(targetDir); + return extraFiles; +} + +// Clean up empty directories after file removal +function cleanupEmptyDirs(dir: string) { + if (!existsSync(dir)) return; + for (const entry of Deno.readDirSync(dir)) { + if (entry.isDirectory) { + const subdir = join(dir, entry.name); + cleanupEmptyDirs(subdir); + // Check if now empty + const contents = [...Deno.readDirSync(subdir)]; + if (contents.length === 0) { + Deno.removeSync(subdir); + } + } + } +} diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index 736e9745f44..083484360f4 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -557,9 +557,12 @@ export async function projectResolveBrand( return project.brandCache.brand; } project.brandCache = {}; - let fileNames = ["_brand.yml", "_brand.yaml"].map((file) => - join(project.dir, file) - ); + let fileNames = [ + "_brand.yml", + "_brand.yaml", + "_brand/_brand.yml", + "_brand/_brand.yaml", + ].map((file) => join(project.dir, file)); const brand = (project?.config?.brand ?? project?.config?.project.brand) as | boolean diff --git a/tests/smoke/use-brand/basic-brand/_brand.yml b/tests/smoke/use-brand/basic-brand/_brand.yml new file mode 100644 index 00000000000..52e42199215 --- /dev/null +++ b/tests/smoke/use-brand/basic-brand/_brand.yml @@ -0,0 +1,4 @@ +meta: + name: Basic Test Brand +color: + primary: "#007bff" diff --git a/tests/smoke/use-brand/basic-brand/logo.png b/tests/smoke/use-brand/basic-brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c8ba37dd5c9224af174f37da11521bc5e80c29ca GIT binary patch literal 1862 zcmV-M2f6r(P)xA%C=uJ}w}5&U+~@vO?tKzRMN zq6Of09vK-*XD;ecI$J8@pH3RryU3YgBy6cx=mx_6+dOqZ#pv2YX;p9Z%S$~JihUf~ zQzoU}L(bsWQ}BycQ(0W*7TU#>kBaM5qZnkdO3V50Bd%Y;BIStN9FBnq!(iux^}Tbe z6+)>uYy)9K2)wgvI>dcHtGzZLv`XO#o7zpW6ld+kem$dTt(g9o{ut@^xV$$W|Fo1O z&h>as*YrC*m=CFt$ zRV~tGYpHiN=esbNZZu}voWs4(zGEdc=ALItlEp|cUVNRZ=z^LTk^FNiG$oBigw=T2znGt zbg!oRk<|s)NA&u8ghTI=FJ?m5$3~97FZU}rVo;=HS#`E{KSZ;t0&DW)Gn4feo#kek1aAF)Z=OU zNM0k`K{*6tza3WxLtBt7J$RZJNcxg;5J?)=q1;)@q_R*C%0L2a0f*j-JgnuPk<0!$ zl*uL#s^x?xMuCo$)n*Kx9{;>Oi7*AP$Mp=L8sIG{#9%o@C=Pk67o}$)KRzYh7UTL! zkbQP3*vbLx$G2l0uHLXBN%-~%uHJOA_tirN zzI`gLzM2dg4ZfWdaP>c1oyK#d7%a)>_tpp=Nt#Vtbo&+Z>~2W76AqWuZ@tJ3VvX`G zxcb*U!k7s-30F@iI1@R}&T3qJ+tvzua&N%Z*U@mT`1aMf`X=%r&)x=B=k4uzc5PmK z1+HHBECJSa_;z-|@SUHtboy1N<&W~FOmns)ddvcp=Xe|H1sSi16$9s*2FB+J(m^`% zlbPjrpYqtYe&rS6P>(-s++n@{ZQOBtk$$H`0a=!V!0On()Z*JgO<2T&tEY&yNV?C? z;g>Ll@{!PQog!0M9~-WD`1T37dR0QfB3iUZJxs!c*(;*Ox5xAGx=W%RTz$>Mw`;1Q z%(@1C+&`!A^@$!2zMaWldCvz@hfJOL_M*DD!coM+h|_pBtcBda7?YHLQ+cn}2>NkeI-y;{_ z9>mobKd!!};@h9;f7apZhxgeX`1TZBy$dgYWMg0^U&&qPv%d>he@w!+`*HO%IOyEZ z7UJqB`y=ad^|Be?$YIQ(gC6HUY;f6|0GYkr9(TPB{qZ4Fuj~G2V!yQ*Fy)`g8=sAD zS3jdp;J%MH9-B8lh^xoujbDRr*Yn1oX+t+}{NLe?=bL%s!Oa{0H+kcE2i|z%kMPD9 zLts@7VuQuTNVSqTo|Aav$+f)k`aC<$AVWlP_19yY;vGbFe#ATu%%U|Oa5QIuGtY-s z0Ew;4std<)$OORMT;6ysuD&=adY1FXr}E4D^X$p#f{DEGeSAC9c;gkB zsVgfp%>QjiE6>iZ)#6(OHy$Ik5xYB1 zo!cMcjX&kPUHEpIdE-m;{>b>E!wh`8o;SWI?z5NMQy+aDZ~VLQ?UAf~O~tqC?w<#F zlF9$xD&p;tj)83AaDHLnE-?L*^}|@hJI7;vrode59>AC`!e46 z66TFB>Adl>w)q0y_&4C&XYs}_##+S_-e>;?EfH%Hu z-uUvzdE*nVxZe8NF1zfq%Pza@vdb>J?DE0oKPD?97$k(*6951J07*qoM6N<$g52iX AK>z>% literal 0 HcmV?d00001 diff --git a/tests/smoke/use-brand/multi-file-brand/_brand.yml b/tests/smoke/use-brand/multi-file-brand/_brand.yml new file mode 100644 index 00000000000..11c1107b45f --- /dev/null +++ b/tests/smoke/use-brand/multi-file-brand/_brand.yml @@ -0,0 +1,4 @@ +meta: + name: Multi-file Test Brand +color: + primary: "#28a745" diff --git a/tests/smoke/use-brand/multi-file-brand/favicon.png b/tests/smoke/use-brand/multi-file-brand/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..03ade4522c91ddf6e1089a9035d85b7e58438a0c GIT binary patch literal 1169 zcmV;C1aA9@P)6b{)8~5pexB}z_F;N1 zey@KLQy|36M-9RW!2LVoPe>@>_c8AsX?pj8@|p7(=rADI;D{2%J*EC$>OIx9?DBRJn;*Y-=teidSr zvbHZi?@GEpDNG1hD9dc^k8kPl_raSQp<=*FHvEV#3OCG%Fj;Q8Vz`wJX%y!3Wzt)d z^LBS#ergr_&?06OAflPqb#*b&v)`863hN_B$F^rbdpP@gZH2WCs}^MhK}*SowYqiHOdR=~QDd!BltaiFR1$x} z%A6Ez;Hh3o)pb~_+|r?Q&|lt2ojj@ARmnA@?5C5=ew|BvXowCNDbWwj}8 zKr*HgLSZE{OCLy(&+C*&-)(oPn5oJhMQugTHrG}B6}o^kt`f-#Q7#B9dA5qt1kh<- zh%Zv?2*m=$1}hk#R9M^sy7FLINKaL-otad}L0_@$tErr^f8@Mw(DB6n<9#$$bR3$A zO-F8bd*P>-?SK}8RrPS#n?+rH_Fq*01hXFCxhv-yXD7~4w)O`Ki-pdj58S2kGllK_ z_`3bu_pr;(^1a`^>H=wk&~`HKDEud79om>%Pm3z+9|!X=pq_B!#iz z95!lUpPJ?I^@Zdmw680S6!tp+HK7r&-i(n7!{{v!oXdrs!nP_AWb5tKYfxA%C=uJ}w}5&U+~@vO?tKzRMN zq6Of09vK-*XD;ecI$J8@pH3RryU3YgBy6cx=mx_6+dOqZ#pv2YX;p9Z%S$~JihUf~ zQzoU}L(bsWQ}BycQ(0W*7TU#>kBaM5qZnkdO3V50Bd%Y;BIStN9FBnq!(iux^}Tbe z6+)>uYy)9K2)wgvI>dcHtGzZLv`XO#o7zpW6ld+kem$dTt(g9o{ut@^xV$$W|Fo1O z&h>as*YrC*m=CFt$ zRV~tGYpHiN=esbNZZu}voWs4(zGEdc=ALItlEp|cUVNRZ=z^LTk^FNiG$oBigw=T2znGt zbg!oRk<|s)NA&u8ghTI=FJ?m5$3~97FZU}rVo;=HS#`E{KSZ;t0&DW)Gn4feo#kek1aAF)Z=OU zNM0k`K{*6tza3WxLtBt7J$RZJNcxg;5J?)=q1;)@q_R*C%0L2a0f*j-JgnuPk<0!$ zl*uL#s^x?xMuCo$)n*Kx9{;>Oi7*AP$Mp=L8sIG{#9%o@C=Pk67o}$)KRzYh7UTL! zkbQP3*vbLx$G2l0uHLXBN%-~%uHJOA_tirN zzI`gLzM2dg4ZfWdaP>c1oyK#d7%a)>_tpp=Nt#Vtbo&+Z>~2W76AqWuZ@tJ3VvX`G zxcb*U!k7s-30F@iI1@R}&T3qJ+tvzua&N%Z*U@mT`1aMf`X=%r&)x=B=k4uzc5PmK z1+HHBECJSa_;z-|@SUHtboy1N<&W~FOmns)ddvcp=Xe|H1sSi16$9s*2FB+J(m^`% zlbPjrpYqtYe&rS6P>(-s++n@{ZQOBtk$$H`0a=!V!0On()Z*JgO<2T&tEY&yNV?C? z;g>Ll@{!PQog!0M9~-WD`1T37dR0QfB3iUZJxs!c*(;*Ox5xAGx=W%RTz$>Mw`;1Q z%(@1C+&`!A^@$!2zMaWldCvz@hfJOL_M*DD!coM+h|_pBtcBda7?YHLQ+cn}2>NkeI-y;{_ z9>mobKd!!};@h9;f7apZhxgeX`1TZBy$dgYWMg0^U&&qPv%d>he@w!+`*HO%IOyEZ z7UJqB`y=ad^|Be?$YIQ(gC6HUY;f6|0GYkr9(TPB{qZ4Fuj~G2V!yQ*Fy)`g8=sAD zS3jdp;J%MH9-B8lh^xoujbDRr*Yn1oX+t+}{NLe?=bL%s!Oa{0H+kcE2i|z%kMPD9 zLts@7VuQuTNVSqTo|Aav$+f)k`aC<$AVWlP_19yY;vGbFe#ATu%%U|Oa5QIuGtY-s z0Ew;4std<)$OORMT;6ysuD&=adY1FXr}E4D^X$p#f{DEGeSAC9c;gkB zsVgfp%>QjiE6>iZ)#6(OHy$Ik5xYB1 zo!cMcjX&kPUHEpIdE-m;{>b>E!wh`8o;SWI?z5NMQy+aDZ~VLQ?UAf~O~tqC?w<#F zlF9$xD&p;tj)83AaDHLnE-?L*^}|@hJI7;vrode59>AC`!e46 z66TFB>Adl>w)q0y_&4C&XYs}_##+S_-e>;?EfH%Hu z-uUvzdE*nVxZe8NF1zfq%Pza@vdb>J?DE0oKPD?97$k(*6951J07*qoM6N<$g52iX AK>z>% literal 0 HcmV?d00001 diff --git a/tests/smoke/use-brand/nested-brand/_brand.yml b/tests/smoke/use-brand/nested-brand/_brand.yml new file mode 100644 index 00000000000..345aa0e4b15 --- /dev/null +++ b/tests/smoke/use-brand/nested-brand/_brand.yml @@ -0,0 +1,4 @@ +meta: + name: Nested Test Brand +color: + primary: "#dc3545" diff --git a/tests/smoke/use-brand/nested-brand/images/header.png b/tests/smoke/use-brand/nested-brand/images/header.png new file mode 100644 index 0000000000000000000000000000000000000000..a9ec42c984e2436eadc51df7a77a319da46c1425 GIT binary patch literal 2209 zcmZ`)eN>Wn6sE1Va%pWH)I`$Oj;rkCmWgH#YiiDU$Z4Pfnz@yTX=?d}h*~>kof6%a z&Z(tYO1zjUx%auxeeS*If7tJB zYGP%AKp;$gzVi$~AU=GFKrG8ysr!Mjv_sY*5Gx~mJon<0yT@d*;}sao6>VdY^0{1D zGvJLg(f+O`CHcD@w&stPd>!iZ>+>(ROf}jx%+#q-?lpi~U^Ce@^G;ptt4-rs-UBR!SB%{>M_@A@W^mu!GTekP(`H=IE%m=A&qsr_hR;qY;-jlOy=!BSb z{x%!VAlAW>qT#JaZ<8&crA@56;}at@EfI}0G6q#X$t&yM)d@5_eSmd9Ll0CGN7sY* zbOctq+P++1GXKc&sAeBxY)Os232SeeLjt&ijdY~o0LtGF3YhweMr!qy-_+uQU_Fw$V4EgC@Ehu2rcjcEdB zTL2cdC=0$EXJAIxUTRI)ppYhaAK0jjDzeBjNNz;Ps6)3y#*F&Dpg3&Zg{Lcl2?@`+D%IhR$J4^vSaREFyDM%

J zhsWQF*H72`NB`#Hif#MYw?$TdR70luMX~T@%zj@N!tqyfBcfuNk)t)^YYRz;`4JWK zL3}zFyrJ$vp^9LuCA)bob+{>}=hB5-Yll(R$$=(s%&!pISoL6;DEXM=nuzR-ae_g~wd%NEz18?U#n9XBh@4?U)ld?O;3dOmG*Q)=o*k_S3YX3?mnm z7DP@AG!v-Op6U{HKjV$FDQIt` zRe7|zd7^N-XchOBN(w!z>|`9(?~4g}B=AJCLIhT|+$zX-ys{aG`VV3;vkhrAn{N_q zN5<^RnkkQRg;C?6otL|UQ8#XHu-8pKd$6mAzoV3NOsKukt=m7_Uxjf$%7&?(bgfbH z#i6YfVG7oX7rho^ou48dHEt8XEJjasL;}yY2Jziug_B)7Dt;Wi;*A|kkp=0sFE9f=c+Qd&QMRWBNv`cP)tLMrESf z1@hS_o!kmjsd|F4n%*v$R?9kTUe^P#L#Pwsy;W@$cw9oTlIrIp`lidL;nAKt30*t6 zUBG=@(RZWVWTmUoMq9{13&d5e=GYS75^`G31>`39qy3<<6!{7bD6j_r_6K?6|G;1j z=tdtQ99Pk5C$-nh%#IHAYWtB$NY+=&8Rs+x#g8mGFFFkdX-}TrHII#8V~O6ngOPLu zx_nYj(13k?co46lJz@CdpR2$w-|UBS^c`V z6L_60{EO-IIXQ)3hyO8zu&jetE#wZDHV5I5@2{Ph%wgNf9^(=#KJ9AfxK@33%$$&s zAaBwTi1AZROD+0`81QZ#343{j8tzFw*GLHF)dsNOa_G23)qGmtyZN{ncZ+=W^-ZSJ z0pzXWmB90%+PXK4G^%;a%fmy9&u2!72mS!AJJQo^%X(raSY=%+Y^PYHCxVQs0|xm0 b4YMB!4rRl*t65F+>%_-vzh~vQhfn_v)UpLH literal 0 HcmV?d00001 diff --git a/tests/smoke/use-brand/nested-brand/images/logo.png b/tests/smoke/use-brand/nested-brand/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c8ba37dd5c9224af174f37da11521bc5e80c29ca GIT binary patch literal 1862 zcmV-M2f6r(P)xA%C=uJ}w}5&U+~@vO?tKzRMN zq6Of09vK-*XD;ecI$J8@pH3RryU3YgBy6cx=mx_6+dOqZ#pv2YX;p9Z%S$~JihUf~ zQzoU}L(bsWQ}BycQ(0W*7TU#>kBaM5qZnkdO3V50Bd%Y;BIStN9FBnq!(iux^}Tbe z6+)>uYy)9K2)wgvI>dcHtGzZLv`XO#o7zpW6ld+kem$dTt(g9o{ut@^xV$$W|Fo1O z&h>as*YrC*m=CFt$ zRV~tGYpHiN=esbNZZu}voWs4(zGEdc=ALItlEp|cUVNRZ=z^LTk^FNiG$oBigw=T2znGt zbg!oRk<|s)NA&u8ghTI=FJ?m5$3~97FZU}rVo;=HS#`E{KSZ;t0&DW)Gn4feo#kek1aAF)Z=OU zNM0k`K{*6tza3WxLtBt7J$RZJNcxg;5J?)=q1;)@q_R*C%0L2a0f*j-JgnuPk<0!$ zl*uL#s^x?xMuCo$)n*Kx9{;>Oi7*AP$Mp=L8sIG{#9%o@C=Pk67o}$)KRzYh7UTL! zkbQP3*vbLx$G2l0uHLXBN%-~%uHJOA_tirN zzI`gLzM2dg4ZfWdaP>c1oyK#d7%a)>_tpp=Nt#Vtbo&+Z>~2W76AqWuZ@tJ3VvX`G zxcb*U!k7s-30F@iI1@R}&T3qJ+tvzua&N%Z*U@mT`1aMf`X=%r&)x=B=k4uzc5PmK z1+HHBECJSa_;z-|@SUHtboy1N<&W~FOmns)ddvcp=Xe|H1sSi16$9s*2FB+J(m^`% zlbPjrpYqtYe&rS6P>(-s++n@{ZQOBtk$$H`0a=!V!0On()Z*JgO<2T&tEY&yNV?C? z;g>Ll@{!PQog!0M9~-WD`1T37dR0QfB3iUZJxs!c*(;*Ox5xAGx=W%RTz$>Mw`;1Q z%(@1C+&`!A^@$!2zMaWldCvz@hfJOL_M*DD!coM+h|_pBtcBda7?YHLQ+cn}2>NkeI-y;{_ z9>mobKd!!};@h9;f7apZhxgeX`1TZBy$dgYWMg0^U&&qPv%d>he@w!+`*HO%IOyEZ z7UJqB`y=ad^|Be?$YIQ(gC6HUY;f6|0GYkr9(TPB{qZ4Fuj~G2V!yQ*Fy)`g8=sAD zS3jdp;J%MH9-B8lh^xoujbDRr*Yn1oX+t+}{NLe?=bL%s!Oa{0H+kcE2i|z%kMPD9 zLts@7VuQuTNVSqTo|Aav$+f)k`aC<$AVWlP_19yY;vGbFe#ATu%%U|Oa5QIuGtY-s z0Ew;4std<)$OORMT;6ysuD&=adY1FXr}E4D^X$p#f{DEGeSAC9c;gkB zsVgfp%>QjiE6>iZ)#6(OHy$Ik5xYB1 zo!cMcjX&kPUHEpIdE-m;{>b>E!wh`8o;SWI?z5NMQy+aDZ~VLQ?UAf~O~tqC?w<#F zlF9$xD&p;tj)83AaDHLnE-?L*^}|@hJI7;vrode59>AC`!e46 z66TFB>Adl>w)q0y_&4C&XYs}_##+S_-e>;?EfH%Hu z-uUvzdE*nVxZe8NF1zfq%Pza@vdb>J?DE0oKPD?97$k(*6951J07*qoM6N<$g52iX AK>z>% literal 0 HcmV?d00001 diff --git a/tests/smoke/use/brand.test.ts b/tests/smoke/use/brand.test.ts new file mode 100644 index 00000000000..766f26ca410 --- /dev/null +++ b/tests/smoke/use/brand.test.ts @@ -0,0 +1,652 @@ +import { testQuartoCmd, ExecuteOutput, Verify } from "../../test.ts"; +import { fileExists, folderExists, noErrorsOrWarnings, printsMessage } from "../../verify.ts"; +import { join, fromFileUrl, dirname } from "../../../src/deno_ral/path.ts"; +import { ensureDirSync, existsSync } from "../../../src/deno_ral/fs.ts"; +import { pathWithForwardSlashes } from "../../../src/core/path.ts"; + +// Helper to verify files appear in the correct output sections +function filesInSections( + expected: { overwrite?: string[]; create?: string[]; remove?: string[] }, + dryRun: boolean +): Verify { + return { + name: "files in correct sections", + verify: (outputs: ExecuteOutput[]) => { + const overwriteHeader = dryRun ? "Would overwrite:" : "Overwritten:"; + const createHeader = dryRun ? "Would create:" : "Created:"; + const removeHeader = dryRun ? "Would remove:" : "Removed:"; + + const found: { overwrite: string[]; create: string[]; remove: string[] } = { + overwrite: [], + create: [], + remove: [], + }; + let currentSection: "overwrite" | "create" | "remove" | null = null; + + for (const output of outputs) { + const line = output.msg; + if (line.includes(overwriteHeader)) { + currentSection = "overwrite"; + } else if (line.includes(createHeader)) { + currentSection = "create"; + } else if (line.includes(removeHeader)) { + currentSection = "remove"; + } else if (currentSection && line.trim().startsWith("- ")) { + const filename = line.trim().slice(2); // remove "- " + // Normalize path separators for cross-platform compatibility + found[currentSection].push(pathWithForwardSlashes(filename)); + } + } + + // Verify expected files are in correct sections + for (const file of expected.overwrite ?? []) { + if (!found.overwrite.includes(pathWithForwardSlashes(file))) { + throw new Error(`Expected ${file} in overwrite section, found: [${found.overwrite.join(", ")}]`); + } + } + for (const file of expected.create ?? []) { + if (!found.create.includes(pathWithForwardSlashes(file))) { + throw new Error(`Expected ${file} in create section, found: [${found.create.join(", ")}]`); + } + } + for (const file of expected.remove ?? []) { + if (!found.remove.includes(pathWithForwardSlashes(file))) { + throw new Error(`Expected ${file} in remove section, found: [${found.remove.join(", ")}]`); + } + } + return Promise.resolve(); + } + }; +} + +const tempDir = Deno.makeTempDirSync(); +const testDir = dirname(fromFileUrl(import.meta.url)); +const fixtureDir = join(testDir, "..", "use-brand"); + +// Scenario 1: Basic brand installation +const basicDir = join(tempDir, "basic"); +ensureDirSync(basicDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--force"], + [ + noErrorsOrWarnings, + folderExists(join(basicDir, "_brand")), + fileExists(join(basicDir, "_brand", "_brand.yml")), + fileExists(join(basicDir, "_brand", "logo.png")), + ], + { + setup: () => { + Deno.writeTextFileSync(join(basicDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => basicDir, + teardown: () => { + try { Deno.removeSync(basicDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - basic installation" +); + +// Scenario 2: Dry-run mode +const dryRunDir = join(tempDir, "dry-run"); +ensureDirSync(dryRunDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--dry-run"], + [ + noErrorsOrWarnings, + printsMessage({ level: "INFO", regex: /Would create directory/ }), + filesInSections({ create: ["_brand.yml", "logo.png"] }, true), + { + name: "_brand directory should not exist in dry-run mode", + verify: () => { + const brandDir = join(dryRunDir, "_brand"); + if (existsSync(brandDir)) { + throw new Error("_brand directory should not exist in dry-run mode"); + } + return Promise.resolve(); + } + } + ], + { + setup: () => { + Deno.writeTextFileSync(join(dryRunDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => dryRunDir, + teardown: () => { + try { Deno.removeSync(dryRunDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - dry-run mode" +); + +// Scenario 3: Force mode - overwrites existing, creates new, removes extra +const forceOverwriteDir = join(tempDir, "force-overwrite"); +ensureDirSync(forceOverwriteDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--force"], + [ + noErrorsOrWarnings, + // _brand.yml should be overwritten (exists in both) + { + name: "_brand.yml should be overwritten with new content", + verify: () => { + const content = Deno.readTextFileSync(join(forceOverwriteDir, "_brand", "_brand.yml")); + if (content.includes("Old Brand")) { + throw new Error("_brand.yml should have been overwritten"); + } + if (!content.includes("Basic Test Brand")) { + throw new Error("_brand.yml should contain new brand content"); + } + return Promise.resolve(); + } + }, + // logo.png should be created (not in target originally) + fileExists(join(forceOverwriteDir, "_brand", "logo.png")), + // unrelated.txt should be removed (not in source) + { + name: "unrelated.txt should be removed", + verify: () => { + if (existsSync(join(forceOverwriteDir, "_brand", "unrelated.txt"))) { + throw new Error("unrelated.txt should have been removed"); + } + return Promise.resolve(); + } + }, + // Verify output sections + filesInSections({ overwrite: ["_brand.yml"], create: ["logo.png"], remove: ["unrelated.txt"] }, false), + ], + { + setup: () => { + Deno.writeTextFileSync(join(forceOverwriteDir, "_quarto.yml"), "project:\n type: default\n"); + // Create existing _brand directory with files + const brandDir = join(forceOverwriteDir, "_brand"); + ensureDirSync(brandDir); + // This file exists in source - should be overwritten + Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old Brand\n"); + // This file does NOT exist in source - should be preserved + Deno.writeTextFileSync(join(brandDir, "unrelated.txt"), "keep me"); + return Promise.resolve(); + }, + cwd: () => forceOverwriteDir, + teardown: () => { + try { Deno.removeSync(forceOverwriteDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - force overwrites existing, creates new, removes extra" +); + +// Scenario 4: Dry-run reports "Would overwrite" vs "Would create" vs "Would remove" correctly +const dryRunOverwriteDir = join(tempDir, "dry-run-overwrite"); +ensureDirSync(dryRunOverwriteDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--dry-run"], + [ + noErrorsOrWarnings, + // _brand.yml exists - should be in overwrite section + // logo.png doesn't exist - should be in create section + // extra.txt exists only in target - should be in remove section + filesInSections({ + overwrite: ["_brand.yml"], + create: ["logo.png"], + remove: ["extra.txt"] + }, true), + // Verify _brand.yml was NOT modified + { + name: "_brand.yml should not be modified in dry-run", + verify: () => { + const content = Deno.readTextFileSync(join(dryRunOverwriteDir, "_brand", "_brand.yml")); + if (!content.includes("Old Brand")) { + throw new Error("_brand.yml should not be modified in dry-run mode"); + } + return Promise.resolve(); + } + }, + // Verify logo.png was NOT created + { + name: "logo.png should not be created in dry-run", + verify: () => { + if (existsSync(join(dryRunOverwriteDir, "_brand", "logo.png"))) { + throw new Error("logo.png should not be created in dry-run mode"); + } + return Promise.resolve(); + } + }, + // Verify extra.txt was NOT removed + { + name: "extra.txt should not be removed in dry-run", + verify: () => { + if (!existsSync(join(dryRunOverwriteDir, "_brand", "extra.txt"))) { + throw new Error("extra.txt should not be removed in dry-run mode"); + } + return Promise.resolve(); + } + }, + ], + { + setup: () => { + Deno.writeTextFileSync(join(dryRunOverwriteDir, "_quarto.yml"), "project:\n type: default\n"); + // Create existing _brand directory with _brand.yml and extra.txt (not logo.png) + const brandDir = join(dryRunOverwriteDir, "_brand"); + ensureDirSync(brandDir); + Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old Brand\n"); + Deno.writeTextFileSync(join(brandDir, "extra.txt"), "extra file not in source"); + return Promise.resolve(); + }, + cwd: () => dryRunOverwriteDir, + teardown: () => { + try { Deno.removeSync(dryRunOverwriteDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - dry-run reports overwrite vs create vs remove correctly" +); + +// Scenario 5: Error - force and dry-run together +const errorFlagDir = join(tempDir, "error-flags"); +ensureDirSync(errorFlagDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--force", "--dry-run"], + [ + printsMessage({ level: "ERROR", regex: /Cannot use --force and --dry-run together/ }), + ], + { + setup: () => { + Deno.writeTextFileSync(join(errorFlagDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => errorFlagDir, + teardown: () => { + try { Deno.removeSync(errorFlagDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - error on --force --dry-run" +); + +// Scenario 6: Multi-file brand installation +const multiFileDir = join(tempDir, "multi-file"); +ensureDirSync(multiFileDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "multi-file-brand"), "--force"], + [ + noErrorsOrWarnings, + folderExists(join(multiFileDir, "_brand")), + fileExists(join(multiFileDir, "_brand", "_brand.yml")), + fileExists(join(multiFileDir, "_brand", "logo.png")), + fileExists(join(multiFileDir, "_brand", "favicon.png")), + ], + { + setup: () => { + Deno.writeTextFileSync(join(multiFileDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => multiFileDir, + teardown: () => { + try { Deno.removeSync(multiFileDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - multi-file installation" +); + +// Scenario 7: Nested directory structure preserved +const nestedDir = join(tempDir, "nested"); +ensureDirSync(nestedDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "nested-brand"), "--force"], + [ + noErrorsOrWarnings, + folderExists(join(nestedDir, "_brand")), + fileExists(join(nestedDir, "_brand", "_brand.yml")), + folderExists(join(nestedDir, "_brand", "images")), + fileExists(join(nestedDir, "_brand", "images", "logo.png")), + fileExists(join(nestedDir, "_brand", "images", "header.png")), + ], + { + setup: () => { + Deno.writeTextFileSync(join(nestedDir, "_quarto.yml"), "project:\n type: default\n"); + return Promise.resolve(); + }, + cwd: () => nestedDir, + teardown: () => { + try { Deno.removeSync(nestedDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - nested directory structure" +); + +// Scenario 8: Error - no project directory +const noProjectDir = join(tempDir, "no-project"); +ensureDirSync(noProjectDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--force"], + [ + printsMessage({ level: "ERROR", regex: /Could not find project dir/ }), + ], + { + setup: () => { + // No _quarto.yml created - this should cause an error + return Promise.resolve(); + }, + cwd: () => noProjectDir, + teardown: () => { + try { Deno.removeSync(noProjectDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - error on no project" +); + +// Scenario 9: Nested directory - overwrite files in subdirectories, remove extra +const nestedOverwriteDir = join(tempDir, "nested-overwrite"); +ensureDirSync(nestedOverwriteDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "nested-brand"), "--force"], + [ + noErrorsOrWarnings, + // images/logo.png should be overwritten (exists in both) + { + name: "images/logo.png should be overwritten", + verify: () => { + const stats = Deno.statSync(join(nestedOverwriteDir, "_brand", "images", "logo.png")); + // Original was 10 bytes ("old logo\n"), new one is 1862 bytes + if (stats.size < 100) { + throw new Error("images/logo.png should have been overwritten with larger file"); + } + return Promise.resolve(); + } + }, + // images/header.png should be created (not in target originally) + fileExists(join(nestedOverwriteDir, "_brand", "images", "header.png")), + // images/unrelated.png should be removed (not in source) + { + name: "images/unrelated.png should be removed", + verify: () => { + if (existsSync(join(nestedOverwriteDir, "_brand", "images", "unrelated.png"))) { + throw new Error("images/unrelated.png should have been removed"); + } + return Promise.resolve(); + } + }, + // Verify output sections (_brand.yml is created since not in target setup) + filesInSections({ + overwrite: ["images/logo.png"], + create: ["_brand.yml", "images/header.png"], + remove: ["images/unrelated.png"] + }, false), + ], + { + setup: () => { + Deno.writeTextFileSync(join(nestedOverwriteDir, "_quarto.yml"), "project:\n type: default\n"); + // Create existing _brand/images directory with files + const imagesDir = join(nestedOverwriteDir, "_brand", "images"); + ensureDirSync(imagesDir); + // This file exists in source - should be overwritten + Deno.writeTextFileSync(join(imagesDir, "logo.png"), "old logo\n"); + // This file does NOT exist in source - should be preserved + Deno.writeTextFileSync(join(imagesDir, "unrelated.png"), "keep me nested"); + return Promise.resolve(); + }, + cwd: () => nestedOverwriteDir, + teardown: () => { + try { Deno.removeSync(nestedOverwriteDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - nested overwrite, create, remove in subdirectories" +); + +// Scenario 10: Dry-run with nested directories - reports correctly +const dryRunNestedDir = join(tempDir, "dry-run-nested"); +ensureDirSync(dryRunNestedDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "nested-brand"), "--dry-run"], + [ + noErrorsOrWarnings, + // images/logo.png and _brand.yml exist - should be in overwrite section + // images/header.png doesn't exist - should be in create section + filesInSections({ + overwrite: ["_brand.yml", "images/logo.png"], + create: ["images/header.png"] + }, true), + // Verify images/logo.png was NOT modified + { + name: "images/logo.png should not be modified in dry-run", + verify: () => { + const content = Deno.readTextFileSync(join(dryRunNestedDir, "_brand", "images", "logo.png")); + if (content !== "old logo\n") { + throw new Error("images/logo.png should not be modified in dry-run mode"); + } + return Promise.resolve(); + } + }, + // Verify images/header.png was NOT created + { + name: "images/header.png should not be created in dry-run", + verify: () => { + if (existsSync(join(dryRunNestedDir, "_brand", "images", "header.png"))) { + throw new Error("images/header.png should not be created in dry-run mode"); + } + return Promise.resolve(); + } + }, + ], + { + setup: () => { + Deno.writeTextFileSync(join(dryRunNestedDir, "_quarto.yml"), "project:\n type: default\n"); + // Create existing _brand/images directory with only logo.png (not header.png) + const imagesDir = join(dryRunNestedDir, "_brand", "images"); + ensureDirSync(imagesDir); + Deno.writeTextFileSync(join(imagesDir, "logo.png"), "old logo\n"); + // Also create _brand.yml so we're only testing nested behavior + Deno.writeTextFileSync(join(dryRunNestedDir, "_brand", "_brand.yml"), "meta:\n name: Old\n"); + return Promise.resolve(); + }, + cwd: () => dryRunNestedDir, + teardown: () => { + try { Deno.removeSync(dryRunNestedDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - dry-run reports nested overwrite vs create correctly" +); + +// Scenario 11: Nested directory created when doesn't exist +const nestedNewSubdirDir = join(tempDir, "nested-new-subdir"); +ensureDirSync(nestedNewSubdirDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "nested-brand"), "--force"], + [ + noErrorsOrWarnings, + // _brand/ exists but images/ doesn't - should be created + folderExists(join(nestedNewSubdirDir, "_brand", "images")), + fileExists(join(nestedNewSubdirDir, "_brand", "images", "logo.png")), + fileExists(join(nestedNewSubdirDir, "_brand", "images", "header.png")), + // existing file at root should be overwritten + { + name: "_brand.yml should be overwritten", + verify: () => { + const content = Deno.readTextFileSync(join(nestedNewSubdirDir, "_brand", "_brand.yml")); + if (content.includes("Old Brand")) { + throw new Error("_brand.yml should have been overwritten"); + } + return Promise.resolve(); + } + }, + ], + { + setup: () => { + Deno.writeTextFileSync(join(nestedNewSubdirDir, "_quarto.yml"), "project:\n type: default\n"); + // Create _brand/ but NOT images/ subdirectory + const brandDir = join(nestedNewSubdirDir, "_brand"); + ensureDirSync(brandDir); + Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old Brand\n"); + return Promise.resolve(); + }, + cwd: () => nestedNewSubdirDir, + teardown: () => { + try { Deno.removeSync(nestedNewSubdirDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - creates nested subdirectory when _brand exists but subdir doesn't" +); + +// Scenario 12: Dry-run reports new subdirectory creation +const dryRunNewSubdirDir = join(tempDir, "dry-run-new-subdir"); +ensureDirSync(dryRunNewSubdirDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "nested-brand"), "--dry-run"], + [ + noErrorsOrWarnings, + // Should NOT report "Would create directory" for _brand/ (already exists) + printsMessage({ level: "INFO", regex: /Would create directory/, negate: true }), + // _brand.yml exists - should be in overwrite section + // images/* files don't exist - should be in create section + filesInSections({ + overwrite: ["_brand.yml"], + create: ["images/logo.png", "images/header.png"] + }, true), + // Verify images/ directory was NOT created + { + name: "images/ directory should not be created in dry-run", + verify: () => { + if (existsSync(join(dryRunNewSubdirDir, "_brand", "images"))) { + throw new Error("images/ directory should not be created in dry-run mode"); + } + return Promise.resolve(); + } + }, + ], + { + setup: () => { + Deno.writeTextFileSync(join(dryRunNewSubdirDir, "_quarto.yml"), "project:\n type: default\n"); + // Create _brand/ but NOT images/ subdirectory + const brandDir = join(dryRunNewSubdirDir, "_brand"); + ensureDirSync(brandDir); + Deno.writeTextFileSync(join(brandDir, "_brand.yml"), "meta:\n name: Old\n"); + return Promise.resolve(); + }, + cwd: () => dryRunNewSubdirDir, + teardown: () => { + try { Deno.removeSync(dryRunNewSubdirDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - dry-run when _brand exists but nested subdir doesn't" +); + +// Scenario 13: Empty directories are cleaned up after file removal +const emptyDirCleanupDir = join(tempDir, "empty-dir-cleanup"); +ensureDirSync(emptyDirCleanupDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--force"], + [ + noErrorsOrWarnings, + // extras/orphan.txt should be removed + { + name: "extras/orphan.txt should be removed", + verify: () => { + if (existsSync(join(emptyDirCleanupDir, "_brand", "extras", "orphan.txt"))) { + throw new Error("extras/orphan.txt should have been removed"); + } + return Promise.resolve(); + } + }, + // extras/ directory should be cleaned up (was empty after removal) + { + name: "extras/ directory should be cleaned up", + verify: () => { + if (existsSync(join(emptyDirCleanupDir, "_brand", "extras"))) { + throw new Error("extras/ directory should have been cleaned up"); + } + return Promise.resolve(); + } + }, + // Verify output shows removal + filesInSections({ remove: ["extras/orphan.txt"] }, false), + ], + { + setup: () => { + Deno.writeTextFileSync(join(emptyDirCleanupDir, "_quarto.yml"), "project:\n type: default\n"); + // Create _brand/extras/ with a file not in source + const extrasDir = join(emptyDirCleanupDir, "_brand", "extras"); + ensureDirSync(extrasDir); + Deno.writeTextFileSync(join(extrasDir, "orphan.txt"), "this file will be removed"); + return Promise.resolve(); + }, + cwd: () => emptyDirCleanupDir, + teardown: () => { + try { Deno.removeSync(emptyDirCleanupDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - empty directories cleaned up after file removal" +); + +// Scenario 14: Deeply nested directories are recursively cleaned up +const deepNestedCleanupDir = join(tempDir, "deep-nested-cleanup"); +ensureDirSync(deepNestedCleanupDir); +testQuartoCmd( + "use", + ["brand", join(fixtureDir, "basic-brand"), "--force"], + [ + noErrorsOrWarnings, + // deep/nested/path/orphan.txt should be removed + { + name: "deep/nested/path/orphan.txt should be removed", + verify: () => { + if (existsSync(join(deepNestedCleanupDir, "_brand", "deep", "nested", "path", "orphan.txt"))) { + throw new Error("deep/nested/path/orphan.txt should have been removed"); + } + return Promise.resolve(); + } + }, + // All empty parent directories should be cleaned up recursively + { + name: "deep/ directory tree should be fully cleaned up", + verify: () => { + if (existsSync(join(deepNestedCleanupDir, "_brand", "deep"))) { + throw new Error("deep/ directory should have been cleaned up recursively"); + } + return Promise.resolve(); + } + }, + // Verify output shows removal with full path + filesInSections({ remove: ["deep/nested/path/orphan.txt"] }, false), + ], + { + setup: () => { + Deno.writeTextFileSync(join(deepNestedCleanupDir, "_quarto.yml"), "project:\n type: default\n"); + // Create _brand/deep/nested/path/ with a file not in source + const deepDir = join(deepNestedCleanupDir, "_brand", "deep", "nested", "path"); + ensureDirSync(deepDir); + Deno.writeTextFileSync(join(deepDir, "orphan.txt"), "deeply nested orphan"); + return Promise.resolve(); + }, + cwd: () => deepNestedCleanupDir, + teardown: () => { + try { Deno.removeSync(deepNestedCleanupDir, { recursive: true }); } catch { /* ignore */ } + return Promise.resolve(); + } + }, + "quarto use brand - deeply nested directories recursively cleaned up" +); From 913fe544e838e45f727a97c514876f2581f382ba Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Wed, 24 Dec 2025 17:34:13 -0500 Subject: [PATCH 2/3] claude: Add tool to analyze CLI command history from git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tools/subcommand-history.ts | 570 ++++++++++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 tools/subcommand-history.ts diff --git a/tools/subcommand-history.ts b/tools/subcommand-history.ts new file mode 100644 index 00000000000..48d8a07f377 --- /dev/null +++ b/tools/subcommand-history.ts @@ -0,0 +1,570 @@ +/** + * subcommand-history.ts + * + * Analyzes git history of a cliffy-based CLI to produce a timeline + * of when commands/subcommands were introduced and removed. + * + * Usage: quarto run tools/subcommand-history.ts + * + * ## What this tool detects + * + * 1. **Top-level commands** - directories under `src/command/` (e.g., `render/`, `publish/`) + * 2. **Cliffy subcommands** - registered via `.command()` API + * 3. **Publish providers** - directories under `src/publish/` (e.g., `netlify/`, `gh-pages/`) + * + * ## What this tool cannot detect + * + * Commands that parse arguments internally rather than using cliffy's subcommand system. + * + * **Cliffy `.command()` registration** (detected): + * ```typescript + * // In call/cmd.ts + * export const callCommand = new Command() + * .command("engine", engineCommand) // ← Creates "quarto call engine" + * .command("build-ts-extension", ...) // ← Creates "quarto call build-ts-extension" + * ``` + * Cliffy handles routing to these subcommands automatically. + * + * **Internal argument parsing** (not detected): + * ```typescript + * // In install/cmd.ts + * export const installCommand = new Command() + * .arguments("[target...]") // ← Just takes arguments + * .action(async (options, ...target) => { + * if (target === "tinytex") { ... } // ← Code checks the value manually + * if (target === "chromium") { ... } + * }); + * ``` + * Here `tinytex` isn't a registered subcommand - it's just an argument value the code + * checks for. + * + * Both look the same to users (`quarto call engine` vs `quarto install tinytex`), but + * they're implemented differently. This tool can only detect the first pattern by + * searching for `.command("..."` in the code. + */ + +import { join, relative } from "https://deno.land/std/path/mod.ts"; + +interface CommandEntry { + date: string; + hash: string; + command: string; + message: string; + removed?: boolean; + parent?: string; // for subcommands, the parent command name +} + +// Execute git command and return stdout +async function runGit(args: string[], cwd?: string): Promise { + const cmd = new Deno.Command("git", { + args, + cwd, + stdout: "piped", + stderr: "piped", + }); + const { stdout, stderr, success } = await cmd.output(); + if (!success) { + const errText = new TextDecoder().decode(stderr); + throw new Error(`git ${args.join(" ")} failed: ${errText}`); + } + return new TextDecoder().decode(stdout).trim(); +} + +// Find the git repository root +async function findGitRoot(): Promise { + return await runGit(["rev-parse", "--show-toplevel"]); +} + +// Parse git log output line: "YYYY-MM-DD hash message" +function parseGitLogLine(line: string): { date: string; hash: string; message: string } | null { + const match = line.match(/^(\d{4}-\d{2}-\d{2})\s+([a-f0-9]+)\s+(.*)$/); + if (!match) return null; + return { date: match[1], hash: match[2], message: match[3] }; +} + +// Find when a directory was first added to git +async function findDirectoryIntroduction( + dirPath: string, + gitRoot: string +): Promise { + const relPath = relative(gitRoot, dirPath); + try { + // Get the oldest commit that added files to this directory + const output = await runGit( + ["log", "--diff-filter=A", "--format=%as %h %s", "--reverse", "--", relPath], + gitRoot + ); + const lines = output.split("\n").filter((l) => l.trim()); + if (lines.length === 0) return null; + + const parsed = parseGitLogLine(lines[0]); + if (!parsed) return null; + + const commandName = dirPath.split("/").pop() || ""; + return { + date: parsed.date, + hash: parsed.hash, + command: commandName, + message: parsed.message, + }; + } catch { + return null; + } +} + +// Find when a string pattern was introduced (oldest commit containing it) +async function findStringIntroduction( + searchStr: string, + path: string, + gitRoot: string +): Promise<{ date: string; hash: string; message: string } | null> { + const relPath = relative(gitRoot, path); + try { + // -S finds commits where the string count changed (added or removed) + // --reverse gives oldest first + const output = await runGit( + ["log", "-S", searchStr, "--format=%as %h %s", "--reverse", "--", relPath], + gitRoot + ); + const lines = output.split("\n").filter((l) => l.trim()); + if (lines.length === 0) return null; + + return parseGitLogLine(lines[0]); + } catch { + return null; + } +} + +// Find when a string pattern was removed (most recent commit where it was removed) +async function findStringRemoval( + searchStr: string, + path: string, + gitRoot: string +): Promise<{ date: string; hash: string; message: string } | null> { + const relPath = relative(gitRoot, path); + try { + // Get most recent commit that changed this string (without --reverse, newest first) + const output = await runGit( + ["log", "-S", searchStr, "--format=%as %h %s", "--", relPath], + gitRoot + ); + const lines = output.split("\n").filter((l) => l.trim()); + if (lines.length === 0) return null; + + // The most recent commit is the removal + return parseGitLogLine(lines[0]); + } catch { + return null; + } +} + +// Cliffy built-in commands that are inherited by all commands +const CLIFFY_BUILTINS = new Set(["help", "completions"]); + +// Extract command names from .command("name" patterns +function extractCliffyCommandNames(content: string): string[] { + const regex = /\.command\s*\(\s*["']([^"']+)["']/g; + const names: string[] = []; + let match; + while ((match = regex.exec(content)) !== null) { + // Extract just the command name (before any space for arguments like "install ") + const fullName = match[1]; + const cmdName = fullName.split(/\s+/)[0]; + // Skip cliffy built-in commands + if (!CLIFFY_BUILTINS.has(cmdName)) { + names.push(cmdName); + } + } + return names; +} + +// Get all directories in a path +async function getDirectories(path: string): Promise { + const dirs: string[] = []; + try { + for await (const entry of Deno.readDir(path)) { + if (entry.isDirectory) { + dirs.push(entry.name); + } + } + } catch { + // Directory doesn't exist + } + return dirs.sort(); +} + +// Scan for top-level commands (directories in src/command/) +async function scanTopLevelCommands( + commandDir: string, + gitRoot: string +): Promise { + const entries: CommandEntry[] = []; + const dirs = await getDirectories(commandDir); + + for (const dir of dirs) { + const dirPath = join(commandDir, dir); + const entry = await findDirectoryIntroduction(dirPath, gitRoot); + if (entry) { + entries.push(entry); + } + } + + return entries.sort((a, b) => a.date.localeCompare(b.date)); +} + +// Read all TypeScript files in a directory recursively +async function readTsFiles(dir: string): Promise> { + const files = new Map(); + + async function walk(currentDir: string) { + try { + for await (const entry of Deno.readDir(currentDir)) { + const fullPath = join(currentDir, entry.name); + if (entry.isDirectory) { + await walk(fullPath); + } else if (entry.name.endsWith(".ts")) { + try { + const content = await Deno.readTextFile(fullPath); + files.set(fullPath, content); + } catch { + // Skip unreadable files + } + } + } + } catch { + // Skip unreadable directories + } + } + + await walk(dir); + return files; +} + +// Scan for cliffy subcommands in command directories +async function scanCliffySubcommands( + commandDir: string, + gitRoot: string +): Promise> { + const subcommandsByParent = new Map(); + const topLevelDirs = await getDirectories(commandDir); + + for (const parentCmd of topLevelDirs) { + const parentDir = join(commandDir, parentCmd); + const tsFiles = await readTsFiles(parentDir); + + // Collect all .command() names in this parent's directory + const commandNames = new Set(); + for (const [filePath, content] of tsFiles) { + const names = extractCliffyCommandNames(content); + for (const name of names) { + commandNames.add(name); + } + } + + if (commandNames.size === 0) continue; + + const entries: CommandEntry[] = []; + for (const cmdName of commandNames) { + // Search for when this .command("name" was introduced + const searchStr = `.command("${cmdName}`; + const intro = await findStringIntroduction(searchStr, parentDir, gitRoot); + + // Also try single quotes + if (!intro) { + const searchStrSingle = `.command('${cmdName}`; + const introSingle = await findStringIntroduction(searchStrSingle, parentDir, gitRoot); + if (introSingle) { + entries.push({ + date: introSingle.date, + hash: introSingle.hash, + command: `quarto ${parentCmd} ${cmdName}`, + message: introSingle.message, + parent: parentCmd, + }); + } + } else { + entries.push({ + date: intro.date, + hash: intro.hash, + command: `quarto ${parentCmd} ${cmdName}`, + message: intro.message, + parent: parentCmd, + }); + } + } + + if (entries.length > 0) { + entries.sort((a, b) => a.date.localeCompare(b.date)); + subcommandsByParent.set(parentCmd, entries); + } + } + + return subcommandsByParent; +} + +// Search git history for .command() patterns that no longer exist in HEAD +async function scanRemovedCommands( + commandDir: string, + gitRoot: string +): Promise { + const removed: CommandEntry[] = []; + const relCommandDir = relative(gitRoot, commandDir); + + // Get all .command("X") patterns that ever existed in git history + // Using git log -p to see actual diff content + try { + const output = await runGit( + ["log", "-p", "--all", "-S", '.command("', "--", relCommandDir], + gitRoot + ); + + // Extract all command names from the diff output (lines starting with +) + const historicalCommands = new Set(); + const addedPattern = /^\+.*\.command\s*\(\s*["']([^"']+)["']/gm; + let match; + while ((match = addedPattern.exec(output)) !== null) { + const cmdName = match[1].split(/\s+/)[0]; + // Skip cliffy built-in commands + if (!CLIFFY_BUILTINS.has(cmdName)) { + historicalCommands.add(cmdName); + } + } + + // Get current commands in HEAD + const currentCommands = new Set(); + const tsFiles = await readTsFiles(commandDir); + for (const [_, content] of tsFiles) { + const names = extractCliffyCommandNames(content); + for (const name of names) { + currentCommands.add(name); + } + } + + // Find commands that existed historically but not now + for (const cmd of historicalCommands) { + if (!currentCommands.has(cmd)) { + // Find when it was removed (most recent commit that touched this pattern) + const searchStr = `.command("${cmd}`; + const removal = await findStringRemoval(searchStr, commandDir, gitRoot); + if (removal) { + // Try to figure out the parent command from the git diff context + // For now, just use the command name + removed.push({ + date: removal.date, + hash: removal.hash, + command: `quarto ??? ${cmd}`, + message: removal.message, + removed: true, + }); + } + } + } + } catch (e) { + console.error("Error scanning for removed commands:", e); + } + + return removed.sort((a, b) => a.date.localeCompare(b.date)); +} + +// Format entries as markdown table with aligned columns +function formatTable( + entries: CommandEntry[], + columns: ("date" | "hash" | "command" | "message")[] +): string { + if (entries.length === 0) return "_No entries found_\n"; + + const headers: Record = { + date: "Date", + hash: "Hash", + command: "Command", + message: "Commit Message", + }; + + // Build all cell values first + const allRows: string[][] = []; + + // Header row + allRows.push(columns.map((c) => headers[c])); + + // Data rows + for (const e of entries) { + const row = columns.map((c) => { + if (c === "command" && e.removed) { + return `~~${e[c]}~~`; + } + return e[c] || ""; + }); + allRows.push(row); + } + + // Calculate max width for each column + const colWidths = columns.map((_, i) => + Math.max(...allRows.map((row) => row[i].length)) + ); + + // Format header row with padding + const headerRow = + "| " + + columns.map((c, i) => headers[c].padEnd(colWidths[i])).join(" | ") + + " |"; + + // Format separator row with dashes + const separatorRow = + "|" + colWidths.map((w) => "-".repeat(w + 2)).join("|") + "|"; + + // Format data rows with padding + const dataRows = entries.map((e) => { + const values = columns.map((c, i) => { + let val: string; + if (c === "command" && e.removed) { + val = `~~${e[c]}~~`; + } else { + val = e[c] || ""; + } + return val.padEnd(colWidths[i]); + }); + return "| " + values.join(" | ") + " |"; + }); + + return [headerRow, separatorRow, ...dataRows].join("\n") + "\n"; +} + +// Scan publish providers (directories in src/publish/) +async function scanPublishProviders( + publishDir: string, + gitRoot: string +): Promise { + const entries: CommandEntry[] = []; + const dirs = await getDirectories(publishDir); + + // Exclude non-provider directories/files + const excludeDirs = new Set(["common"]); + + for (const dir of dirs) { + if (excludeDirs.has(dir)) continue; + + const dirPath = join(publishDir, dir); + const entry = await findDirectoryIntroduction(dirPath, gitRoot); + if (entry) { + entries.push({ + ...entry, + command: `quarto publish ${dir}`, + parent: "publish", + }); + } + } + + return entries.sort((a, b) => a.date.localeCompare(b.date)); +} + +// Find removed publish providers by checking git history +async function scanRemovedPublishProviders( + publishDir: string, + gitRoot: string +): Promise { + const removed: CommandEntry[] = []; + const relPublishDir = relative(gitRoot, publishDir); + + try { + // Get all directories that ever existed in src/publish/ + const output = await runGit( + ["log", "--all", "--name-status", "--diff-filter=D", "--", relPublishDir], + gitRoot + ); + + // Extract deleted directory names + const deletedDirs = new Set(); + const deletePattern = /^D\s+src\/publish\/([^/]+)\//gm; + let match; + while ((match = deletePattern.exec(output)) !== null) { + const dir = match[1]; + if (dir !== "common") { + deletedDirs.add(dir); + } + } + + // Get current providers + const currentDirs = new Set(await getDirectories(publishDir)); + + // Find providers that were deleted + for (const dir of deletedDirs) { + if (!currentDirs.has(dir)) { + // Find when it was removed + const searchStr = `src/publish/${dir}/`; + const removalOutput = await runGit( + ["log", "--diff-filter=D", "--format=%as %h %s", "-1", "--", searchStr], + gitRoot + ); + const parsed = parseGitLogLine(removalOutput); + if (parsed) { + removed.push({ + date: parsed.date, + hash: parsed.hash, + command: `quarto publish ${dir}`, + message: parsed.message, + removed: true, + parent: "publish", + }); + } + } + } + } catch (e) { + console.error("Error scanning for removed publish providers:", e); + } + + return removed.sort((a, b) => a.date.localeCompare(b.date)); +} + +// Main function +async function main() { + const gitRoot = await findGitRoot(); + const commandDir = join(gitRoot, "src/command"); + const publishDir = join(gitRoot, "src/publish"); + + console.log("# Quarto CLI Command History\n"); + console.log("_Generated by analyzing git history of cliffy `.command()` registrations and directory structure._\n"); + + // 1. Top-level commands + console.log("## Top-Level Commands (`quarto `)\n"); + const topLevel = await scanTopLevelCommands(commandDir, gitRoot); + console.log(formatTable(topLevel, ["date", "hash", "command", "message"])); + + // 2. Subcommands grouped by parent + console.log("\n## Subcommands (`quarto `)\n"); + console.log("_Note: Only subcommands registered via cliffy's `.command()` API are tracked. Commands that parse their arguments internally (e.g., `quarto install tinytex`, `quarto check jupyter`) are not detected._\n"); + const subcommands = await scanCliffySubcommands(commandDir, gitRoot); + + // Add publish providers as subcommands + const publishProviders = await scanPublishProviders(publishDir, gitRoot); + if (publishProviders.length > 0) { + subcommands.set("publish", publishProviders); + } + + // Sort parent commands alphabetically + const sortedParents = [...subcommands.keys()].sort(); + for (const parent of sortedParents) { + const entries = subcommands.get(parent)!; + console.log(`### ${parent}\n`); + console.log(formatTable(entries, ["date", "hash", "command"])); + console.log(""); + } + + // 3. Removed commands + console.log("\n## Removed Commands\n"); + const removedCliffy = await scanRemovedCommands(commandDir, gitRoot); + const removedPublish = await scanRemovedPublishProviders(publishDir, gitRoot); + const allRemoved = [...removedCliffy, ...removedPublish].sort((a, b) => + a.date.localeCompare(b.date) + ); + + if (allRemoved.length > 0) { + console.log(formatTable(allRemoved, ["date", "hash", "command", "message"])); + } else { + console.log("_No removed commands detected._\n"); + } +} + +main().catch((e) => { + console.error("Error:", e.message); + Deno.exit(1); +}); From 4bc7b38911b5aa1bc38c386fbbe19d2446f8cc0c Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Wed, 24 Dec 2025 18:10:46 -0500 Subject: [PATCH 3/3] changelog --- news/changelog-1.9.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index fc729be0e8d..752babea7a0 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -11,7 +11,6 @@ All changes included in 1.9: - ([#13633](https://github.com/quarto-dev/quarto-cli/issues/13633)): Fix detection and auto-installation of babel language packages from newer error format that doesn't explicitly mention `.ldf` filename. - ([#13694](https://github.com/quarto-dev/quarto-cli/issues/13694)): Fix `notebook-view.url` being ignored - external notebook links now properly use specified URLs instead of local preview files. - ([#13732](https://github.com/quarto-dev/quarto-cli/issues/13732)): Fix automatic font package installation for fonts with spaces in their names (e.g., "Noto Emoji", "DejaVu Sans"). Font file search patterns now match both with and without spaces. -- ([#13798](https://github.com/quarto-dev/quarto-cli/pull/13798)): Directories specified in `ExecutionEngineDiscovery.ignoreDirs` were not getting ignored. ## Dependencies @@ -19,12 +18,6 @@ All changes included in 1.9: - Update `deno` to 2.4.5 - ([#13601](https://github.com/quarto-dev/quarto-cli/pull/13601)): Update `mermaid` to 11.12.0 (author: @multimeric) -## Extensions - -- Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory. - -- New **Engine Extensions**, to allow other execution engines than knitr, jupyter, julia. Julia is now a bundled extension. See [the prerelease notes](https://prerelease.quarto.org/docs/prerelease/1.9/) and [engine extension documentation](https://prerelease.quarto.org/docs/extensions/engine.html). - ## Formats ### `gfm` @@ -78,7 +71,7 @@ All changes included in 1.9: - ([#10031](https://github.com/quarto-dev/quarto-cli/issues/10031)): Fix manuscript rendering prompting for GitHub credentials when origin points to private repository. Auto-detection of manuscript URL now fails gracefully with a warning instead of blocking renders. -## `publish` +## Publishing ### Confluence @@ -88,6 +81,22 @@ All changes included in 1.9: - ([#13762](https://github.com/quarto-dev/quarto-cli/issues/13762)): Add `quarto.paths.typst()` to Quarto's Lua API to resolve Typst binary path in Lua filters and extensions consistently with Quarto itself. (author: @mcanouil) +## Commands + +### `use brand` + +- ([#13828](https://github.com/quarto-dev/quarto-cli/pull/13828)): New `quarto use brand` command copies and synchronizes the `_brand/` directory from a repo, directory, or ZIP file. See [the prerelease documentation](https://prerelease.quarto.org/docs/authoring/brand.html#quarto-use-brand) for details. + +### `call build-ts-extension` + +- (): New `quarto call build-ts-extension` command builds a TypeScript extension, such as an engine extension, and places the artifacts in the `_extensions` directory. See the [engine extension pre-release documentation](https://prerelease.quarto.org/docs/extensions/engine.html) for details. + +## Extensions + +- Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory. + +- New **Engine Extensions**, to allow other execution engines than knitr, jupyter, julia. Julia is now a bundled extension. See [the prerelease notes](https://prerelease.quarto.org/docs/prerelease/1.9/) and [engine extension documentation](https://prerelease.quarto.org/docs/extensions/engine.html). + ## Other fixes and improvements - ([#13402](https://github.com/quarto-dev/quarto-cli/issues/13402)): `nfpm` () is now used to create the `.deb` package, and new `.rpm` package. Both Linux packages are also now built for `x86_64` (`amd64`) and `aarch64` (`arm64`) architectures.