Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
All changes included in 1.11:

## Commands

### `quarto preview`

- ([#14783](https://github.com/quarto-dev/quarto-cli/issues/14783)): Fix `quarto preview` ignoring project metadata contributed by an extension, such as `brand`. (author: @mcanouil)

## Engines

### `knitr`
Expand Down
88 changes: 75 additions & 13 deletions src/project/project-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,35 +107,91 @@ import { createProjectCache } from "../core/cache/cache.ts";
import { createTempContext } from "../core/temp.ts";

import { onCleanup } from "../core/cleanup.ts";
import { warning } from "../deno_ral/log.ts";
import { ZodError } from "zod";
import { extensionIdString } from "../extension/extension-shared.ts";
import { Extension } from "../extension/types.ts";
import { Zod } from "../resources/types/zod/schema-types.ts";
import { ExternalEngine } from "../resources/types/schema-types.ts";
import {
ExternalEngine,
ProjectConfig as ContributedProjectConfig,
} from "../resources/types/schema-types.ts";

// Turn a validation failure into one line that names the offending keys, so
// the caller can report it without dumping the raw validation error
const projectMetadataError = (extension: Extension, err: unknown) => {
const reason = err instanceof ZodError
? err.issues.map((issue) =>
issue.path.length
? `${issue.path.join(".")}: ${issue.message}`
: issue.message
).join("; ")
: err instanceof Error
? err.message
: String(err);
return new Error(
`The extension ${
extensionIdString(extension.id)
} contributes invalid project metadata (${reason}).`,
);
};

export const mergeExtensionMetadata = async (
const mergeExtensionMetadata = async (
context: ProjectContext,
pOptions: RenderOptions,
extensionContext: ExtensionContext,
// called with the extension that failed validation; it either throws to
// make the failure fatal, or returns to drop that one contribution
onInvalid: (err: Error) => void,
) => {
// this will mutate context.config.project to merge
// in any project metadata from extensions
if (context.config) {
const extensions = await pOptions.services.extension.extensions(
const extensions = await extensionContext.extensions(
undefined,
context.config,
context.dir,
{ builtIn: false },
);
// Handle project metadata extensions
const projectMetadata = extensions.filter((extension) =>
extension.contributes.metadata?.project
).map((extension) => {
return Zod.ProjectConfig.parse(extension.contributes.metadata!.project);
});
// Handle project metadata extensions, one by one, so that a single
// invalid extension does not discard what the others contribute
const projectMetadata: ContributedProjectConfig[] = [];
for (
const extension of extensions.filter((extension) =>
extension.contributes.metadata?.project
)
) {
try {
projectMetadata.push(
Zod.ProjectConfig.parse(extension.contributes.metadata!.project),
);
} catch (err) {
onInvalid(projectMetadataError(extension, err));
}
}
context.config.project = mergeProjectMetadata(
context.config.project,
...projectMetadata,
);
}
};

// Extension metadata is validated against a strict schema, so an invalid
// extension makes the merge fail. That is fatal for a render, as it always
// was, and elsewhere the contribution of that one extension is dropped with
// a warning, so commands such as preview and inspect keep working (#14783).
export const mergeExtensionMetadataForContext = async (
context: ProjectContext,
extensionContext: ExtensionContext,
fatal: boolean,
) => {
await mergeExtensionMetadata(context, extensionContext, (err) => {
if (fatal) {
throw err;
}
warning(`Ignoring the invalid project metadata. ${err.message}`);
});
};

export async function projectContext(
path: string,
notebookContext: NotebookContext,
Expand Down Expand Up @@ -176,10 +232,16 @@ export async function projectContext(
const returnResult = async (
context: ProjectContext,
) => {
if (renderOptions) {
await mergeExtensionMetadata(context, renderOptions);
}
// Register cleanup before merging: the merge validates extension metadata
// and can throw, and the context already holds an open disk cache
onCleanup(context.cleanup);
// Always merge extension metadata so contributions such as `brand` are
// seen even when called without renderOptions (e.g. from preview, #14783)
await mergeExtensionMetadataForContext(
context,
extensionContext,
renderOptions !== undefined,
);
return context;
};

Expand Down
64 changes: 41 additions & 23 deletions src/project/types/single-file/single-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { dirname } from "../../../deno_ral/path.ts";
import { warning } from "../../../deno_ral/log.ts";
import { warnOnce } from "../../../core/log.ts";
import { normalizePath } from "../../../core/path.ts";
import { NotebookContext } from "../../../render/notebook/notebook-types.ts";
import { makeProjectEnvironmentMemoizer } from "../../project-environment.ts";
Expand All @@ -32,7 +33,7 @@ import { createProjectCache } from "../../../core/cache/cache.ts";
import { globalTempContext } from "../../../core/temp.ts";
import { once } from "../../../core/once.ts";
import {
mergeExtensionMetadata,
mergeExtensionMetadataForContext,
resolveEngineExtensions,
} from "../../project-context.ts";
import { createExtensionContext } from "../../../extension/extension.ts";
Expand Down Expand Up @@ -105,32 +106,49 @@ export async function singleFileProjectContext(
result.dir,
);

if (renderOptions) {
// Merge extension metadata (requires renderOptions for full services)
await mergeExtensionMetadata(result, renderOptions);

// Check if extensions contributed output-dir metadata
// If so, set forceClean as if --output-dir specified on command line,
// to ensure proper cleanup
const outputDir = result.config?.project?.["output-dir"];
if (outputDir) {
const willForceClean = renderOptions.flags?.clean !== false;
warning(
`An extension contributed 'output-dir: ${outputDir}' metadata for single-file render.\n` +
`Output will go to that directory. The temporary .quarto directory will ${
willForceClean
? "be cleaned up"
: "NOT be cleaned up (--no-clean specified)"
} after rendering.\n` +
"To suppress this warning, use --output-dir flag instead of extension metadata.",
);
renderOptions.forceClean = willForceClean;
}
}
// because the single-file project is cleaned up with
// the global text context, we don't need to register it
// in the same way that we need to register the multi-file
// projects.
// This is registered before the merge below, because the merge validates
// extension metadata and can throw once the disk cache is already open.
temp.onCleanup(result.cleanup);

// Always merge extension metadata so contributions such as `brand` are
// seen even when called without renderOptions (e.g. from preview, #14783)
await mergeExtensionMetadataForContext(
result,
extensionContext,
renderOptions !== undefined,
);

// Warn whenever an extension contributed output-dir metadata, whichever
// command built this context, and on the render path also set forceClean
// as if --output-dir was given on the command line, to ensure proper cleanup
const outputDir = result.config?.project?.["output-dir"];
if (outputDir) {
const willForceClean = renderOptions
? renderOptions.flags?.clean !== false
: undefined;
const outcome = willForceClean === undefined
? "Output will go to that directory when the file is rendered."
: `Output will go to that directory. The temporary .quarto directory will ${
willForceClean
? "be cleaned up"
: "NOT be cleaned up (--no-clean specified)"
} after rendering.`;
const message =
`An extension contributed 'output-dir: ${outputDir}' metadata for a single file.\n` +
`${outcome}\n` +
"To suppress this warning, use --output-dir flag instead of extension metadata.";
if (renderOptions && willForceClean !== undefined) {
warning(message);
renderOptions.forceClean = willForceClean;
} else {
// inspect, publish and serve build a context repeatedly, and the user
// cannot act on this at that point, so say it once per process
warnOnce(message);
}
}
return result;
}
Loading
Loading