diff --git a/packages/quarto-types/dist/index.d.ts b/packages/quarto-types/dist/index.d.ts index bd99cdc1141..a104417616e 100644 --- a/packages/quarto-types/dist/index.d.ts +++ b/packages/quarto-types/dist/index.d.ts @@ -831,6 +831,13 @@ export interface QuartoAPI { * @returns Set of language identifiers found in fenced code blocks */ getLanguages: (markdown: string) => Set; + /** + * Extract programming languages and their first class from code blocks + * + * @param markdown - Markdown content to analyze + * @returns Map of language identifiers to their first class (or undefined) + */ + getLanguagesWithClasses: (markdown: string) => Map; /** * Break Quarto markdown into cells * @@ -1566,8 +1573,12 @@ export interface ExecutionEngineDiscovery { claimsFile: (file: string, ext: string) => boolean; /** * Whether this engine can handle the given language + * + * @param language - The language identifier (e.g., "python", "r", "julia") + * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) + * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) */ - claimsLanguage: (language: string) => boolean; + claimsLanguage: (language: string, firstClass?: string) => boolean | number; /** * Whether this engine supports freezing */ diff --git a/packages/quarto-types/src/execution-engine.ts b/packages/quarto-types/src/execution-engine.ts index 3c20446140c..87d8b2821be 100644 --- a/packages/quarto-types/src/execution-engine.ts +++ b/packages/quarto-types/src/execution-engine.ts @@ -95,8 +95,12 @@ export interface ExecutionEngineDiscovery { /** * Whether this engine can handle the given language + * + * @param language - The language identifier (e.g., "python", "r", "julia") + * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) + * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) */ - claimsLanguage: (language: string) => boolean; + claimsLanguage: (language: string, firstClass?: string) => boolean | number; /** * Whether this engine supports freezing diff --git a/packages/quarto-types/src/quarto-api.ts b/packages/quarto-types/src/quarto-api.ts index 0f8013e098e..d1c615ac722 100644 --- a/packages/quarto-types/src/quarto-api.ts +++ b/packages/quarto-types/src/quarto-api.ts @@ -66,6 +66,14 @@ export interface QuartoAPI { */ getLanguages: (markdown: string) => Set; + /** + * Extract programming languages and their first class from code blocks + * + * @param markdown - Markdown content to analyze + * @returns Map of language identifiers to their first class (or undefined) + */ + getLanguagesWithClasses: (markdown: string) => Map; + /** * Break Quarto markdown into cells * diff --git a/src/core/api/markdown-regex.ts b/src/core/api/markdown-regex.ts index 75f87c4ce22..d614f1623ab 100644 --- a/src/core/api/markdown-regex.ts +++ b/src/core/api/markdown-regex.ts @@ -7,6 +7,7 @@ import type { MarkdownRegexNamespace } from "./types.ts"; import { readYamlFromMarkdown } from "../yaml.ts"; import { languagesInMarkdown, + languagesWithClasses, partitionMarkdown, } from "../pandoc/pandoc-partition.ts"; import { breakQuartoMd } from "../lib/break-quarto-md.ts"; @@ -17,6 +18,7 @@ globalRegistry.register("markdownRegex", (): MarkdownRegexNamespace => { extractYaml: readYamlFromMarkdown, partition: partitionMarkdown, getLanguages: languagesInMarkdown, + getLanguagesWithClasses: languagesWithClasses, breakQuartoMd, }; }); diff --git a/src/core/api/types.ts b/src/core/api/types.ts index 3d503d4745b..91ef31a9def 100644 --- a/src/core/api/types.ts +++ b/src/core/api/types.ts @@ -38,6 +38,9 @@ export interface MarkdownRegexNamespace { extractYaml: (markdown: string) => Metadata; partition: (markdown: string) => PartitionedMarkdown; getLanguages: (markdown: string) => Set; + getLanguagesWithClasses: ( + markdown: string, + ) => Map; breakQuartoMd: ( src: string | MappedString, validate?: boolean, diff --git a/src/core/pandoc/pandoc-partition.ts b/src/core/pandoc/pandoc-partition.ts index 7271c96b9cc..24a23479fa2 100644 --- a/src/core/pandoc/pandoc-partition.ts +++ b/src/core/pandoc/pandoc-partition.ts @@ -114,19 +114,29 @@ export function languagesInMarkdownFile(file: string) { return languagesInMarkdown(Deno.readTextFileSync(file)); } -export function languagesInMarkdown(markdown: string) { - // see if there are any code chunks in the file - const languages = new Set(); - const kChunkRegex = /^[\t >]*```+\s*\{([a-zA-Z0-9_]+)( *[ ,].*)?\}\s*$/gm; +export function languagesWithClasses( + markdown: string, +): Map { + const result = new Map(); + // Capture language and everything after it (including dot-joined classes like {python.marimo}) + const kChunkRegex = /^[\t >]*```+\s*\{([a-zA-Z0-9_]+)([^}]*)?\}\s*$/gm; kChunkRegex.lastIndex = 0; let match = kChunkRegex.exec(markdown); while (match) { const language = match[1].toLowerCase(); - if (!languages.has(language)) { - languages.add(language); + if (!result.has(language)) { + // Extract first class from attrs (group 2) + // Handles {python.marimo}, {python .marimo}, {python #id .marimo}, etc. + const attrs = match[2]; + const firstClass = attrs?.match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/)?.[1]; + result.set(language, firstClass); } match = kChunkRegex.exec(markdown); } kChunkRegex.lastIndex = 0; - return languages; + return result; +} + +export function languagesInMarkdown(markdown: string): Set { + return new Set(languagesWithClasses(markdown).keys()); } diff --git a/src/execute/engine-shared.ts b/src/execute/engine-shared.ts deleted file mode 100644 index e0b9e14a52d..00000000000 --- a/src/execute/engine-shared.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * engine-shared.ts - * - * Copyright (C) 2021-2022 Posit Software, PBC - */ - -import { dirname, isAbsolute, join } from "../deno_ral/path.ts"; - -import { restorePreservedHtml } from "../core/jupyter/preserve.ts"; -import { PostProcessOptions } from "./types.ts"; - -export function postProcessRestorePreservedHtml(options: PostProcessOptions) { - // read the output file - - const outputPath = isAbsolute(options.output) - ? options.output - : join(dirname(options.target.input), options.output); - let output = Deno.readTextFileSync(outputPath); - - // substitute - output = restorePreservedHtml( - output, - options.preserve, - ); - - // re-write the output - Deno.writeTextFileSync(outputPath, output); -} - -export function languagesInMarkdownFile(file: string) { - return languagesInMarkdown(Deno.readTextFileSync(file)); -} - -export function languagesInMarkdown(markdown: string) { - // see if there are any code chunks in the file - const languages = new Set(); - const kChunkRegex = /^[\t >]*```+\s*\{([a-zA-Z0-9_]+)( *[ ,].*)?\}\s*$/gm; - kChunkRegex.lastIndex = 0; - let match = kChunkRegex.exec(markdown); - while (match) { - const language = match[1].toLowerCase(); - if (!languages.has(language)) { - languages.add(language); - } - match = kChunkRegex.exec(markdown); - } - kChunkRegex.lastIndex = 0; - return languages; -} diff --git a/src/execute/engine.ts b/src/execute/engine.ts index 497b8262aa0..ae4ff262178 100644 --- a/src/execute/engine.ts +++ b/src/execute/engine.ts @@ -27,7 +27,10 @@ import { ExecutionTarget, kQmdExtensions, } from "./types.ts"; -import { languagesInMarkdown } from "./engine-shared.ts"; +import { + languagesInMarkdown, + languagesWithClasses, +} from "../core/pandoc/pandoc-partition.ts"; import { languages as handlerLanguages } from "../core/handlers/base.ts"; import { RenderContext, RenderFlags } from "../command/render/types.ts"; import { mergeConfigs } from "../core/config.ts"; @@ -168,20 +171,31 @@ export function markdownExecutionEngine( } // if there are languages see if any engines want to claim them - const languages = languagesInMarkdown(markdown); + const languagesWithClassesMap = languagesWithClasses(markdown); + + // see if there is an engine that claims this language (highest score wins) + for (const [language, firstClass] of languagesWithClassesMap) { + let bestEngine: ExecutionEngineDiscovery | undefined; + let bestScore = 0; - // see if there is an engine that claims this language - for (const language of languages) { for (const [_, engine] of reorderedEngines) { - if (engine.claimsLanguage(language)) { - return engine.launch(engineProjectContext(project)); + const claim = engine.claimsLanguage(language, firstClass); + // Convert boolean to number for backwards compatibility + const score = typeof claim === "boolean" ? (claim ? 1 : 0) : claim; + if (score > bestScore) { + bestScore = score; + bestEngine = engine; } } + + if (bestEngine) { + return bestEngine.launch(engineProjectContext(project)); + } } const handlerLanguagesVal = handlerLanguages(); // if there is a non-cell handler language then this must be jupyter - for (const language of languages) { + for (const language of languagesWithClassesMap.keys()) { if (language !== "ojs" && !handlerLanguagesVal.includes(language)) { return jupyterEngineDiscovery.launch(engineProjectContext(project)); } diff --git a/src/execute/ojs/compile.ts b/src/execute/ojs/compile.ts index cc33bc02b39..e87cbeb6dde 100644 --- a/src/execute/ojs/compile.ts +++ b/src/execute/ojs/compile.ts @@ -65,7 +65,7 @@ import { logError } from "../../core/log.ts"; import { breakQuartoMd, QuartoMdCell } from "../../core/lib/break-quarto-md.ts"; import { MappedString } from "../../core/mapped-text.ts"; -import { languagesInMarkdown } from "../engine-shared.ts"; +import { languagesInMarkdown } from "../../core/pandoc/pandoc-partition.ts"; import { pandocBlock, diff --git a/src/execute/types.ts b/src/execute/types.ts index 0b6c1802c73..5ff00d617a0 100644 --- a/src/execute/types.ts +++ b/src/execute/types.ts @@ -46,7 +46,14 @@ export interface ExecutionEngineDiscovery { defaultContent: (kernel?: string) => string[]; validExtensions: () => string[]; claimsFile: (file: string, ext: string) => boolean; - claimsLanguage: (language: string) => boolean; + /** + * Whether this engine can handle the given language + * + * @param language - The language identifier (e.g., "python", "r", "julia") + * @param firstClass - Optional first class from code block attributes (e.g., "marimo" from {python .marimo}) + * @returns boolean for simple claim, or number for priority (higher wins, 0 = no claim, 1 = standard claim) + */ + claimsLanguage: (language: string, firstClass?: string) => boolean | number; canFreeze: boolean; generatesFigures: boolean; ignoreDirs?: () => string[] | undefined; diff --git a/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts b/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts index 516124c357d..59e17012f20 100644 --- a/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts +++ b/src/resources/create/extensions/engine/src/qstart-filesafename-qend.ejs.ts @@ -52,8 +52,14 @@ const exampleEngineDiscovery: ExecutionEngineDiscovery = { return false; }, - claimsLanguage: (language: string) => { - // This engine claims cells with its own language name + claimsLanguage: ( + language: string, + _firstClass?: string, + ): boolean | number => { + // This engine claims cells with its own language name. + // The optional firstClass parameter allows claiming based on code block class + // (e.g., {python.myengine} would have firstClass="myengine"). + // Return a number > 1 to override other engines that also claim this language. return language.toLowerCase() === kCellLanguage.toLowerCase(); }, diff --git a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml new file mode 100644 index 00000000000..c639eeb22a3 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml @@ -0,0 +1,7 @@ +title: Foo Engine +author: Quarto Dev Team +version: 1.0.0 +quarto-required: ">=1.9.17" +contributes: + engines: + - path: foo-engine.js diff --git a/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js new file mode 100644 index 00000000000..985b9bd5196 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js @@ -0,0 +1,102 @@ +// Minimal engine to test class-based engine override +// Claims {python.foo} blocks with priority 2 (higher than Jupyter's 1) + +let quarto; + +const fooEngineDiscovery = { + init: (quartoAPI) => { + quarto = quartoAPI; + }, + + name: "foo", + defaultExt: ".qmd", + defaultYaml: () => ["engine: foo"], + defaultContent: () => ["# Foo Engine Document"], + validExtensions: () => [".qmd"], + + claimsFile: (_file, _ext) => false, + + claimsLanguage: (language, firstClass) => { + // Claim python.foo with priority 2 (overrides Jupyter's 1) + if (language === "python" && firstClass === "foo") { + return 2; + } + return 0; + }, + + canFreeze: false, + generatesFigures: false, + + launch: (context) => { + return { + name: "foo", + canFreeze: false, + + markdownForFile: async (file) => { + return quarto.mappedString.fromFile(file); + }, + + target: async (file, _quiet, markdown) => { + if (!markdown) { + markdown = quarto.mappedString.fromFile(file); + } + const metadata = quarto.markdownRegex.extractYaml(markdown.value); + return { + source: file, + input: file, + markdown, + metadata, + }; + }, + + partitionedMarkdown: async (file) => { + const markdown = quarto.mappedString.fromFile(file); + return quarto.markdownRegex.partition(markdown.value); + }, + + execute: async (options) => { + const chunks = await quarto.markdownRegex.breakQuartoMd( + options.target.markdown, + ); + + const processedCells = []; + for (const cell of chunks.cells) { + if ( + typeof cell.cell_type === "object" && + cell.cell_type.language === "python" + ) { + const header = cell.sourceVerbatim.value.split(/\r?\n/)[0]; + const hasClassFoo = /\.foo\b/.test(header); + if (hasClassFoo) { + processedCells.push(`::: {#foo-engine-marker .foo-engine-output} +**FOO ENGINE PROCESSED THIS BLOCK** + +Original code: +\`\`\`python +${cell.source.value.trim()} +\`\`\` +::: +`); + continue; + } + } + processedCells.push(cell.sourceVerbatim.value); + } + + return { + markdown: processedCells.join(""), + supporting: [], + filters: [], + }; + }, + + dependencies: async (_options) => { + return { includes: {} }; + }, + + postprocess: async (_options) => {}, + }; + }, +}; + +export default fooEngineDiscovery; diff --git a/tests/docs/smoke-all/engine/class-override/test.qmd b/tests/docs/smoke-all/engine/class-override/test.qmd new file mode 100644 index 00000000000..c04d54fcaa6 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/test.qmd @@ -0,0 +1,27 @@ +--- +title: Engine Class Override Test +_quarto: + tests: + html: + ensureHtmlElements: + - + - "#foo-engine-marker" + - ".foo-engine-output" + - [] + ensureFileRegexMatches: + - + - "FOO ENGINE PROCESSED THIS BLOCK" + - [] +--- + +This document tests that `{python.foo}` blocks are processed by the foo-engine +instead of Jupyter, because foo-engine claims `python` with `firstClass === "foo"` +at priority 2 (higher than Jupyter's default of 1). + +```{python .foo} +x = 1 + 1 +print(x) +``` + +The block above should show "FOO ENGINE PROCESSED THIS BLOCK" instead of +actually executing the Python code.