From 7c4c84feec9cb372c629f8b5a821dbeaed4739d8 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 30 Dec 2025 11:21:19 -0500 Subject: [PATCH 1/4] claude: Remove duplicate engine-shared.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The functions in engine-shared.ts (languagesInMarkdown, languagesInMarkdownFile, postProcessRestorePreservedHtml) have identical implementations in src/core/pandoc/pandoc-partition.ts and src/core/jupyter/preserve.ts. Update imports to use the canonical locations and remove the duplicate file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/execute/engine-shared.ts | 49 ------------------------------------ src/execute/engine.ts | 2 +- src/execute/ojs/compile.ts | 2 +- 3 files changed, 2 insertions(+), 51 deletions(-) delete mode 100644 src/execute/engine-shared.ts 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..44b4b1fa7b6 100644 --- a/src/execute/engine.ts +++ b/src/execute/engine.ts @@ -27,7 +27,7 @@ import { ExecutionTarget, kQmdExtensions, } from "./types.ts"; -import { languagesInMarkdown } from "./engine-shared.ts"; +import { languagesInMarkdown } 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"; 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, From a20557b682ea12f0f6d24821c0a2b62c72e51fb0 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 30 Dec 2025 11:26:03 -0500 Subject: [PATCH 2/4] claude: Add class-based engine selection with scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for extracting the first class from code block attributes (e.g., {python .marimo}) and passing it to engine's claimsLanguage method. Changes: - Add languagesWithClasses() function returning Map - Update languagesInMarkdown() to use languagesWithClasses internally - Update claimsLanguage signature to accept optional firstClass parameter and return boolean | number for priority-based selection - Update markdownExecutionEngine to use scoring: highest score wins, first engine wins ties (backwards compatible) - Expose getLanguagesWithClasses in the markdownRegex API namespace Backwards compatibility: - Old engines returning true -> score 1 - Old engines returning false -> score 0 - New engines can return numbers > 1 to override 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/quarto-types/dist/index.d.ts | 13 ++- packages/quarto-types/src/execution-engine.ts | 6 +- packages/quarto-types/src/quarto-api.ts | 8 ++ src/core/api/markdown-regex.ts | 2 + src/core/api/types.ts | 3 + src/core/pandoc/pandoc-partition.ts | 24 +++-- src/execute/engine.ts | 28 ++++-- src/execute/types.ts | 9 +- .../_extensions/foo-engine/_extension.yml | 7 ++ .../_extensions/foo-engine/foo-engine.js | 92 +++++++++++++++++++ .../smoke-all/engine/class-override/test.qmd | 27 ++++++ 11 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/_extension.yml create mode 100644 tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js create mode 100644 tests/docs/smoke-all/engine/class-override/test.qmd 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.ts b/src/execute/engine.ts index 44b4b1fa7b6..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 "../core/pandoc/pandoc-partition.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/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/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..b70c507bd15 --- /dev/null +++ b/tests/docs/smoke-all/engine/class-override/_extensions/foo-engine/foo-engine.js @@ -0,0 +1,92 @@ +// 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) => { + // Replace python.foo code blocks with a marker div showing we processed them + let markdown = options.target.markdown.value; + + // Find and replace {python.foo} blocks with our marker output + const codeBlockRegex = /```\{python\.foo[^}]*\}\n([\s\S]*?)```/g; + markdown = markdown.replace(codeBlockRegex, (match, code) => { + return ` +::: {#foo-engine-marker .foo-engine-output} +**FOO ENGINE PROCESSED THIS BLOCK** + +Original code: +\`\`\`python +${code.trim()} +\`\`\` +::: +`; + }); + + return { + markdown: markdown, + 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..fc15b7559c7 --- /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. From e492c5ec31fca79c3ed57b97f0af51bc673ef96d Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 30 Dec 2025 12:14:07 -0500 Subject: [PATCH 3/4] claude: Update engine template with new claimsLanguage signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the `quarto create extension engine` template to reflect the new claimsLanguage signature that accepts an optional firstClass parameter and returns boolean | number for priority-based selection. No behavioral change - just updated signature and documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../engine/src/qstart-filesafename-qend.ejs.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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(); }, From 022f9cdb67a3b44dde2c353174171b48a81bf9c3 Mon Sep 17 00:00:00 2001 From: Gordon Woodhull Date: Tue, 30 Dec 2025 13:07:08 -0500 Subject: [PATCH 4/4] claude: refactor foo-engine test to use Quarto API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use breakQuartoMd API for cell iteration instead of raw regex, making the code more idiomatic and fixing Windows CI failures caused by line ending differences. Also fix test syntax to use {python .foo} (space-separated) which is recognized by breakQuartoMd. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../_extensions/foo-engine/foo-engine.js | 34 ++++++++++++------- .../smoke-all/engine/class-override/test.qmd | 2 +- 2 files changed, 23 insertions(+), 13 deletions(-) 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 index b70c507bd15..985b9bd5196 100644 --- 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 @@ -55,26 +55,36 @@ const fooEngineDiscovery = { }, execute: async (options) => { - // Replace python.foo code blocks with a marker div showing we processed them - let markdown = options.target.markdown.value; - - // Find and replace {python.foo} blocks with our marker output - const codeBlockRegex = /```\{python\.foo[^}]*\}\n([\s\S]*?)```/g; - markdown = markdown.replace(codeBlockRegex, (match, code) => { - return ` -::: {#foo-engine-marker .foo-engine-output} + 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 -${code.trim()} +${cell.source.value.trim()} \`\`\` ::: -`; - }); +`); + continue; + } + } + processedCells.push(cell.sourceVerbatim.value); + } return { - markdown: markdown, + markdown: processedCells.join(""), supporting: [], filters: [], }; diff --git a/tests/docs/smoke-all/engine/class-override/test.qmd b/tests/docs/smoke-all/engine/class-override/test.qmd index fc15b7559c7..c04d54fcaa6 100644 --- a/tests/docs/smoke-all/engine/class-override/test.qmd +++ b/tests/docs/smoke-all/engine/class-override/test.qmd @@ -18,7 +18,7 @@ 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} +```{python .foo} x = 1 + 1 print(x) ```