From 9953c16bd60351f3a7772aa22e3e1ac88cc1ae9a Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 11:22:01 -0400 Subject: [PATCH 01/10] ensure filter paths are resolved relative to project --- src/command/render/filters.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index 1081b5fc6e..4c22305566 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -85,7 +85,7 @@ import { quartoConfig } from "../../core/quarto.ts"; import { metadataNormalizationFilterActive } from "./normalize.ts"; import { kCodeAnnotations } from "../../format/html/format-html-shared.ts"; import { projectOutputDir } from "../../project/project-shared.ts"; -import { relative } from "../../deno_ral/path.ts"; +import { join, relative } from "../../deno_ral/path.ts"; import { citeIndexFilterParams } from "../../project/project-cites.ts"; import { debug } from "../../deno_ral/log.ts"; import { kJatsSubarticle } from "../../format/jats/format-jats-types.ts"; @@ -746,14 +746,17 @@ export async function resolveFilters( .filter((f) => f !== "quarto") // remove quarto marker .map((filter, i) => { if (isFilterEntryPoint(filter)) { - return filter; // send entry-point-style filters unchanged + return { + ...filter, + path: join(options.project.dir, filter.path), + }; // send entry-point-style filters unchanged } const at = quartoLoc > i ? kQuartoPre : kQuartoPost; const result: QuartoFilterEntryPoint = typeof filter === "string" ? { "at": at, "type": filter.endsWith(".lua") ? "lua" : "json", - "path": filter, + "path": join(options.project.dir, filter), } : { "at": at, From 8d41d30e15be4a1bb2cbb05604c0d6e370f5e5d7 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 11:22:54 -0400 Subject: [PATCH 02/10] regressiont test --- .../04/08/fix-filter-project-paths/.gitignore | 1 + .../08/fix-filter-project-paths/_quarto.yml | 21 +++++++++++++++++++ .../04/08/fix-filter-project-paths/about.qmd | 5 +++++ .../04/08/fix-filter-project-paths/index.qmd | 9 ++++++++ .../04/08/fix-filter-project-paths/root.lua | 4 ++++ .../04/08/fix-filter-project-paths/styles.css | 1 + 6 files changed, 41 insertions(+) create mode 100644 tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/.gitignore create mode 100644 tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/_quarto.yml create mode 100644 tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/about.qmd create mode 100644 tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/index.qmd create mode 100644 tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/root.lua create mode 100644 tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/styles.css diff --git a/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/.gitignore b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/.gitignore new file mode 100644 index 0000000000..075b2542af --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/.gitignore @@ -0,0 +1 @@ +/.quarto/ diff --git a/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/_quarto.yml b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/_quarto.yml new file mode 100644 index 0000000000..e8cfb5f72c --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/_quarto.yml @@ -0,0 +1,21 @@ +project: + type: website + +website: + title: "fix-filter-project-paths" + navbar: + left: + - href: index.qmd + text: Home + - about.qmd + +format: + html: + theme: + - cosmo + - brand + css: styles.css + toc: true + + + diff --git a/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/about.qmd b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/about.qmd new file mode 100644 index 0000000000..07c5e7f9d1 --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/about.qmd @@ -0,0 +1,5 @@ +--- +title: "About" +--- + +About this site diff --git a/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/index.qmd b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/index.qmd new file mode 100644 index 0000000000..7b8e67ae3a --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/index.qmd @@ -0,0 +1,9 @@ +--- +title: "fix-filter-project-paths" +filters: + - /root.lua +--- + +This is a Quarto website. + +To learn more about Quarto websites visit . diff --git a/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/root.lua b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/root.lua new file mode 100644 index 0000000000..d20aba2d1a --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/root.lua @@ -0,0 +1,4 @@ +function Pandoc(doc) + -- we just need to make sure this runs + return doc +end \ No newline at end of file diff --git a/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/styles.css b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/styles.css new file mode 100644 index 0000000000..2ddf50c7b4 --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/fix-filter-project-paths/styles.css @@ -0,0 +1 @@ +/* css styles */ From 28e2cf10c95779629548dc59bf6cc4f9ca7bdef1 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 13:13:07 -0400 Subject: [PATCH 03/10] carry path type information throughout extension resolution --- src/command/render/filters.ts | 217 +++++++++++++++++++++++----------- src/config/types.ts | 21 +++- src/extension/extension.ts | 2 +- 3 files changed, 166 insertions(+), 74 deletions(-) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index 4c22305566..d4e8a40354 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -62,6 +62,8 @@ import { isFilterEntryPoint, QuartoFilter, QuartoFilterEntryPoint, + QuartoFilterEntryPointQualified, + QuartoFilterEntryPointQualifiedFull, } from "../../config/types.ts"; import { QuartoFilterSpec } from "./types.ts"; import { Metadata } from "../../config/types.ts"; @@ -85,7 +87,7 @@ import { quartoConfig } from "../../core/quarto.ts"; import { metadataNormalizationFilterActive } from "./normalize.ts"; import { kCodeAnnotations } from "../../format/html/format-html-shared.ts"; import { projectOutputDir } from "../../project/project-shared.ts"; -import { join, relative } from "../../deno_ral/path.ts"; +import { extname, join, relative, resolve } from "../../deno_ral/path.ts"; import { citeIndexFilterParams } from "../../project/project-cites.ts"; import { debug } from "../../deno_ral/log.ts"; import { kJatsSubarticle } from "../../format/jats/format-jats-types.ts"; @@ -95,6 +97,7 @@ import { pythonExec } from "../../core/jupyter/exec.ts"; import { kTocIndent } from "../../config/constants.ts"; import { isWindows } from "../../deno_ral/platform.ts"; import { tinyTexBinDir } from "../../tools/impl/tinytex-info.ts"; +import { warn } from "log"; const kQuartoParams = "quarto-params"; @@ -716,7 +719,7 @@ const kQuartoCiteProcMarker = "citeproc"; // NB: this mutates `pandoc.citeproc` export async function resolveFilters( - filters: QuartoFilter[], + filtersParam: QuartoFilter[], options: PandocOptions, pandoc: FormatPandoc, ): Promise { @@ -729,8 +732,10 @@ export async function resolveFilters( quartoFilters.push(quartoMainFilter()); // Resolve any filters that are provided by an extension - filters = await resolveFilterExtension(options, filters); - let quartoLoc = filters.findIndex((filter) => filter === kQuartoFilterMarker); + const filters = await resolveFilterExtension(options, filtersParam); + let quartoLoc = filters.findIndex((filter) => + filter.type === kQuartoFilterMarker + ); if (quartoLoc === -1) { quartoLoc = Infinity; // if no quarto marker, put our filters at the beginning } @@ -742,31 +747,28 @@ export async function resolveFilters( // if 'quarto' is not in the filter, all declarations go to the kQuartoPre entry point // // (note that citeproc will in all cases run last) - const entryPoints: QuartoFilterEntryPoint[] = filters - .filter((f) => f !== "quarto") // remove quarto marker + + // citeproc at the very end so all other filters can interact with citations + + // remove special filter markers + const fullFilters = filters.filter((filter) => + filter.type !== kQuartoCiteProcMarker && filter.type !== kQuartoFilterMarker + ) as QuartoFilterEntryPointQualifiedFull[]; + + const entryPoints: QuartoFilterEntryPoint[] = fullFilters .map((filter, i) => { - if (isFilterEntryPoint(filter)) { - return { - ...filter, - path: join(options.project.dir, filter.path), - }; // send entry-point-style filters unchanged - } - const at = quartoLoc > i ? kQuartoPre : kQuartoPost; - const result: QuartoFilterEntryPoint = typeof filter === "string" - ? { - "at": at, - "type": filter.endsWith(".lua") ? "lua" : "json", - "path": join(options.project.dir, filter), - } - : { - "at": at, - ...filter, - }; + const at = filter.at === "__quarto-auto" + ? (quartoLoc > i ? kQuartoPre : kQuartoPost) + : filter.at; + + const result: QuartoFilterEntryPoint = { + "at": at, + "type": filter.type, + "path": filter.path.path, + }; return result; }); - // citeproc at the very end so all other filters can interact with citations - filters = filters.filter((filter) => filter !== kQuartoCiteProcMarker); const citeproc = citeMethod(options) === kQuartoCiteProcMarker; if (citeproc) { // If we're explicitely adding the citeproc filter, turn off @@ -847,60 +849,133 @@ function pdfEngine(options: PandocOptions): string { return pdfEngine; } +// Resolve any filters that are provided by an extension async function resolveFilterExtension( options: PandocOptions, filters: QuartoFilter[], -): Promise { - // Resolve any filters that are provided by an extension - const results: (QuartoFilter | QuartoFilter[])[] = []; - const getFilter = async (filter: QuartoFilter) => { - // Look for extension names in the filter list and result them - // into the filters provided by the extension - if ( - filter !== kQuartoFilterMarker && filter !== kQuartoCiteProcMarker && - typeof filter === "string" - ) { - // The filter string points to an executable file which exists - if (existsSync(filter) && !Deno.statSync(filter).isDirectory) { - return filter; +): Promise { + const results: + (QuartoFilterEntryPointQualified | QuartoFilterEntryPointQualified[])[] = + []; + + // Look for extension names in the filter list and result them + // into the filters provided by the extension + const getFilter = async ( + filter: QuartoFilter, + ): Promise< + QuartoFilterEntryPointQualified | QuartoFilterEntryPointQualified[] + > => { + if (filter === kQuartoFilterMarker || filter === kQuartoCiteProcMarker) { + return { type: filter }; + } + if (typeof filter !== "string") { + // deno-lint-ignore no-explicit-any + if ((filter as any).at) { + const entryPoint = filter as QuartoFilterEntryPoint; + return { + ...entryPoint, + path: { + type: "relative", + path: entryPoint.path, + }, + }; + } else { + return { + at: "__quarto-auto", + type: filter.type, + path: { + type: "relative", + path: filter.path, + }, + }; } + } - const extensions = await options.services.extension?.find( - filter, - options.source, - "filters", - options.project?.config, - options.project?.dir, - ) || []; - - // Filter this list of extensions - const filteredExtensions = filterExtensions( - extensions || [], - filter, - "filter", + // The filter string points to an executable file which exists + if (existsSync(filter) && !Deno.statSync(filter).isDirectory) { + const type = extname(filter) !== "lua" ? "json" : "lua"; + return { + at: "__quarto-auto", + type, + path: { + type: "absolute", + path: resolve(filter), + }, + }; + } + + const extensions = await options.services.extension?.find( + filter, + options.source, + "filters", + options.project?.config, + options.project?.dir, + ) || []; + + if (extensions.length === 0) { + // There were no extensions matching this name, + // this should not happen + // + // Previously, we allowed it to pass, we're warning now and dropping + warn( + `No extensions matching name but filter (${filter}) is a string that isn't an existing path or quarto or citeproc. Ignoring`, ); - // Return any contributed plugins - if (filteredExtensions.length > 0) { - // This matches an extension, use the contributed filters - const filters = extensions[0].contributes.filters; - if (filters) { - return filters; - } else { - return filter; - } - } else if (extensions.length > 0) { - // There was a matching extension with this name, but - // it was filtered out, just hide the filter altogether - return []; - } else { - // There were no extensions matching this name, just allow it - // through - return filter; - } - } else { - return filter; + return []; } + + // Filter this list of extensions + const filteredExtensions = filterExtensions( + extensions || [], + filter, + "filter", + ); + + if (filteredExtensions.length === 0) { + // There was a matching extension with this name, but + // it was filtered out, just hide the filter altogether + return []; + } + + // Return any contributed plugins + // This matches an extension, use the contributed filters + const filters = extensions[0].contributes.filters; + if (!filters) { + warn( + `No extensions matching name but filter (${filter}) is a string that isn't an existing path or quarto or citeproc. Ignoring`, + ); + return []; + } + + // our extension-finding service returns absolute paths + // so any paths below will be "type": "absolute" + // and need no conversion + + return filters.map((f) => { + if (typeof f === "string") { + const isExistingFile = existsSync(f) && !Deno.statSync(f).isDirectory; + const type = (isExistingFile && extname(f) !== ".lua") ? "json" : "lua"; + return { + at: "__quarto-auto", + type, + path: { + type: "absolute", + path: f, + }, + }; + } + + return { + ...f, + // deno-lint-ignore no-explicit-any + at: (f as any).at ?? "__quarto-auto", + path: { + type: "absolute", + path: f.path, + }, + }; + }); }; + for (const filter of filters) { const r = await getFilter(filter); results.push(r); diff --git a/src/config/types.ts b/src/config/types.ts index 8754526782..2a10678b3e 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -277,6 +277,11 @@ export interface FormatDependency { resources?: DependencyFile[]; } +type QualifiedPath = { + path: string; + type: "relative" | "project" | "absolute"; +}; + export interface DependencyFile { name: string; path: string; @@ -350,7 +355,19 @@ export type PandocFilter = { path: string; }; -export type QuartoFilterEntryPoint = PandocFilter & { "at": string }; +export type QuartoFilterEntryPoint = PandocFilter & { at: string }; + +export type QuartoFilterEntryPointQualifiedFull = { + type: "json" | "lua"; + at: string; + path: QualifiedPath; +}; +export type QuartoFilterSpecialEntryPoint = { + type: "citeproc" | "quarto"; +}; +export type QuartoFilterEntryPointQualified = + | QuartoFilterEntryPointQualifiedFull + | QuartoFilterSpecialEntryPoint; export type QuartoFilter = string | PandocFilter | QuartoFilterEntryPoint; @@ -457,7 +474,7 @@ export interface Format { export interface LightDarkBrand { [kLight]?: Brand; - [kDark]?: Brand + [kDark]?: Brand; } export interface FormatRender { diff --git a/src/extension/extension.ts b/src/extension/extension.ts index c9d3650cf9..e045c1354a 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -230,7 +230,7 @@ export function filterExtensions( extensionId: string, type: string, ) { - if (extensions && extensions.length > 0) { + if (extensions.length > 0) { // First see if there are now built it (quarto organization) // filters that we previously provided by quarto-ext and // filter those out From 5cfe6e027fc72c9ef929f21b5b03ad2e735499b7 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 13:24:14 -0400 Subject: [PATCH 04/10] include period in extension string test --- src/command/render/filters.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index d4e8a40354..ab5da2e74b 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -87,7 +87,7 @@ import { quartoConfig } from "../../core/quarto.ts"; import { metadataNormalizationFilterActive } from "./normalize.ts"; import { kCodeAnnotations } from "../../format/html/format-html-shared.ts"; import { projectOutputDir } from "../../project/project-shared.ts"; -import { extname, join, relative, resolve } from "../../deno_ral/path.ts"; +import { extname, relative, resolve } from "../../deno_ral/path.ts"; import { citeIndexFilterParams } from "../../project/project-cites.ts"; import { debug } from "../../deno_ral/log.ts"; import { kJatsSubarticle } from "../../format/jats/format-jats-types.ts"; @@ -891,9 +891,9 @@ async function resolveFilterExtension( } } - // The filter string points to an executable file which exists + // The filter string points to a file which exists if (existsSync(filter) && !Deno.statSync(filter).isDirectory) { - const type = extname(filter) !== "lua" ? "json" : "lua"; + const type = extname(filter) !== ".lua" ? "json" : "lua"; return { at: "__quarto-auto", type, From 43a5cc5016671cef6e267290a41c48450582b86a Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 13:55:16 -0400 Subject: [PATCH 05/10] preserve and resolve relative, project, and absolute paths properly --- src/command/render/filters.ts | 66 ++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index ab5da2e74b..51b5826edd 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -87,7 +87,7 @@ import { quartoConfig } from "../../core/quarto.ts"; import { metadataNormalizationFilterActive } from "./normalize.ts"; import { kCodeAnnotations } from "../../format/html/format-html-shared.ts"; import { projectOutputDir } from "../../project/project-shared.ts"; -import { extname, relative, resolve } from "../../deno_ral/path.ts"; +import { dirname, extname, relative, resolve } from "../../deno_ral/path.ts"; import { citeIndexFilterParams } from "../../project/project-cites.ts"; import { debug } from "../../deno_ral/log.ts"; import { kJatsSubarticle } from "../../format/jats/format-jats-types.ts"; @@ -755,6 +755,17 @@ export async function resolveFilters( filter.type !== kQuartoCiteProcMarker && filter.type !== kQuartoFilterMarker ) as QuartoFilterEntryPointQualifiedFull[]; + const resolvePath = (filter: QuartoFilterEntryPointQualifiedFull["path"]) => { + switch (filter.type) { + case "absolute": + return filter.path; + case "relative": + return resolve(dirname(options.source), filter.path); + case "project": + return resolve(options.project.dir, filter.path); + } + }; + const entryPoints: QuartoFilterEntryPoint[] = fullFilters .map((filter, i) => { const at = filter.at === "__quarto-auto" @@ -764,7 +775,7 @@ export async function resolveFilters( const result: QuartoFilterEntryPoint = { "at": at, "type": filter.type, - "path": filter.path.path, + "path": resolvePath(filter.path), }; return result; }); @@ -869,24 +880,24 @@ async function resolveFilterExtension( return { type: filter }; } if (typeof filter !== "string") { + const fileType: "project" | "relative" = + extname(filter.path).startsWith("/") ? "project" : "relative"; + const path = { + type: fileType, + path: filter.path, + }; // deno-lint-ignore no-explicit-any if ((filter as any).at) { const entryPoint = filter as QuartoFilterEntryPoint; return { ...entryPoint, - path: { - type: "relative", - path: entryPoint.path, - }, + path, }; } else { return { at: "__quarto-auto", type: filter.type, - path: { - type: "relative", - path: filter.path, - }, + path, }; } } @@ -912,15 +923,31 @@ async function resolveFilterExtension( options.project?.dir, ) || []; + const fallthroughResult = () => { + const filterType: "json" | "lua" = extname(filter) !== ".lua" + ? "json" + : "lua"; + const pathType: "project" | "relative" = filter.startsWith("/") + ? "project" + : "relative"; + + return { + at: "__quarto-auto", + type: filterType, + path: { + type: pathType, + path: filter, + }, + }; + }; + if (extensions.length === 0) { // There were no extensions matching this name, - // this should not happen - // - // Previously, we allowed it to pass, we're warning now and dropping - warn( - `No extensions matching name but filter (${filter}) is a string that isn't an existing path or quarto or citeproc. Ignoring`, - ); - return []; + // but the filter is a string that isn't an existing path + // this indicates that the filter is meant to be interpreted + // as a project- or file-relative path + + return fallthroughResult(); } // Filter this list of extensions @@ -940,10 +967,7 @@ async function resolveFilterExtension( // This matches an extension, use the contributed filters const filters = extensions[0].contributes.filters; if (!filters) { - warn( - `No extensions matching name but filter (${filter}) is a string that isn't an existing path or quarto or citeproc. Ignoring`, - ); - return []; + return fallthroughResult(); } // our extension-finding service returns absolute paths From 79b7b635999434d6f419404d9d30947d8f4e5052 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 13:58:34 -0400 Subject: [PATCH 06/10] more path fixes --- src/command/render/filters.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index 51b5826edd..40b5375761 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -59,7 +59,6 @@ import { PandocOptions } from "./types.ts"; import { Format, FormatPandoc, - isFilterEntryPoint, QuartoFilter, QuartoFilterEntryPoint, QuartoFilterEntryPointQualified, @@ -87,7 +86,13 @@ import { quartoConfig } from "../../core/quarto.ts"; import { metadataNormalizationFilterActive } from "./normalize.ts"; import { kCodeAnnotations } from "../../format/html/format-html-shared.ts"; import { projectOutputDir } from "../../project/project-shared.ts"; -import { dirname, extname, relative, resolve } from "../../deno_ral/path.ts"; +import { + dirname, + extname, + join, + relative, + resolve, +} from "../../deno_ral/path.ts"; import { citeIndexFilterParams } from "../../project/project-cites.ts"; import { debug } from "../../deno_ral/log.ts"; import { kJatsSubarticle } from "../../format/jats/format-jats-types.ts"; @@ -762,7 +767,7 @@ export async function resolveFilters( case "relative": return resolve(dirname(options.source), filter.path); case "project": - return resolve(options.project.dir, filter.path); + return resolve(join(options.project.dir, filter.path)); } }; @@ -880,8 +885,9 @@ async function resolveFilterExtension( return { type: filter }; } if (typeof filter !== "string") { - const fileType: "project" | "relative" = - extname(filter.path).startsWith("/") ? "project" : "relative"; + const fileType: "project" | "relative" = filter.path.startsWith("/") + ? "project" + : "relative"; const path = { type: fileType, path: filter.path, From 3980cfbf6385d7de9e27c32d8e045ca7c1fcb316 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 14:11:15 -0400 Subject: [PATCH 07/10] resolve project-relative shortcodes --- src/command/render/filters.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index 40b5375761..67f209deb1 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -607,7 +607,11 @@ async function quartoFilterParams( } const shortcodes = format.render[kShortcodes]; if (shortcodes !== undefined) { - params[kShortcodes] = shortcodes; + params[kShortcodes] = shortcodes.map((p) => { + if (p.startsWith("/")) { + return resolve(join(options.project.dir, p)); + } + }); } const extShortcodes = await extensionShortcodes(options); if (extShortcodes) { From 3bc84439dd2eaaf93070f9099ce0afd67d8defaa Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 14:13:00 -0400 Subject: [PATCH 08/10] regression test --- .../2025/04/08/project-shortcode-paths/_quarto.yml | 0 .../2025/04/08/project-shortcode-paths/shortcode.lua | 5 +++++ .../04/08/project-shortcode-paths/subdir/hello.qmd | 10 ++++++++++ 3 files changed, 15 insertions(+) create mode 100644 tests/docs/smoke-all/2025/04/08/project-shortcode-paths/_quarto.yml create mode 100644 tests/docs/smoke-all/2025/04/08/project-shortcode-paths/shortcode.lua create mode 100644 tests/docs/smoke-all/2025/04/08/project-shortcode-paths/subdir/hello.qmd diff --git a/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/_quarto.yml b/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/_quarto.yml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/shortcode.lua b/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/shortcode.lua new file mode 100644 index 0000000000..e4753eec53 --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/shortcode.lua @@ -0,0 +1,5 @@ +return { + ['my-shortcode'] = function(args, kwargs, meta) + return pandoc.Str("Hello from Shorty!") + end + } \ No newline at end of file diff --git a/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/subdir/hello.qmd b/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/subdir/hello.qmd new file mode 100644 index 0000000000..87eeca199f --- /dev/null +++ b/tests/docs/smoke-all/2025/04/08/project-shortcode-paths/subdir/hello.qmd @@ -0,0 +1,10 @@ +--- +title: "Hello" +format: html +shortcodes: + - /shortcode.lua +--- + +# Hello, world! + +{{< my-shortcode >}} From 7f65fdaf97287efd9d6ad8209703ceaf10ae0e66 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 14:24:26 -0400 Subject: [PATCH 09/10] fix else condition --- src/command/render/filters.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index 67f209deb1..5ec3876de3 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -610,6 +610,8 @@ async function quartoFilterParams( params[kShortcodes] = shortcodes.map((p) => { if (p.startsWith("/")) { return resolve(join(options.project.dir, p)); + } else { + return p; } }); } From 6b7bb1fdf7d3303ce75c66a9c2dbd86af47c37d4 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Tue, 8 Apr 2025 16:20:25 -0400 Subject: [PATCH 10/10] extensions return fully-qualified filter declarations to avoid double-appending project paths --- src/command/preview/preview.ts | 37 ++++- src/command/render/filters.ts | 45 +++--- src/command/render/pandoc.ts | 11 +- src/config/types.ts | 24 ++- src/core/qualified-path.ts | 257 +++------------------------------ src/extension/extension.ts | 59 ++++++-- 6 files changed, 159 insertions(+), 274 deletions(-) diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 6c8bdab0e3..f07df8a39f 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -57,7 +57,11 @@ import { renderFormats } from "../render/render-contexts.ts"; import { renderResultFinalOutput } from "../render/render.ts"; import { replacePandocArg } from "../render/flags.ts"; -import { Format, isPandocFilter } from "../../config/types.ts"; +import { + Format, + isPandocFilter, + isQuartoFilterEntryPointQualifiedFull, +} from "../../config/types.ts"; import { kPdfJsInitialPath, pdfJsBaseDir, @@ -467,17 +471,34 @@ export async function renderForPreview( extensionFiles.push(...renderResult.files.reduce( (extensionFiles: string[], file: RenderResultFile) => { const shortcodes = file.format.render.shortcodes || []; - const filters = (file.format.pandoc.filters || []).map((filter) => - isPandocFilter(filter) ? filter.path : filter - ); + const filters = (file.format.pandoc.filters || []).map((filter) => { + if (isPandocFilter(filter)) { + return filter.path; + } + if (isQuartoFilterEntryPointQualifiedFull(filter)) { + switch (filter.path.type) { + case "absolute": + return filter.path.path; + case "relative": + return join(dirname(file.input), filter.path.path); + case "project-relative": + return join( + project?.dir ?? dirname(file.input), + filter.path.path, + ); + } + } + return filter; + }); const ipynbFilters = file.format.execute["ipynb-filters"] || []; [...shortcodes, ...filters.map((filter) => filter), ...ipynbFilters] .forEach((extensionFile) => { if (!isAbsolute(extensionFile)) { - const extensionFullPath = join(dirname(file.input), extensionFile); - if (existsSync(extensionFullPath)) { - extensionFiles.push(normalizePath(extensionFullPath)); - } + extensionFile = join(dirname(file.input), extensionFile); + } + // const extensionFullPath = join(dirname(file.input), extensionFile); + if (existsSync(extensionFile)) { + extensionFiles.push(normalizePath(extensionFile)); } }); return extensionFiles; diff --git a/src/command/render/filters.ts b/src/command/render/filters.ts index 5ec3876de3..33ae6bc0e8 100644 --- a/src/command/render/filters.ts +++ b/src/command/render/filters.ts @@ -65,7 +65,7 @@ import { QuartoFilterEntryPointQualifiedFull, } from "../../config/types.ts"; import { QuartoFilterSpec } from "./types.ts"; -import { Metadata } from "../../config/types.ts"; +import { Metadata, QualifiedPath } from "../../config/types.ts"; import { kProjectType } from "../../project/types.ts"; import { bibEngine } from "../../config/pdf.ts"; import { rBinaryPath, resourcePath } from "../../core/resources.ts"; @@ -772,7 +772,7 @@ export async function resolveFilters( return filter.path; case "relative": return resolve(dirname(options.source), filter.path); - case "project": + case "project-relative": return resolve(join(options.project.dir, filter.path)); } }; @@ -891,13 +891,18 @@ async function resolveFilterExtension( return { type: filter }; } if (typeof filter !== "string") { - const fileType: "project" | "relative" = filter.path.startsWith("/") - ? "project" - : "relative"; - const path = { - type: fileType, - path: filter.path, - }; + const path: QualifiedPath = (() => { + if (typeof filter.path !== "string") { + const result = filter.path; + return result; + } + const fileType: "project-relative" | "relative" = + filter.path.startsWith("/") ? "project-relative" : "relative"; + return { + type: fileType, + path: filter.path, + }; + })(); // deno-lint-ignore no-explicit-any if ((filter as any).at) { const entryPoint = filter as QuartoFilterEntryPoint; @@ -939,8 +944,8 @@ async function resolveFilterExtension( const filterType: "json" | "lua" = extname(filter) !== ".lua" ? "json" : "lua"; - const pathType: "project" | "relative" = filter.startsWith("/") - ? "project" + const pathType: "project-relative" | "relative" = filter.startsWith("/") + ? "project-relative" : "relative"; return { @@ -999,15 +1004,21 @@ async function resolveFilterExtension( }, }; } - + if (typeof f.path === "string") { + return { + ...f, + // deno-lint-ignore no-explicit-any + at: (f as any).at ?? "__quarto-auto", + path: { + type: "absolute", + path: f.path, + }, + }; + } return { ...f, - // deno-lint-ignore no-explicit-any at: (f as any).at ?? "__quarto-auto", - path: { - type: "absolute", - path: f.path, - }, + path: f.path, }; }); }; diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index 158e271402..8919e2ad1f 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -207,6 +207,7 @@ import { isWindows } from "../../deno_ral/platform.ts"; import { appendToCombinedLuaProfile } from "../../core/performance/perfetto-utils.ts"; import { makeTimedFunctionAsync } from "../../core/performance/function-times.ts"; import { walkJson } from "../../core/json.ts"; +import { asRawPath } from "../../core/qualified-path.ts"; // in case we are running multiple pandoc processes // we need to make sure we capture all of the trace files @@ -919,11 +920,19 @@ export async function runPandoc( allDefaults.filters = allDefaults.filters.map((filter) => { if (typeof filter === "string") { return pandocMetadataPath(filter); - } else { + } else if (typeof filter.path === "string") { return { type: filter.type, path: pandocMetadataPath(filter.path), }; + } else { + return { + type: filter.type, + path: pandocMetadataPath(asRawPath(filter.path, { + projectRoot: options.project.dir, + currentFileDir: dirname(options.source), + })), + }; } }); diff --git a/src/config/types.ts b/src/config/types.ts index 2a10678b3e..f765049fdc 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -277,11 +277,17 @@ export interface FormatDependency { resources?: DependencyFile[]; } -type QualifiedPath = { +export type PathType = "project-relative" | "relative" | "absolute"; + +type BasePath = { path: string; - type: "relative" | "project" | "absolute"; }; +export type QualifiedPath = BasePath & { type: PathType }; +export type AbsolutePath = BasePath & { type: "absolute" }; +export type RelativePath = BasePath & { type: "relative" }; +export type ProjectRelativePath = BasePath & { type: "project-relative" }; + export interface DependencyFile { name: string; path: string; @@ -369,10 +375,20 @@ export type QuartoFilterEntryPointQualified = | QuartoFilterEntryPointQualifiedFull | QuartoFilterSpecialEntryPoint; -export type QuartoFilter = string | PandocFilter | QuartoFilterEntryPoint; +export type QuartoFilter = + | string + | PandocFilter + | QuartoFilterEntryPoint + | QuartoFilterEntryPointQualifiedFull; export function isPandocFilter(filter: QuartoFilter): filter is PandocFilter { - return ( filter).path !== undefined; + return typeof ( filter).path === "string"; +} + +export function isQuartoFilterEntryPointQualifiedFull( + filter: QuartoFilter, +): filter is QuartoFilterEntryPointQualifiedFull { + return typeof ( filter).path !== "string"; } export function isFilterEntryPoint( diff --git a/src/core/qualified-path.ts b/src/core/qualified-path.ts index f391acdad9..3a3bbb1d1e 100644 --- a/src/core/qualified-path.ts +++ b/src/core/qualified-path.ts @@ -6,7 +6,13 @@ * Copyright (C) 2022 Posit Software, PBC */ -import { join, relative, resolve } from "../deno_ral/path.ts"; +import { + AbsolutePath, + ProjectRelativePath, + QualifiedPath, + RelativePath, +} from "../config/types.ts"; +import { join } from "../deno_ral/path.ts"; import { UnreachableError } from "./lib/error.ts"; export class InvalidPathError extends Error { @@ -20,23 +26,21 @@ export type PathInfo = { currentFileDir: string; }; -export type PathType = "project-relative" | "relative" | "absolute"; - -type BasePath = { - value: string; - asAbsolute: (info?: PathInfo) => AbsolutePath; - asRelative: (info?: PathInfo) => RelativePath; - asProjectRelative: (info?: PathInfo) => ProjectRelativePath; +export const asRawPath = (path: QualifiedPath, info: PathInfo): string => { + switch (path.type) { + case "absolute": + return path.path; + case "relative": + return join(info.currentFileDir, path.path); + case "project-relative": + return join(info.projectRoot, path.path); + default: + throw new UnreachableError(); + } }; -export type QualifiedPath = BasePath & { type?: PathType }; -export type AbsolutePath = BasePath & { type: "absolute" }; -export type RelativePath = BasePath & { type: "relative" }; -export type ProjectRelativePath = BasePath & { type: "project-relative" }; - export function makePath( path: string, - info?: PathInfo, forceAbsolute?: boolean, ): QualifiedPath { const type = path.startsWith("/") @@ -44,236 +48,23 @@ export function makePath( : "relative"; const result: QualifiedPath = { - value: path, + path: path, type, - asAbsolute(info?: PathInfo) { - return toAbsolutePath(this, info); - }, - asRelative(info?: PathInfo) { - return toRelativePath(this, info); - }, - asProjectRelative(info?: PathInfo) { - return toProjectRelativePath(this, info); - }, }; - // we call asAbsolute() at least once on each path so - // that the path is validated; this is simply - // so that exceptions can be raised appropriately. - const quartoPaths: PathInfo = resolvePathInfo(info); - result.asAbsolute(quartoPaths); - return result; } -export function readTextFile(t: QualifiedPath, options?: Deno.ReadFileOptions) { - return Deno.readTextFile(t.asAbsolute().value, options); -} - -export function readTextFileSync( - t: QualifiedPath, -) { - return Deno.readTextFileSync(t.asAbsolute().value); -} - -// validates an absolute path -function validate(value: string, quartoPaths: PathInfo): string { - if (!value.startsWith(quartoPaths.projectRoot)) { - throw new InvalidPathError( - "Paths cannot resolve outside of document or project root", - ); - } - return value; -} - -function toAbsolutePath( - path: QualifiedPath, - info?: PathInfo, -): AbsolutePath { - let value: string; - - if (isAbsolutePath(path)) { - return path; - } - - const quartoPaths: PathInfo = resolvePathInfo(info); - - switch (path.type) { - case "project-relative": - // project-relative -> absolute - value = resolve(join(quartoPaths.projectRoot, path.value)); - break; - case "relative": - // relative -> absolute - value = resolve(join(quartoPaths.currentFileDir, path.value)); - break; - default: - if (path.value.startsWith("/")) { - // project-relative -> absolute - value = resolve(join(quartoPaths.projectRoot, path.value)); - } else { - // relative -> absolute - value = resolve(join(quartoPaths.currentFileDir, path.value)); - } - } - value = validate(value, quartoPaths); - - return { - value, - type: "absolute", - asAbsolute(_info?: PathInfo) { - return this; - }, - asRelative(info?: PathInfo) { - return toRelativePath(this, info); - }, - asProjectRelative(info?: PathInfo) { - return toProjectRelativePath(this, info); - }, - }; -} - -function toRelativePath( - path: QualifiedPath, - info?: PathInfo, -): RelativePath { - let value: string; - - if (isRelativePath(path)) { - return path; - } - - const quartoPaths: PathInfo = resolvePathInfo(info); - - switch (path.type) { - case "absolute": - // absolute -> relative - value = relative(quartoPaths.currentFileDir, path.value); - break; - case "project-relative": { - // project-relative -> absolute -> relative - const absPath = validate( - resolve(join(quartoPaths.projectRoot, path.value)), - quartoPaths, - ); - value = relative( - quartoPaths.currentFileDir, - absPath, - ); - break; - } - default: - if (path.value.startsWith("/")) { - // project-relative -> absolute -> relative - const absPath = validate( - resolve(join(quartoPaths.projectRoot, path.value)), - quartoPaths, - ); - value = relative( - quartoPaths.currentFileDir, - absPath, - ); - } else { - throw new UnreachableError(); - } - } - - return { - value, - type: "relative", - asAbsolute(info?: PathInfo) { - return toAbsolutePath(this, info); - }, - asRelative(_info?: PathInfo) { - return this; - }, - asProjectRelative(info?: PathInfo) { - return toProjectRelativePath(this, info); - }, - }; -} - -function toProjectRelativePath( - path: QualifiedPath, - info?: PathInfo, -): ProjectRelativePath { - let value: string; - - if (isProjectRelativePath(path)) { - return path; - } - - const quartoPaths: PathInfo = resolvePathInfo(info); - - switch (path.type) { - case "absolute": - // absolute -> project-relative - value = `/${relative(quartoPaths.projectRoot, path.value)}`; - break; - case "relative": - // relative -> absolute -> project-relative - value = `/${ - relative( - quartoPaths.projectRoot, - validate( - resolve(join(quartoPaths.currentFileDir, path.value)), - quartoPaths, - ), - ) - }`; - break; - default: - if (!path.value.startsWith("/")) { - throw new UnreachableError(); - } else { - // relative -> absolute -> project-relative - value = `/${ - relative( - quartoPaths.projectRoot, - validate( - resolve(join(quartoPaths.currentFileDir, path.value)), - quartoPaths, - ), - ) - }`; - } - } - - return { - value, - type: "project-relative", - asAbsolute(info?: PathInfo) { - return toAbsolutePath(this, info); - }, - asProjectRelative(_info?: PathInfo) { - return this; - }, - asRelative(info?: PathInfo) { - return toRelativePath(this, info); - }, - }; -} - -function resolvePathInfo(path?: PathInfo): PathInfo { - if (path !== undefined) { - return path; - } - throw new Error("Unimplemented"); - // return {} as any; // FIXME this should get information from quarto's runtime. -} - -function isRelativePath(path: QualifiedPath): path is RelativePath { - return (path.type === "relative") || - (path.type === undefined && !path.value.startsWith("/")); +export function isRelativePath(path: QualifiedPath): path is RelativePath { + return path.type === "relative"; } -function isProjectRelativePath( +export function isProjectRelativePath( path: QualifiedPath, ): path is ProjectRelativePath { - return (path.type === "project-relative") || - (path.type === undefined && path.value.startsWith("/")); + return path.type === "project-relative"; } -function isAbsolutePath(path: QualifiedPath): path is AbsolutePath { +export function isAbsolutePath(path: QualifiedPath): path is AbsolutePath { return path.type === "absolute"; } diff --git a/src/extension/extension.ts b/src/extension/extension.ts index e045c1354a..7b60ad7419 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -18,6 +18,7 @@ import { import { dirname, + extname, isAbsolute, join, normalize, @@ -71,6 +72,7 @@ import { resourcePath } from "../core/resources.ts"; import { warnOnce } from "../core/log.ts"; import { existsSync1 } from "../core/file.ts"; import { kFormatResources } from "../config/constants.ts"; +import { assert } from "testing/asserts"; // This is where we maintain a list of extensions that have been promoted // to 'built-in' status. If we see these extensions, we will filter them @@ -757,7 +759,18 @@ async function readExtension( }); formatMeta.filters = (formatMeta.filters as QuartoFilter[] || []).flatMap( (filter) => { - return resolveFilter(embeddedExtensions, extensionDir, filter); + // that cast above is maybe invalid if filters are declared without 'type' + // deno-lint-ignore no-explicit-any + if (typeof filter === "object" && (filter as any).type === undefined) { + const unqualifiedPath = typeof filter.path === "string" + ? filter.path + : filter.path.path; + if (extname(unqualifiedPath) === ".lua") { + filter = { ...filter, type: "lua" }; + } + } + const result = resolveFilter(embeddedExtensions, extensionDir, filter); + return result; }, ); formatMeta[kRevealJSPlugins] = (formatMeta?.[kRevealJSPlugins] as Array< @@ -987,7 +1000,7 @@ function resolveFilter( // First check for the sentinel quarto filter, and allow that through // if it is present if (filter === "quarto") { - return filter; + return [filter]; } // First attempt to load this shortcode from an embedded extension @@ -1005,11 +1018,20 @@ function resolveFilter( return filters; } else { validateExtensionPath("filter", dir, filter); - return resolveFilterPath(dir, filter); + return [resolveFilterPath(dir, filter)]; } } else { - validateExtensionPath("filter", dir, filter.path); - return resolveFilterPath(dir, filter); + if (typeof filter.path === "string") { + validateExtensionPath("filter", dir, filter.path); + return [resolveFilterPath(dir, filter)]; + } else { + if (filter.path.type !== "relative") { + throw new Error( + `Failed to resolve referenced ${filter.path.path} - extensions can only declare fully-qualified paths of type "relative"`, + ); + } + return [resolveFilterPath(dir, filter.path.path)]; + } } } @@ -1027,12 +1049,27 @@ function resolveFilterPath( } else { // deno-lint-ignore no-explicit-any const filterAt = ((filter as any).at) as string | undefined; - const result: QuartoFilter = { - type: filter.type, - path: isAbsolute(filter.path) - ? filter.path - : join(extensionDir, filter.path), - }; + const result: QuartoFilter = typeof filter.path === "string" + ? { + type: filter.type, + at: "__quarto-auto", + path: { + type: "absolute", + path: isAbsolute(filter.path) + ? filter.path + : join(extensionDir, filter.path), + }, + } + : { + type: filter.type, + at: "__quarto-auto", + path: { + type: "absolute", + path: isAbsolute(filter.path.path) + ? filter.path.path + : join(extensionDir, filter.path.path), + }, + }; if (filterAt === undefined) { return result; } else {