From db2fa15b4516a3e006c600ecc17fa32592a29586 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 1 Dec 2023 16:55:15 -0700 Subject: [PATCH] support includes in non-executable code blocks --- news/changelog-1.4.md | 1 + src/core/handlers/base.ts | 35 +++++++++ src/core/handlers/include-standalone.ts | 92 ++++++++++++++++++++++++ src/core/handlers/include.ts | 90 ++--------------------- tests/docs/smoke-all/2023/12/01/1237.qmd | 19 +++++ tests/docs/smoke-all/2023/12/01/file.r | 1 + 6 files changed, 155 insertions(+), 83 deletions(-) create mode 100644 src/core/handlers/include-standalone.ts create mode 100644 tests/docs/smoke-all/2023/12/01/1237.qmd create mode 100644 tests/docs/smoke-all/2023/12/01/file.r diff --git a/news/changelog-1.4.md b/news/changelog-1.4.md index e8445eca3a..33869e1969 100644 --- a/news/changelog-1.4.md +++ b/news/changelog-1.4.md @@ -268,6 +268,7 @@ - Exit if project pre or post render script fails - Support `--output-dir` for rendering individual files. - Use InternalError in typescript code, and offer a more helpful error message when an internal error happens. +- ([#1237](https://github.com/quarto-dev/quarto-cli/issues/1237)): Allow `include` shortcodes to be resolved from inside non-executable code cells and metadata blocks. - ([#1392](https://github.com/quarto-dev/quarto-cli/issues/1392)): Add tools and LaTeX information to `quarto check` output. - ([#2214](https://github.com/quarto-dev/quarto-cli/issues/2214), reopened): don't report a non-existing version of Google Chrome in macOS. - ([#4820](https://github.com/quarto-dev/quarto-cli/issues/4820)): Add support for setting the Giscus light/dark themes. diff --git a/src/core/handlers/base.ts b/src/core/handlers/base.ts index cab703dcd4..adebca583c 100644 --- a/src/core/handlers/base.ts +++ b/src/core/handlers/base.ts @@ -68,6 +68,8 @@ import { mappedStringFromFile } from "../mapped-text.ts"; import { error } from "log/mod.ts"; import { withCriClient } from "../cri/cri.ts"; import { normalizePath } from "../path.ts"; +import { isBlockShortcode } from "../lib/parse-shortcode.ts"; +import { standaloneInclude } from "./include-standalone.ts"; const handlers: Record = {}; @@ -311,6 +313,34 @@ export function install(handler: LanguageHandler) { } } +const processMarkdownIncludes = async ( + newCells: MappedString[], + options: LanguageCellHandlerOptions, +) => { + const includeHandler = makeHandlerContext({ + ...options, + }); + // search for include shortcodes in the cell content + for (let i = 0; i < newCells.length; ++i) { + const lines = mappedLines(newCells[i], true); + let foundShortcodes = false; + for (let j = 0; j < lines.length; ++j) { + const shortcode = isBlockShortcode(lines[j].value); + if (shortcode && shortcode.name === "include") { + foundShortcodes = true; + const param = shortcode.params[0]; + if (!param) { + throw new Error("Include directive needs filename as a parameter"); + } + lines[j] = await standaloneInclude(includeHandler.context, param); + } + } + if (foundShortcodes) { + newCells[i] = mappedConcat(lines); + } + } +}; + export async function handleLanguageCells( options: LanguageCellHandlerOptions, ): Promise<{ @@ -438,6 +468,11 @@ export async function handleLanguageCells( } } } + + // now handle the markdown content. This is necessary specifically for + // include shortcodes that can still be hiding inside of code blocks + await processMarkdownIncludes(newCells, options); + return { markdown: mappedJoin(newCells, ""), results, diff --git a/src/core/handlers/include-standalone.ts b/src/core/handlers/include-standalone.ts new file mode 100644 index 0000000000..e37acb61c0 --- /dev/null +++ b/src/core/handlers/include-standalone.ts @@ -0,0 +1,92 @@ +/* + * include-standalone.ts + * + * Copyright (C) 2023 Posit Software, PBC + */ + +import { LanguageCellHandlerContext } from "./types.ts"; + +import { + asMappedString, + EitherString, + mappedConcat, + MappedString, + mappedString, +} from "../lib/mapped-text.ts"; + +import { rangedLines } from "../lib/ranged-text.ts"; +import { isBlockShortcode } from "../lib/parse-shortcode.ts"; + +export const standaloneInclude = async ( + handlerContext: LanguageCellHandlerContext, + filename: string, +): Promise => { + const source = handlerContext.options.context.target.source; + const retrievedFiles: string[] = [source]; + + const textFragments: EitherString[] = []; + + const retrieveInclude = async (filename: string) => { + const path = handlerContext.resolvePath(filename); + + if (retrievedFiles.indexOf(path) !== -1) { + throw new Error( + `Include directive found circular include of file ${filename}.`, + ); + } + + let includeSrc; + try { + includeSrc = asMappedString( + Deno.readTextFileSync(path), + path, + ); + } catch (_e) { + const errMsg: string[] = [`Include directive failed.`]; + errMsg.push(...retrievedFiles.map((s) => ` in file ${s}, `)); + errMsg.push( + ` could not find file ${path + // relative(handlerContext.options.context.target.source, path) + }.`, + ); + throw new Error(errMsg.join("\n")); + } + + retrievedFiles.push(filename); + + let rangeStart = 0; + for (const { substring, range } of rangedLines(includeSrc.value)) { + const m = isBlockShortcode(substring); + if (m && m.name.toLocaleLowerCase() === "include") { + textFragments.push( + mappedString(includeSrc, [{ + start: rangeStart, + end: range.start, + }]), + ); + rangeStart = range.end; + const params = m.params; + if (params.length === 0) { + throw new Error("Include directive needs file parameter"); + } + + await retrieveInclude(params[0]); + } + } + if (rangeStart !== includeSrc.value.length) { + textFragments.push( + mappedString(includeSrc, [{ + start: rangeStart, + end: includeSrc.value.length, + }]), + ); + } + textFragments.push(includeSrc.value.endsWith("\n") ? "\n" : "\n\n"); + + retrievedFiles.pop(); + }; + + await retrieveInclude(filename); + + return Promise.resolve(mappedConcat(textFragments)); +}; diff --git a/src/core/handlers/include.ts b/src/core/handlers/include.ts index adb62baae1..e710463805 100644 --- a/src/core/handlers/include.ts +++ b/src/core/handlers/include.ts @@ -1,23 +1,15 @@ /* -* include.ts -* -* Copyright (C) 2022 Posit Software, PBC -* -*/ + * include.ts + * + * Copyright (C) 2022 Posit Software, PBC + */ import { LanguageCellHandlerContext, LanguageHandler } from "./types.ts"; import { baseHandler, install } from "./base.ts"; -import { - asMappedString, - EitherString, - mappedConcat, - MappedString, - mappedString, -} from "../lib/mapped-text.ts"; +import { MappedString } from "../lib/mapped-text.ts"; -import { rangedLines } from "../lib/ranged-text.ts"; -import { isBlockShortcode } from "../lib/parse-shortcode.ts"; import { DirectiveCell } from "../lib/break-quarto-md-types.ts"; +import { standaloneInclude } from "./include-standalone.ts"; const includeHandler: LanguageHandler = { ...baseHandler, @@ -31,79 +23,11 @@ const includeHandler: LanguageHandler = { handlerContext: LanguageCellHandlerContext, directive: DirectiveCell, ): Promise { - const source = handlerContext.options.context.target.source; - const retrievedFiles: string[] = [source]; - - const textFragments: EitherString[] = []; - - const retrieveInclude = async (filename: string) => { - const path = handlerContext.resolvePath(filename); - - if (retrievedFiles.indexOf(path) !== -1) { - throw new Error( - `Include directive found circular include of file ${filename}.`, - ); - } - - let includeSrc; - try { - includeSrc = asMappedString( - Deno.readTextFileSync(path), - path, - ); - } catch (_e) { - const errMsg: string[] = [`Include directive failed.`]; - errMsg.push(...retrievedFiles.map((s) => ` in file ${s}, `)); - errMsg.push( - ` could not find file ${path - // relative(handlerContext.options.context.target.source, path) - }.`, - ); - throw new Error(errMsg.join("\n")); - } - - retrievedFiles.push(filename); - - let rangeStart = 0; - for (const { substring, range } of rangedLines(includeSrc.value)) { - const m = isBlockShortcode(substring); - if (m && m.name.toLocaleLowerCase() === "include") { - textFragments.push( - mappedString(includeSrc, [{ - start: rangeStart, - end: range.start, - }]), - ); - rangeStart = range.end; - const params = m.params; - if (params.length === 0) { - throw new Error("Include directive needs file parameter"); - } - - await retrieveInclude(params[0]); - } - } - if (rangeStart !== includeSrc.value.length) { - textFragments.push( - mappedString(includeSrc, [{ - start: rangeStart, - end: includeSrc.value.length, - }]), - ); - } - textFragments.push(includeSrc.value.endsWith("\n") ? "\n" : "\n\n"); - - retrievedFiles.pop(); - }; - const param = directive.shortcode.params[0]; if (!param) { throw new Error("Include directive needs filename as a parameter"); } - - await retrieveInclude(param); - - return Promise.resolve(mappedConcat(textFragments)); + return await standaloneInclude(handlerContext, param); }, }; diff --git a/tests/docs/smoke-all/2023/12/01/1237.qmd b/tests/docs/smoke-all/2023/12/01/1237.qmd new file mode 100644 index 0000000000..b47fba74e3 --- /dev/null +++ b/tests/docs/smoke-all/2023/12/01/1237.qmd @@ -0,0 +1,19 @@ +--- +format: html +_quarto: + tests: + html: + ensureFileRegexMatches: + - ["Hello"] + - [] +--- + +```.r +{{< include file.r >}} +``` + + +```{r} +# Don't do anything here. +cat("world.") +``` \ No newline at end of file diff --git a/tests/docs/smoke-all/2023/12/01/file.r b/tests/docs/smoke-all/2023/12/01/file.r new file mode 100644 index 0000000000..e83811edb6 --- /dev/null +++ b/tests/docs/smoke-all/2023/12/01/file.r @@ -0,0 +1 @@ +cat("Hello.") \ No newline at end of file