fix: lots of missing pieces for python macro handling
This commit is contained in:
@@ -25,6 +25,7 @@ export interface PythonMacroRendererContract {
|
||||
scriptContent: string;
|
||||
entrypoint: string;
|
||||
contextJson: string;
|
||||
postDataJson?: string | null;
|
||||
cacheKey?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ html: string; data?: Record<string, unknown>; warnings?: string[] }>;
|
||||
@@ -99,6 +100,7 @@ export interface PostListTemplateContext {
|
||||
next_page_href: string;
|
||||
canonical_post_path_by_slug: Record<string, string>;
|
||||
canonical_media_path_by_source_path: Record<string, string>;
|
||||
post_data_json_by_id: Record<string, string>;
|
||||
day_blocks: DayBlockContext[];
|
||||
}
|
||||
|
||||
@@ -116,6 +118,7 @@ export interface SinglePostTemplateContext {
|
||||
calendar_initial_month: number | null;
|
||||
canonical_post_path_by_slug: Record<string, string>;
|
||||
canonical_media_path_by_source_path: Record<string, string>;
|
||||
post_data_json_by_id: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotFoundTemplateContext {
|
||||
@@ -826,6 +829,24 @@ export function isBuiltInMacro(name: string): boolean {
|
||||
return JS_BUILTIN_MACROS.has(normalizeMacroName(name));
|
||||
}
|
||||
|
||||
export function serializePostDataForMacro(post: PostData): Record<string, unknown> {
|
||||
return {
|
||||
id: post.id,
|
||||
projectId: post.projectId,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt ?? null,
|
||||
content: post.content,
|
||||
status: post.status,
|
||||
author: post.author ?? null,
|
||||
createdAt: post.createdAt instanceof Date ? post.createdAt.toISOString() : String(post.createdAt),
|
||||
updatedAt: post.updatedAt instanceof Date ? post.updatedAt.toISOString() : String(post.updatedAt),
|
||||
publishedAt: post.publishedAt instanceof Date ? post.publishedAt.toISOString() : (post.publishedAt ?? null),
|
||||
tags: Array.isArray(post.tags) ? post.tags : [],
|
||||
categories: Array.isArray(post.categories) ? post.categories : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function replaceAllMacrosAsync(
|
||||
content: string,
|
||||
postId: string,
|
||||
@@ -834,6 +855,7 @@ export async function replaceAllMacrosAsync(
|
||||
tagUsage: TagUsageEntry[],
|
||||
renderLanguage: string,
|
||||
pythonMacroRenderer?: PythonMacroRendererContract | null,
|
||||
postDataJson?: string | null,
|
||||
): Promise<string> {
|
||||
const macroRegex = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g;
|
||||
const matches: Array<{ fullMatch: string; name: string; rawParams: string | undefined; start: number; end: number }> = [];
|
||||
@@ -900,6 +922,7 @@ export async function replaceAllMacrosAsync(
|
||||
scriptContent: pythonScript.content,
|
||||
entrypoint: pythonScript.entrypoint,
|
||||
contextJson: JSON.stringify(context),
|
||||
postDataJson: postDataJson ?? null,
|
||||
cacheKey: `${pythonScript.id}:${pythonScript.version}`,
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
@@ -1055,10 +1078,14 @@ export class PageRenderer {
|
||||
return translateRender(resolved, key);
|
||||
});
|
||||
|
||||
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
|
||||
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
|
||||
const content = typeof value === 'string' ? value : '';
|
||||
const postId = typeof postIdArg === 'string' ? postIdArg : '';
|
||||
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en';
|
||||
const postDataJsonById = (postDataJsonByIdArg && typeof postDataJsonByIdArg === 'object' && !Array.isArray(postDataJsonByIdArg))
|
||||
? postDataJsonByIdArg as Record<string, string>
|
||||
: {};
|
||||
const postDataJson = postId ? (postDataJsonById[postId] ?? null) : null;
|
||||
const rewriteContext: HtmlRewriteContext = {
|
||||
canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
|
||||
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
|
||||
@@ -1081,7 +1108,7 @@ export class PageRenderer {
|
||||
: null;
|
||||
|
||||
const withMacros = await replaceAllMacrosAsync(
|
||||
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer,
|
||||
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson,
|
||||
);
|
||||
|
||||
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
|
||||
@@ -1280,6 +1307,9 @@ export class PageRenderer {
|
||||
next_page_href: nextPageHref,
|
||||
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
|
||||
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
|
||||
post_data_json_by_id: Object.fromEntries(
|
||||
posts.map((post) => [post.id, JSON.stringify(serializePostDataForMacro(post))]),
|
||||
),
|
||||
day_blocks: dayBlocks,
|
||||
};
|
||||
}
|
||||
@@ -1362,6 +1392,9 @@ export class PageRenderer {
|
||||
calendar_initial_month: renderablePost.createdAt.getMonth() + 1,
|
||||
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
|
||||
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
|
||||
post_data_json_by_id: {
|
||||
[renderablePost.id]: JSON.stringify(serializePostDataForMacro(renderablePost)),
|
||||
},
|
||||
};
|
||||
|
||||
return this.liquid.renderFile('single-post', context);
|
||||
|
||||
@@ -7,6 +7,7 @@ interface WorkerRenderMacroRequest {
|
||||
scriptContent: string;
|
||||
entrypoint: string;
|
||||
contextJson: string;
|
||||
postDataJson?: string | null;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
@@ -33,12 +34,29 @@ interface WorkerFatalErrorMessage {
|
||||
error: string;
|
||||
}
|
||||
|
||||
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage;
|
||||
interface WorkerApiCallMessage {
|
||||
type: 'apiCall';
|
||||
requestId: string;
|
||||
callId: string;
|
||||
method: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WorkerApiResultMessage {
|
||||
type: 'apiResult';
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage | WorkerApiCallMessage;
|
||||
|
||||
export interface MacroRenderParams {
|
||||
scriptContent: string;
|
||||
entrypoint: string;
|
||||
contextJson: string;
|
||||
postDataJson?: string | null;
|
||||
timeoutMs?: number;
|
||||
cacheKey?: string;
|
||||
}
|
||||
@@ -69,6 +87,8 @@ export interface WorkerLike {
|
||||
|
||||
export type WorkerFactory = (workerPath: string) => WorkerLike;
|
||||
|
||||
export type ApiInvoker = (method: string, args: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
export class PythonMacroWorkerRuntime {
|
||||
private worker: WorkerLike | null = null;
|
||||
private workerReady = false;
|
||||
@@ -82,9 +102,11 @@ export class PythonMacroWorkerRuntime {
|
||||
private _errorCount = 0;
|
||||
private _timeoutCount = 0;
|
||||
private readonly workerFactory: WorkerFactory;
|
||||
private readonly apiInvoker: ApiInvoker | null;
|
||||
|
||||
constructor(workerFactory?: WorkerFactory) {
|
||||
constructor(workerFactory?: WorkerFactory, apiInvoker?: ApiInvoker) {
|
||||
this.workerFactory = workerFactory ?? ((workerPath: string) => new Worker(workerPath) as unknown as WorkerLike);
|
||||
this.apiInvoker = apiInvoker ?? null;
|
||||
}
|
||||
|
||||
async renderMacro(params: MacroRenderParams): Promise<MacroRenderResult> {
|
||||
@@ -99,6 +121,7 @@ export class PythonMacroWorkerRuntime {
|
||||
scriptContent: params.scriptContent,
|
||||
entrypoint: params.entrypoint,
|
||||
contextJson: params.contextJson,
|
||||
postDataJson: params.postDataJson ?? null,
|
||||
cacheKey: params.cacheKey,
|
||||
},
|
||||
timeoutMs,
|
||||
@@ -218,6 +241,11 @@ export class PythonMacroWorkerRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'apiCall') {
|
||||
void this.handleApiCall(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.activeRequest;
|
||||
if (!active) {
|
||||
return;
|
||||
@@ -254,6 +282,35 @@ export class PythonMacroWorkerRuntime {
|
||||
this.resetWorker();
|
||||
}
|
||||
|
||||
private async handleApiCall(message: WorkerApiCallMessage): Promise<void> {
|
||||
if (!this.worker || !this.apiInvoker) {
|
||||
this.worker?.postMessage({
|
||||
type: 'apiResult',
|
||||
callId: message.callId,
|
||||
ok: false,
|
||||
error: 'API invoker not available',
|
||||
} satisfies WorkerApiResultMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.apiInvoker(message.method, message.args);
|
||||
this.worker?.postMessage({
|
||||
type: 'apiResult',
|
||||
callId: message.callId,
|
||||
ok: true,
|
||||
result,
|
||||
} satisfies WorkerApiResultMessage);
|
||||
} catch (error) {
|
||||
this.worker?.postMessage({
|
||||
type: 'apiResult',
|
||||
callId: message.callId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} satisfies WorkerApiResultMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private rejectActiveAndQueue(error: Error): void {
|
||||
if (this.activeRequest) {
|
||||
if (this.activeRequest.timeoutId) {
|
||||
@@ -310,7 +367,8 @@ let pythonMacroWorkerRuntimeInstance: PythonMacroWorkerRuntime | null = null;
|
||||
|
||||
export function getPythonMacroWorkerRuntime(): PythonMacroWorkerRuntime {
|
||||
if (!pythonMacroWorkerRuntimeInstance) {
|
||||
pythonMacroWorkerRuntimeInstance = new PythonMacroWorkerRuntime();
|
||||
const { invokeMainProcessPythonApi } = require('./mainProcessPythonApiInvoker') as { invokeMainProcessPythonApi: ApiInvoker };
|
||||
pythonMacroWorkerRuntimeInstance = new PythonMacroWorkerRuntime(undefined, invokeMainProcessPythonApi);
|
||||
}
|
||||
|
||||
return pythonMacroWorkerRuntimeInstance;
|
||||
|
||||
232
src/main/engine/mainProcessPythonApiInvoker.ts
Normal file
232
src/main/engine/mainProcessPythonApiInvoker.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { getPythonApiMethodContract } from '../shared/pythonApiContractV1';
|
||||
import type { PythonApiParamContractV1 } from '../shared/pythonApiContractV1';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function validateParamValue(methodName: string, param: PythonApiParamContractV1, value: unknown): void {
|
||||
if (param.type === 'stringOrNull') {
|
||||
if (value === null || (typeof value === 'string' && value.length > 0)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${methodName} requires stringOrNull arg ${param.name}`);
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
if (!param.required) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${methodName} requires ${param.type} arg ${param.name}`);
|
||||
}
|
||||
|
||||
if (param.type === 'any') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (param.type === 'string') {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${methodName} requires string arg ${param.name}`);
|
||||
}
|
||||
|
||||
if (param.type === 'number') {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${methodName} requires number arg ${param.name}`);
|
||||
}
|
||||
|
||||
if (param.type === 'boolean') {
|
||||
if (typeof value === 'boolean') {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${methodName} requires boolean arg ${param.name}`);
|
||||
}
|
||||
|
||||
if (param.type === 'array') {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${methodName} requires array arg ${param.name}`);
|
||||
}
|
||||
|
||||
if (param.type === 'object') {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${methodName} requires object arg ${param.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
type EngineGetter = () => Record<string, (...args: unknown[]) => unknown>;
|
||||
|
||||
const ENGINE_MAP: Record<string, EngineGetter> = {
|
||||
posts: () => {
|
||||
const { getPostEngine } = require('../engine/PostEngine');
|
||||
return getPostEngine();
|
||||
},
|
||||
media: () => {
|
||||
const { getMediaEngine } = require('../engine/MediaEngine');
|
||||
return getMediaEngine();
|
||||
},
|
||||
projects: () => {
|
||||
const { getProjectEngine } = require('../engine/ProjectEngine');
|
||||
return getProjectEngine();
|
||||
},
|
||||
meta: () => {
|
||||
const { getMetaEngine } = require('../engine/MetaEngine');
|
||||
return getMetaEngine();
|
||||
},
|
||||
tags: () => {
|
||||
const { getTagEngine } = require('../engine/TagEngine');
|
||||
return getTagEngine();
|
||||
},
|
||||
scripts: () => {
|
||||
const { getScriptEngine } = require('../engine/ScriptEngine');
|
||||
return getScriptEngine();
|
||||
},
|
||||
tasks: () => {
|
||||
const { taskManager } = require('../engine/TaskManager');
|
||||
return taskManager;
|
||||
},
|
||||
};
|
||||
|
||||
// Map API method names to engine method names where they differ
|
||||
const METHOD_NAME_MAP: Record<string, string> = {
|
||||
'posts.get': 'getPost',
|
||||
'posts.create': 'createPost',
|
||||
'posts.update': 'updatePost',
|
||||
'posts.delete': 'deletePost',
|
||||
'posts.getAll': 'getAllPosts',
|
||||
'posts.getByStatus': 'getPostsByStatus',
|
||||
'posts.publish': 'publishPost',
|
||||
'posts.discard': 'discardChanges',
|
||||
'posts.hasPublishedVersion': 'hasPublishedVersion',
|
||||
'posts.rebuildFromFiles': 'rebuildDatabaseFromFiles',
|
||||
'posts.reindexText': 'reindexText',
|
||||
'posts.search': 'searchPosts',
|
||||
'posts.filter': 'getPostsFiltered',
|
||||
'posts.getTags': 'getAvailableTags',
|
||||
'posts.getCategories': 'getAvailableCategories',
|
||||
'posts.getByYearMonth': 'getPostsByYearMonth',
|
||||
'posts.getDashboardStats': 'getDashboardStats',
|
||||
'posts.getTagsWithCounts': 'getTagsWithCounts',
|
||||
'posts.getCategoriesWithCounts': 'getCategoriesWithCounts',
|
||||
'posts.getLinksTo': 'getLinksTo',
|
||||
'posts.getLinkedBy': 'getLinkedBy',
|
||||
'posts.rebuildLinks': 'rebuildAllPostLinks',
|
||||
'posts.isSlugAvailable': 'isSlugAvailable',
|
||||
'posts.generateUniqueSlug': 'generateUniqueSlug',
|
||||
'posts.getPreviewUrl': 'getPost', // handled specially
|
||||
'media.import': 'importMedia',
|
||||
'media.update': 'updateMedia',
|
||||
'media.replaceFile': 'replaceMediaFile',
|
||||
'media.delete': 'deleteMedia',
|
||||
'media.get': 'getMedia',
|
||||
'media.getUrl': 'getRelativePath',
|
||||
'media.getAll': 'getAllMedia',
|
||||
'media.rebuildFromFiles': 'rebuildDatabaseFromFiles',
|
||||
'media.reindexText': 'reindexText',
|
||||
'media.getThumbnail': 'getThumbnailDataUrl',
|
||||
'media.regenerateThumbnails': 'generateThumbnails',
|
||||
'media.regenerateMissingThumbnails': 'regenerateMissingThumbnails',
|
||||
'media.filter': 'getMediaFiltered',
|
||||
'media.search': 'searchMedia',
|
||||
'media.getByYearMonth': 'getMediaByYearMonth',
|
||||
'media.getTags': 'getAvailableTags',
|
||||
'media.getTagsWithCounts': 'getTagsWithCounts',
|
||||
'projects.create': 'createProject',
|
||||
'projects.update': 'updateProject',
|
||||
'projects.delete': 'deleteProject',
|
||||
'projects.deleteWithData': 'deleteProjectWithData',
|
||||
'projects.get': 'getProject',
|
||||
'projects.getAll': 'getAllProjects',
|
||||
'projects.getActive': 'getActiveProject',
|
||||
'projects.setActive': 'setActiveProject',
|
||||
'meta.getTags': 'getTags',
|
||||
'meta.getCategories': 'getCategories',
|
||||
'meta.addTag': 'addTag',
|
||||
'meta.removeTag': 'removeTag',
|
||||
'meta.addCategory': 'addCategory',
|
||||
'meta.removeCategory': 'removeCategory',
|
||||
'meta.syncOnStartup': 'syncOnStartup',
|
||||
'meta.getProjectMetadata': 'getProjectMetadata',
|
||||
'meta.setProjectMetadata': 'setProjectMetadata',
|
||||
'meta.updateProjectMetadata': 'updateProjectMetadata',
|
||||
'tags.getAll': 'getAllTags',
|
||||
'tags.getWithCounts': 'getTagsWithCounts',
|
||||
'tags.get': 'getTag',
|
||||
'tags.getByName': 'getTagByName',
|
||||
'tags.create': 'createTag',
|
||||
'tags.update': 'updateTag',
|
||||
'tags.delete': 'deleteTag',
|
||||
'tags.merge': 'mergeTags',
|
||||
'tags.rename': 'renameTag',
|
||||
'tags.getPostsWithTag': 'getPostsWithTag',
|
||||
'tags.syncFromPosts': 'syncTagsFromPosts',
|
||||
'scripts.create': 'createScript',
|
||||
'scripts.update': 'updateScript',
|
||||
'scripts.delete': 'deleteScript',
|
||||
'scripts.get': 'getScript',
|
||||
'scripts.getAll': 'getAllScripts',
|
||||
'scripts.rebuildFromFiles': 'rebuildDatabaseFromFiles',
|
||||
'tasks.getAll': 'getAllTasks',
|
||||
'tasks.getRunning': 'getRunningTasks',
|
||||
'tasks.cancel': 'cancelTask',
|
||||
'tasks.clearCompleted': 'clearCompletedTasks',
|
||||
};
|
||||
|
||||
export async function invokeMainProcessPythonApi(method: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const contract = getPythonApiMethodContract(method);
|
||||
if (!contract) {
|
||||
throw new Error(`Unsupported Python API method: ${method}`);
|
||||
}
|
||||
|
||||
const normalizedArgs = asRecord(args);
|
||||
|
||||
const [namespace, member] = contract.method.split('.');
|
||||
if (!namespace || !member) {
|
||||
throw new Error(`Unsupported Python API method: ${method}`);
|
||||
}
|
||||
|
||||
// Skip methods that require UI/dialog interaction or are not safe for background use
|
||||
const unsafeMethods = new Set([
|
||||
'media.importDialog', 'media.replaceFileDialog', 'media.getFilePath',
|
||||
'app.openFolder', 'app.selectFolder', 'app.showItemInFolder',
|
||||
'app.getTitleBarMetrics', 'app.notifyRendererReady', 'app.triggerMenuAction',
|
||||
'app.getBlogmarkBookmarklet', 'app.copyToClipboard',
|
||||
'chat.sendMessage', 'chat.abortMessage', 'chat.analyzeTaxonomy',
|
||||
'chat.analyzeMediaImage',
|
||||
'sync.configure', 'sync.start', 'sync.stopAutoSync',
|
||||
]);
|
||||
|
||||
if (unsafeMethods.has(method)) {
|
||||
throw new Error(`Python API method '${method}' is not available in main-process macro context`);
|
||||
}
|
||||
|
||||
const engineGetter = ENGINE_MAP[namespace];
|
||||
if (!engineGetter) {
|
||||
throw new Error(`Unsupported Python API namespace: ${namespace}`);
|
||||
}
|
||||
|
||||
const engine = engineGetter();
|
||||
const engineMethodName = METHOD_NAME_MAP[method] ?? member;
|
||||
const callable = engine[engineMethodName];
|
||||
|
||||
if (typeof callable !== 'function') {
|
||||
throw new Error(`Unsupported Python API method: ${method} (engine method '${engineMethodName}' not found)`);
|
||||
}
|
||||
|
||||
const orderedArgs = contract.params.map((param) => {
|
||||
const value = normalizedArgs[param.name];
|
||||
validateParamValue(contract.method, param, value);
|
||||
return value;
|
||||
});
|
||||
|
||||
return callable.apply(engine, orderedArgs);
|
||||
}
|
||||
@@ -6,9 +6,20 @@ interface WorkerRenderMacroRequest {
|
||||
scriptContent: string;
|
||||
entrypoint: string;
|
||||
contextJson: string;
|
||||
postDataJson?: string | null;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
interface WorkerApiResultMessage {
|
||||
type: 'apiResult';
|
||||
callId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type WorkerIncomingMessage = WorkerRenderMacroRequest | WorkerApiResultMessage;
|
||||
|
||||
interface WorkerReadyMessage {
|
||||
type: 'ready';
|
||||
}
|
||||
@@ -32,27 +43,131 @@ interface WorkerFatalErrorMessage {
|
||||
error: string;
|
||||
}
|
||||
|
||||
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage;
|
||||
interface WorkerApiCallMessage {
|
||||
type: 'apiCall';
|
||||
requestId: string;
|
||||
callId: string;
|
||||
method: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | WorkerMacroErrorMessage | WorkerFatalErrorMessage | WorkerApiCallMessage;
|
||||
|
||||
type PyodideRuntime = {
|
||||
globals: {
|
||||
set: (name: string, value: unknown) => void;
|
||||
} | any;
|
||||
runPythonAsync: (code: string) => Promise<unknown>;
|
||||
registerJsModule: (name: string, module: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
let runtimePromise: Promise<PyodideRuntime> | null = null;
|
||||
let lastCacheKey: string | null = null;
|
||||
let activeRequestId: string | null = null;
|
||||
let apiCallCounter = 0;
|
||||
|
||||
function postMessage(message: WorkerResponseMessage): void {
|
||||
interface PendingApiCall {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
const pendingApiCalls = new Map<string, PendingApiCall>();
|
||||
|
||||
function postWorkerMessage(message: WorkerResponseMessage): void {
|
||||
parentPort?.postMessage(message);
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requestHostApi(requestId: string, method: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
apiCallCounter += 1;
|
||||
const callId = `api-${apiCallCounter}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingApiCalls.set(callId, { resolve, reject });
|
||||
|
||||
postWorkerMessage({
|
||||
type: 'apiCall',
|
||||
requestId,
|
||||
callId,
|
||||
method,
|
||||
args,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleApiResultMessage(message: WorkerApiResultMessage): void {
|
||||
const pendingCall = pendingApiCalls.get(message.callId);
|
||||
if (!pendingCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingApiCalls.delete(message.callId);
|
||||
|
||||
if (message.ok) {
|
||||
pendingCall.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCall.reject(new Error(message.error ?? 'Host API call failed'));
|
||||
}
|
||||
|
||||
function rejectPendingApiCalls(message: string): void {
|
||||
for (const [callId, pendingCall] of pendingApiCalls.entries()) {
|
||||
pendingApiCalls.delete(callId);
|
||||
pendingCall.reject(new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
async function getRuntime(): Promise<PyodideRuntime> {
|
||||
if (!runtimePromise) {
|
||||
runtimePromise = (async () => {
|
||||
const pyodideModule = await import('pyodide');
|
||||
return (await pyodideModule.loadPyodide()) as unknown as PyodideRuntime;
|
||||
const runtime = (await pyodideModule.loadPyodide()) as unknown as PyodideRuntime;
|
||||
|
||||
// Register bds_api transport bridge
|
||||
runtime.registerJsModule('__bds_transport', {
|
||||
call_host_api: async (method: unknown, argsJson: unknown) => {
|
||||
if (!activeRequestId) {
|
||||
throw new Error('No active Python request for host API bridge');
|
||||
}
|
||||
|
||||
if (typeof method !== 'string' || method.length === 0) {
|
||||
throw new Error('Host API method must be a non-empty string');
|
||||
}
|
||||
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
if (typeof argsJson === 'string' && argsJson.length > 0) {
|
||||
const decoded = JSON.parse(argsJson);
|
||||
parsedArgs = toRecord(decoded);
|
||||
}
|
||||
|
||||
const result = await requestHostApi(activeRequestId, method, parsedArgs);
|
||||
return JSON.stringify(result ?? null);
|
||||
},
|
||||
});
|
||||
|
||||
// Install bds_api module
|
||||
const { generatePythonApiModuleV1 } = await import('../shared/generatePythonApiModuleV1');
|
||||
runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1());
|
||||
await runtime.runPythonAsync(`
|
||||
import sys
|
||||
import types
|
||||
|
||||
__bds_api_module = types.ModuleType("bds_api")
|
||||
exec(__bds_api_module_source, __bds_api_module.__dict__)
|
||||
|
||||
from __bds_transport import call_host_api as __bds_call_host_api
|
||||
__bds_api_module.bds = __bds_api_module.install_bds_api(__bds_call_host_api)
|
||||
sys.modules["bds_api"] = __bds_api_module
|
||||
`);
|
||||
|
||||
return runtime;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -60,6 +175,7 @@ async function getRuntime(): Promise<PyodideRuntime> {
|
||||
}
|
||||
|
||||
async function renderMacro(request: WorkerRenderMacroRequest): Promise<void> {
|
||||
activeRequestId = request.requestId;
|
||||
try {
|
||||
const runtime = await getRuntime();
|
||||
|
||||
@@ -72,6 +188,7 @@ async function renderMacro(request: WorkerRenderMacroRequest): Promise<void> {
|
||||
|
||||
runtime.globals.set('__bds_macro_context_json', request.contextJson);
|
||||
runtime.globals.set('__bds_macro_entrypoint', request.entrypoint);
|
||||
runtime.globals.set('__bds_macro_post_data_json', request.postDataJson ?? '');
|
||||
|
||||
const rawResult = await runtime.runPythonAsync(`
|
||||
import json as _json
|
||||
@@ -81,7 +198,9 @@ _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)
|
||||
_macro_post_json = __bds_macro_post_data_json
|
||||
_macro_post = _json.loads(_macro_post_json) if _macro_post_json else None
|
||||
_macro_result = _macro_fn(_macro_ctx, _macro_post)
|
||||
if _macro_result is None:
|
||||
raise RuntimeError("Macro function returned None")
|
||||
if not isinstance(_macro_result, dict):
|
||||
@@ -93,7 +212,7 @@ _json.dumps(_macro_result)
|
||||
|
||||
const parsed = JSON.parse(String(rawResult));
|
||||
|
||||
postMessage({
|
||||
postWorkerMessage({
|
||||
type: 'macroResult',
|
||||
requestId: request.requestId,
|
||||
html: typeof parsed.html === 'string' ? parsed.html : '',
|
||||
@@ -101,12 +220,21 @@ _json.dumps(_macro_result)
|
||||
warnings: Array.isArray(parsed.warnings) ? parsed.warnings : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
rejectPendingApiCalls('Python macro execution failed');
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postMessage({ type: 'macroError', requestId: request.requestId, error: message });
|
||||
postWorkerMessage({ type: 'macroError', requestId: request.requestId, error: message });
|
||||
} finally {
|
||||
rejectPendingApiCalls('Python macro execution finished');
|
||||
activeRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
parentPort?.on('message', (message: WorkerRenderMacroRequest) => {
|
||||
parentPort?.on('message', (message: WorkerIncomingMessage) => {
|
||||
if (message.type === 'apiResult') {
|
||||
handleApiResultMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type !== 'renderMacro') {
|
||||
return;
|
||||
}
|
||||
@@ -116,9 +244,9 @@ parentPort?.on('message', (message: WorkerRenderMacroRequest) => {
|
||||
|
||||
void getRuntime()
|
||||
.then(() => {
|
||||
postMessage({ type: 'ready' });
|
||||
postWorkerMessage({ type: 'ready' });
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
postMessage({ type: 'error', error: message });
|
||||
postWorkerMessage({ type: 'error', error: message });
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<article class="single-post" data-template="single-post">
|
||||
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
|
||||
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user