From b34cb4a1106fafc7bbb9bebf850e5b581e9cde48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:41:26 +0000 Subject: [PATCH] Add Python macro worker runtime, ScriptEngine resolution, and PageRenderer/registry integration Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com> --- src/main/engine/PageRenderer.ts | 136 ++++++++- src/main/engine/PreviewServer.ts | 23 +- src/main/engine/PythonMacroWorkerRuntime.ts | 301 ++++++++++++++++++++ src/main/engine/ScriptEngine.ts | 18 ++ src/main/engine/pythonMacro.worker.ts | 124 ++++++++ src/renderer/macros/index.ts | 4 + src/renderer/macros/registry.ts | 69 +++-- src/renderer/macros/types.ts | 27 ++ 8 files changed, 679 insertions(+), 23 deletions(-) create mode 100644 src/main/engine/PythonMacroWorkerRuntime.ts create mode 100644 src/main/engine/pythonMacro.worker.ts diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index fa085e7..6800157 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -11,6 +11,25 @@ import { CALENDAR_RUNTIME_JS } from './assets/calendarRuntime'; import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime'; import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n'; +export interface PythonMacroScript { + id: string; + slug: string; + entrypoint: string; + content: string; + version: number; +} + +export interface PythonMacroRendererContract { + getEnabledMacroScripts(): Promise; + renderMacro(params: { + scriptContent: string; + entrypoint: string; + contextJson: string; + cacheKey?: string; + timeoutMs?: number; + }): Promise<{ html: string; data?: Record; warnings?: string[] }>; +} + export interface HtmlRewriteContext { canonicalPostPathBySlug: Map; canonicalMediaPathBySourcePath: Map; @@ -801,6 +820,107 @@ export function renderMacro( return ''; } +const JS_BUILTIN_MACROS = new Set(['youtube', 'vimeo', 'gallery', 'photo_archive', 'photo_album', 'tag_cloud']); + +export function isBuiltInMacro(name: string): boolean { + return JS_BUILTIN_MACROS.has(normalizeMacroName(name)); +} + +export async function replaceAllMacrosAsync( + content: string, + postId: string, + mediaItems: MediaData[], + linkedMediaIds: Set | null, + tagUsage: TagUsageEntry[], + renderLanguage: string, + pythonMacroRenderer?: PythonMacroRendererContract | null, +): Promise { + const macroRegex = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g; + const matches: Array<{ fullMatch: string; name: string; rawParams: string | undefined; start: number; end: number }> = []; + + let match: RegExpExecArray | null = null; + while ((match = macroRegex.exec(content)) !== null) { + matches.push({ + fullMatch: match[0], + name: match[1].toLowerCase(), + rawParams: match[2], + start: match.index, + end: match.index + match[0].length, + }); + } + + if (matches.length === 0) { + return content; + } + + let pythonScripts: PythonMacroScript[] | null = null; + const hasUnknownMacros = matches.some((m) => !isBuiltInMacro(m.name)); + + if (hasUnknownMacros && pythonMacroRenderer) { + try { + pythonScripts = await pythonMacroRenderer.getEnabledMacroScripts(); + } catch { + pythonScripts = []; + } + } + + const scriptsBySlug = new Map(); + if (pythonScripts) { + for (const script of pythonScripts) { + scriptsBySlug.set(script.slug.toLowerCase(), script); + } + } + + const rendered: string[] = []; + + for (const m of matches) { + const params = parseMacroParams(m.rawParams); + const builtInResult = renderMacro(m.name, params, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage); + + if (builtInResult || isBuiltInMacro(m.name)) { + rendered.push(builtInResult); + continue; + } + + const pythonScript = scriptsBySlug.get(normalizeMacroName(m.name)); + if (pythonScript && pythonMacroRenderer) { + try { + const context = { + env: { + isPreview: false, + mainLanguage: renderLanguage, + hook: m.name, + source: { kind: 'macro', id: pythonScript.id }, + }, + params: params as Record, + }; + + const result = await pythonMacroRenderer.renderMacro({ + scriptContent: pythonScript.content, + entrypoint: pythonScript.entrypoint, + contextJson: JSON.stringify(context), + cacheKey: `${pythonScript.id}:${pythonScript.version}`, + timeoutMs: 10000, + }); + + rendered.push(result.html); + } catch { + rendered.push(''); + } + } else { + rendered.push(''); + } + } + + let result = content; + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]; + result = result.slice(0, m.start) + rendered[i] + result.slice(m.end); + } + + return result; +} + export function buildCanonicalPostPath(post: PostData): string { const year = post.createdAt.getFullYear(); const month = String(post.createdAt.getMonth() + 1).padStart(2, '0'); @@ -898,12 +1018,19 @@ export class PageRenderer { private readonly mediaEngine: MediaEngineContract; private readonly postMediaEngine: PostMediaEngineContract; private readonly postEngineForMacros?: PostEngineContract; + private readonly pythonMacroRenderer?: PythonMacroRendererContract; private readonly liquid: Liquid; - constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract, postEngineForMacros?: PostEngineContract) { + constructor( + mediaEngine: MediaEngineContract, + postMediaEngine: PostMediaEngineContract, + postEngineForMacros?: PostEngineContract, + pythonMacroRenderer?: PythonMacroRendererContract, + ) { this.mediaEngine = mediaEngine; this.postMediaEngine = postMediaEngine; this.postEngineForMacros = postEngineForMacros; + this.pythonMacroRenderer = pythonMacroRenderer; const templateRoots = resolvePageRendererTemplateRoots(); @@ -951,10 +1078,9 @@ export class PageRenderer { .catch(() => null) : null; - const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => { - const params = parseMacroParams(rawParams); - return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage); - }); + const withMacros = await replaceAllMacrosAsync( + content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, + ); const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false }); const annotatedMarkdownHtml = annotateCodeBlocksWithLanguage(markdownHtml); diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index be95ddf..1d2e8ab 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -19,7 +19,10 @@ import { type HtmlRewriteContext, type MediaEngineContract, type PostMediaEngineContract, + type PythonMacroRendererContract, } from './PageRenderer'; +import { getScriptEngine } from './ScriptEngine'; +import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; import { renderRouteWithSharedContext } from './SharedRouteRenderer'; import { @@ -103,7 +106,7 @@ export class PreviewServer { projectDescription: activeProject?.description ?? undefined, }; }); - this.pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine); + this.pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine, buildPythonMacroRenderer()); } async start(preferredPort = 0): Promise { @@ -614,3 +617,21 @@ export class PreviewServer { res.end(body); } } + +function buildPythonMacroRenderer(): PythonMacroRendererContract { + return { + async getEnabledMacroScripts() { + const scripts = await getScriptEngine().getEnabledMacroScripts(); + return scripts.map((s) => ({ + id: s.id, + slug: s.slug, + entrypoint: s.entrypoint, + content: s.content, + version: s.version, + })); + }, + async renderMacro(params) { + return getPythonMacroWorkerRuntime().renderMacro(params); + }, + }; +} diff --git a/src/main/engine/PythonMacroWorkerRuntime.ts b/src/main/engine/PythonMacroWorkerRuntime.ts new file mode 100644 index 0000000..5792c88 --- /dev/null +++ b/src/main/engine/PythonMacroWorkerRuntime.ts @@ -0,0 +1,301 @@ +import * as path from 'path'; +import { Worker } from 'worker_threads'; + +interface WorkerRenderMacroRequest { + type: 'renderMacro'; + requestId: string; + scriptContent: string; + entrypoint: string; + contextJson: string; + cacheKey?: string; +} + +interface WorkerReadyMessage { + type: 'ready'; +} + +interface WorkerMacroResultMessage { + type: 'macroResult'; + requestId: string; + html: string; + data?: Record; + warnings?: string[]; +} + +interface WorkerMacroErrorMessage { + type: 'macroError'; + requestId: string; + error: string; +} + +interface WorkerFatalErrorMessage { + type: 'error'; + error: string; +} + +type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage; + +export interface MacroRenderParams { + scriptContent: string; + entrypoint: string; + contextJson: string; + timeoutMs?: number; + cacheKey?: string; +} + +export interface MacroRenderResult { + html: string; + data?: Record; + warnings?: string[]; +} + +interface QueuedRequest { + request: WorkerRenderMacroRequest; + timeoutMs: number; + resolve: (value: MacroRenderResult) => void; + reject: (error: Error) => void; +} + +interface ActiveRequest extends QueuedRequest { + timeoutId: ReturnType | null; +} + +export class PythonMacroWorkerRuntime { + private worker: Worker | null = null; + private workerReady = false; + private workerStartPromise: Promise | null = null; + private workerStartResolve: (() => void) | null = null; + private workerStartReject: ((error: Error) => void) | null = null; + private activeRequest: ActiveRequest | null = null; + private queue: QueuedRequest[] = []; + private requestCounter = 0; + private _macroCount = 0; + private _errorCount = 0; + private _timeoutCount = 0; + + async renderMacro(params: MacroRenderParams): Promise { + const requestId = this.nextRequestId(); + const timeoutMs = params.timeoutMs ?? 5000; + + return new Promise((resolve, reject) => { + this.queue.push({ + request: { + type: 'renderMacro', + requestId, + scriptContent: params.scriptContent, + entrypoint: params.entrypoint, + contextJson: params.contextJson, + cacheKey: params.cacheKey, + }, + timeoutMs, + resolve, + reject, + }); + + this.dispatchNext().catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + } + + get macroCount(): number { + return this._macroCount; + } + + get errorCount(): number { + return this._errorCount; + } + + get timeoutCount(): number { + return this._timeoutCount; + } + + resetCounters(): void { + this._macroCount = 0; + this._errorCount = 0; + this._timeoutCount = 0; + } + + dispose(): void { + this.rejectStartPromise(new Error('Python macro worker runtime disposed')); + this.rejectActiveAndQueue(new Error('Python macro worker runtime disposed')); + this.resetWorker(); + } + + private async dispatchNext(): Promise { + if (this.activeRequest || this.queue.length === 0) { + return; + } + + await this.ensureWorkerStarted(); + + const nextRequest = this.queue.shift(); + if (!nextRequest) { + return; + } + + const timeoutId = setTimeout(() => { + if (!this.activeRequest || this.activeRequest.request.requestId !== nextRequest.request.requestId) { + return; + } + + this._timeoutCount += 1; + const timeoutError = new Error(`Python macro timed out after ${nextRequest.timeoutMs}ms`); + this.activeRequest.reject(timeoutError); + this.activeRequest = null; + this.resetWorker(); + void this.dispatchNext(); + }, nextRequest.timeoutMs); + + this.activeRequest = { + ...nextRequest, + timeoutId, + }; + + this.worker?.postMessage(nextRequest.request); + } + + private async ensureWorkerStarted(): Promise { + if (this.worker && this.workerReady) { + return; + } + + if (this.workerStartPromise) { + return this.workerStartPromise; + } + + const workerPath = path.join(__dirname, 'pythonMacro.worker.js'); + this.worker = new Worker(workerPath); + this.workerReady = false; + + this.worker.on('message', (message: WorkerResponseMessage) => { + this.handleWorkerMessage(message); + }); + + this.worker.on('error', (error) => { + this.handleWorkerCrash(error instanceof Error ? error : new Error(String(error))); + }); + + this.worker.on('exit', (code) => { + if (code !== 0) { + this.handleWorkerCrash(new Error(`Python macro worker exited with code ${code}`)); + } + }); + + this.workerStartPromise = new Promise((resolve, reject) => { + this.workerStartResolve = resolve; + this.workerStartReject = reject; + }); + + return this.workerStartPromise; + } + + private handleWorkerMessage(message: WorkerResponseMessage): void { + if (message.type === 'ready') { + this.workerReady = true; + this.resolveStartPromise(); + return; + } + + if (message.type === 'error') { + this.handleWorkerCrash(new Error(message.error)); + return; + } + + const active = this.activeRequest; + if (!active) { + return; + } + + if (active.request.requestId !== message.requestId) { + return; + } + + if (active.timeoutId) { + clearTimeout(active.timeoutId); + } + + this.activeRequest = null; + this._macroCount += 1; + + if (message.type === 'macroResult') { + active.resolve({ + html: message.html, + data: message.data, + warnings: message.warnings, + }); + } else { + this._errorCount += 1; + active.reject(new Error(message.error)); + } + + void this.dispatchNext(); + } + + private handleWorkerCrash(error: Error): void { + this.rejectStartPromise(error); + this.rejectActiveAndQueue(error); + this.resetWorker(); + } + + private rejectActiveAndQueue(error: Error): void { + if (this.activeRequest) { + if (this.activeRequest.timeoutId) { + clearTimeout(this.activeRequest.timeoutId); + } + this.activeRequest.reject(error); + this.activeRequest = null; + } + + while (this.queue.length > 0) { + const queued = this.queue.shift(); + queued?.reject(error); + } + } + + private resolveStartPromise(): void { + if (this.workerStartResolve) { + this.workerStartResolve(); + } + this.workerStartResolve = null; + this.workerStartReject = null; + this.workerStartPromise = null; + } + + private rejectStartPromise(error: Error): void { + if (this.workerStartReject) { + this.workerStartReject(error); + } + this.workerStartResolve = null; + this.workerStartReject = null; + this.workerStartPromise = null; + } + + private resetWorker(): void { + if (this.worker) { + this.worker.removeAllListeners(); + this.worker.terminate(); + } + + this.worker = null; + this.workerReady = false; + this.workerStartPromise = null; + this.workerStartResolve = null; + this.workerStartReject = null; + } + + private nextRequestId(): string { + this.requestCounter += 1; + return `py-macro-${this.requestCounter}`; + } +} + +let pythonMacroWorkerRuntimeInstance: PythonMacroWorkerRuntime | null = null; + +export function getPythonMacroWorkerRuntime(): PythonMacroWorkerRuntime { + if (!pythonMacroWorkerRuntimeInstance) { + pythonMacroWorkerRuntimeInstance = new PythonMacroWorkerRuntime(); + } + + return pythonMacroWorkerRuntimeInstance; +} diff --git a/src/main/engine/ScriptEngine.ts b/src/main/engine/ScriptEngine.ts index c31f2b4..2072513 100644 --- a/src/main/engine/ScriptEngine.ts +++ b/src/main/engine/ScriptEngine.ts @@ -222,6 +222,24 @@ export class ScriptEngine extends EventEmitter { return Promise.all(rows.map((item) => this.toScriptData(item))); } + async getEnabledMacroScripts(): Promise { + const rows = await this.getAllScriptRows(); + const macroRows = rows.filter((row) => row.kind === 'macro' && row.enabled); + return Promise.all(macroRows.map((item) => this.toScriptData(item))); + } + + async getMacroScriptBySlug(slug: string): Promise { + const normalizedSlug = slug.toLowerCase(); + const rows = await this.getAllScriptRows(); + const match = rows.find( + (row) => row.kind === 'macro' && row.enabled && row.slug.toLowerCase() === normalizedSlug, + ); + if (!match) { + return null; + } + return this.toScriptData(match); + } + async rebuildDatabaseFromFiles(): Promise { const db = getDatabase().getLocal(); const scriptsDir = this.getScriptsDir(); diff --git a/src/main/engine/pythonMacro.worker.ts b/src/main/engine/pythonMacro.worker.ts new file mode 100644 index 0000000..55f4ed4 --- /dev/null +++ b/src/main/engine/pythonMacro.worker.ts @@ -0,0 +1,124 @@ +import { parentPort } from 'worker_threads'; + +interface WorkerRenderMacroRequest { + type: 'renderMacro'; + requestId: string; + scriptContent: string; + entrypoint: string; + contextJson: string; + cacheKey?: string; +} + +interface WorkerReadyMessage { + type: 'ready'; +} + +interface WorkerMacroResultMessage { + type: 'macroResult'; + requestId: string; + html: string; + data?: Record; + warnings?: string[]; +} + +interface WorkerMacroErrorMessage { + type: 'macroError'; + requestId: string; + error: string; +} + +interface WorkerFatalErrorMessage { + type: 'error'; + error: string; +} + +type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage; + +type PyodideRuntime = { + globals: { + set: (name: string, value: unknown) => void; + } | any; + runPythonAsync: (code: string) => Promise; +}; + +let runtimePromise: Promise | null = null; +let lastCacheKey: string | null = null; + +function postMessage(message: WorkerResponseMessage): void { + parentPort?.postMessage(message); +} + +async function getRuntime(): Promise { + if (!runtimePromise) { + runtimePromise = (async () => { + const pyodideModule = await import('pyodide'); + return (await pyodideModule.loadPyodide()) as unknown as PyodideRuntime; + })(); + } + + return runtimePromise; +} + +async function renderMacro(request: WorkerRenderMacroRequest): Promise { + try { + const runtime = await getRuntime(); + + const shouldReloadScript = !request.cacheKey || request.cacheKey !== lastCacheKey; + + if (shouldReloadScript) { + await runtime.runPythonAsync(request.scriptContent); + lastCacheKey = request.cacheKey ?? null; + } + + runtime.globals.set('__bds_macro_context_json', request.contextJson); + runtime.globals.set('__bds_macro_entrypoint', request.entrypoint); + + const rawResult = await runtime.runPythonAsync(` +import json as _json + +_macro_ctx = _json.loads(__bds_macro_context_json) +_macro_ep = __bds_macro_entrypoint +_macro_fn = globals().get(_macro_ep) +if _macro_fn is None or not callable(_macro_fn): + raise RuntimeError(f"Macro entrypoint '{_macro_ep}' is not callable") +_macro_result = _macro_fn(_macro_ctx) +if _macro_result is None: + raise RuntimeError("Macro function returned None") +if not isinstance(_macro_result, dict): + raise RuntimeError("Macro function must return a dict with at least an 'html' key") +if "html" not in _macro_result: + raise RuntimeError("Macro result must contain an 'html' key") +_json.dumps(_macro_result) +`); + + const parsed = JSON.parse(String(rawResult)); + + postMessage({ + type: 'macroResult', + requestId: request.requestId, + html: typeof parsed.html === 'string' ? parsed.html : '', + data: parsed.data, + warnings: Array.isArray(parsed.warnings) ? parsed.warnings : undefined, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + postMessage({ type: 'macroError', requestId: request.requestId, error: message }); + } +} + +parentPort?.on('message', (message: WorkerRenderMacroRequest) => { + if (message.type !== 'renderMacro') { + return; + } + + void renderMacro(message); +}); + +void getRuntime() + .then(() => { + postMessage({ type: 'ready' }); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + postMessage({ type: 'error', error: message }); + }); diff --git a/src/renderer/macros/index.ts b/src/renderer/macros/index.ts index f7e0f6f..2ea0558 100644 --- a/src/renderer/macros/index.ts +++ b/src/renderer/macros/index.ts @@ -24,6 +24,9 @@ export type { MacroParams, MacroRenderContext, ParsedMacro, + PythonMacroInfo, + PythonMacroResolver, + PythonMacroRendererFn, } from './types'; // Re-export registry functions @@ -39,4 +42,5 @@ export { renderMacro, renderAllMacros, getEditorPreview, + setPythonMacroResolver, } from './registry'; diff --git a/src/renderer/macros/registry.ts b/src/renderer/macros/registry.ts index 72e27b6..98a0ea4 100644 --- a/src/renderer/macros/registry.ts +++ b/src/renderer/macros/registry.ts @@ -5,11 +5,22 @@ * Macros self-register using registerMacro() function. */ -import type { MacroDefinition, MacroParams, MacroRenderContext, ParsedMacro } from './types'; +import type { + MacroDefinition, + MacroParams, + MacroRenderContext, + ParsedMacro, + PythonMacroResolver, + PythonMacroRendererFn, +} from './types'; // Internal registry storage const macroRegistry = new Map(); +// Python macro resolution +let pythonMacroResolverFn: PythonMacroResolver | null = null; +let pythonMacroRendererFn: PythonMacroRendererFn | null = null; + /** * Register a macro definition. * Call this from each macro definition file. @@ -25,6 +36,18 @@ export function registerMacro(macro: MacroDefinition): void { macroRegistry.set(name, macro); } +/** + * Set the Python macro resolver and renderer for preview rendering. + * When a macro is not found in the JS registry, the resolver will be called. + */ +export function setPythonMacroResolver( + resolver: PythonMacroResolver | null, + renderer: PythonMacroRendererFn | null, +): void { + pythonMacroResolverFn = resolver; + pythonMacroRendererFn = renderer; +} + /** * Get a macro definition by name. * @@ -124,6 +147,7 @@ export function parseMacros(markdown: string): ParsedMacro[] { /** * Render a single macro to HTML. + * First checks JS registry, then falls back to Python macro resolver. * * @param macro - The parsed macro * @param context - Render context @@ -135,25 +159,36 @@ export async function renderMacro( ): Promise { const definition = getMacro(macro.name); - if (!definition) { - return `${macro.rawText}`; - } - - // Validate if validator exists - if (definition.validate) { - const error = definition.validate(macro.params); - if (error) { - return `${macro.rawText}`; + if (definition) { + // Validate if validator exists + if (definition.validate) { + const error = definition.validate(macro.params); + if (error) { + return `${macro.rawText}`; + } + } + + try { + const result = definition.render(macro.params, context); + return result instanceof Promise ? await result : result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Render error'; + return `${macro.rawText}`; } } - - try { - const result = definition.render(macro.params, context); - return result instanceof Promise ? await result : result; - } catch (error) { - const message = error instanceof Error ? error.message : 'Render error'; - return `${macro.rawText}`; + + if (pythonMacroResolverFn && pythonMacroRendererFn) { + try { + const pythonInfo = await pythonMacroResolverFn(macro.name); + if (pythonInfo) { + return await pythonMacroRendererFn(pythonInfo, macro.params, context); + } + } catch { + return `${macro.rawText}`; + } } + + return `${macro.rawText}`; } /** diff --git a/src/renderer/macros/types.ts b/src/renderer/macros/types.ts index 53e6006..9fc56c8 100644 --- a/src/renderer/macros/types.ts +++ b/src/renderer/macros/types.ts @@ -75,3 +75,30 @@ export interface ParsedMacro { /** End position in the source text */ end: number; } + +/** + * Resolved Python macro script information for rendering. + */ +export interface PythonMacroInfo { + scriptId: string; + slug: string; + code: string; + entrypoint: string; + version: number; +} + +/** + * Resolver function that checks if a macro name maps to a Python script. + * Returns script info if found, null otherwise. + */ +export type PythonMacroResolver = (macroName: string) => Promise; + +/** + * Renderer function that executes a Python macro with the given context. + * Returns the rendered HTML string. + */ +export type PythonMacroRendererFn = ( + info: PythonMacroInfo, + params: MacroParams, + context: MacroRenderContext, +) => Promise;