import { z } from 'zod'; import { getScriptEngine } from './ScriptEngine'; import { getMetaEngine } from './MetaEngine'; import { getBlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime'; const transformPostSchema = z.object({ title: z.string().trim().min(1), content: z.string().trim().min(1), tags: z.array(z.string().trim().min(1)), categories: z.array(z.string().trim().min(1)), }); export type BlogmarkTransformedPost = z.infer; export interface BlogmarkTransformInput { post: BlogmarkTransformedPost; context: { source: 'blogmark'; url: string; }; } export interface BlogmarkTransformScriptRecord { id: string; slug: string; title: string; kind: 'macro' | 'utility' | 'transform'; entrypoint: string; enabled: boolean; content: string; updatedAt: Date | string; } export interface BlogmarkTransformExecutor { runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise; } export interface BlogmarkTransformScriptProvider { getScripts(): Promise; } export interface BlogmarkTransformError { scriptId: string; scriptSlug: string; message: string; } export interface BlogmarkTransformExecutionData { output: unknown; toasts: string[]; } export interface BlogmarkTransformResult { post: BlogmarkTransformedPost; appliedScriptIds: string[]; errors: BlogmarkTransformError[]; toasts: string[]; } export type PythonRuntimeMode = 'webworker' | 'main-thread'; const MAX_TOASTS_PER_SCRIPT = 5; const MAX_TOASTS_TOTAL = 20; const MAX_TOAST_LENGTH = 300; const scriptEngineBackedProvider: BlogmarkTransformScriptProvider = { async getScripts() { return getScriptEngine().getAllScripts(); }, }; function toTimestamp(value: Date | string): number { if (value instanceof Date) { return value.getTime(); } const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; } function normalizePost(value: unknown): BlogmarkTransformedPost | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } const valueRecord = value as Record; const maybePost = valueRecord.post; const candidate = maybePost && typeof maybePost === 'object' && !Array.isArray(maybePost) ? maybePost : value; const parsed = transformPostSchema.safeParse(candidate); if (!parsed.success) { return null; } return parsed.data; } function normalizeToastMessage(value: unknown): string | null { if (value === undefined || value === null) { return null; } const normalized = String(value).trim(); if (normalized.length === 0) { return null; } return normalized.slice(0, MAX_TOAST_LENGTH); } function toExecutionData(value: unknown): BlogmarkTransformExecutionData { if (value && typeof value === 'object' && !Array.isArray(value)) { const valueRecord = value as Record; const toasts = Array.isArray(valueRecord.toasts) ? valueRecord.toasts .map((item) => normalizeToastMessage(item)) .filter((item): item is string => item !== null) : []; if (Object.prototype.hasOwnProperty.call(valueRecord, 'output')) { return { output: valueRecord.output, toasts, }; } return { output: value, toasts, }; } return { output: value, toasts: [], }; } function toErrorMessage(error: unknown): string { if (error instanceof Error && typeof error.message === 'string' && error.message.trim().length > 0) { return error.message; } return String(error); } function resolveTransformEntrypoint(value: string): string { const nextEntrypoint = typeof value === 'string' ? value.trim() : ''; if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(nextEntrypoint) && nextEntrypoint !== 'main') { return nextEntrypoint; } return 'transform'; } function resolvePythonRuntimeMode(value: unknown): PythonRuntimeMode { if (value === 'main-thread') { return 'main-thread'; } return 'webworker'; } async function getConfiguredPythonRuntimeMode(): Promise { const metadata = await getMetaEngine().getProjectMetadata(); return resolvePythonRuntimeMode((metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode); } class PythonBlogmarkTransformExecutor implements BlogmarkTransformExecutor { private runtimePromise: Promise | null = null; async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise { const runtime = await this.getRuntime(); const toastMessages: string[] = []; const pushToast = (message: unknown): void => { if (toastMessages.length >= MAX_TOASTS_PER_SCRIPT) { return; } const normalizedMessage = normalizeToastMessage(message); if (!normalizedMessage) { return; } toastMessages.push(normalizedMessage); }; runtime.globals.set('__bds_push_toast', pushToast); await runtime.runPythonAsync(` def toast(message): __bds_push_toast(str(message)) `); await runtime.runPythonAsync(script.content); const requestedEntrypoint = resolveTransformEntrypoint(script.entrypoint); const payload = JSON.stringify(input); runtime.globals.set('__bds_transform_payload_json', payload); runtime.globals.set('__bds_transform_entrypoint', requestedEntrypoint); const rawResult = await runtime.runPythonAsync(` import json _payload = json.loads(__bds_transform_payload_json) _entrypoint = __bds_transform_entrypoint _transform_fn = globals().get(_entrypoint) if _transform_fn is None or not callable(_transform_fn): raise RuntimeError(f"Transform entrypoint '{_entrypoint}' is not callable") _post = _payload.get("post") if not isinstance(_post, dict): raise RuntimeError("Transform payload is missing a valid 'post' object") _context = _payload.get("context") try: _result = _transform_fn(_post, _context) except TypeError: _result = _transform_fn(_post) if _result is None: _result = _post json.dumps(_result) `); return { output: JSON.parse(String(rawResult)), toasts: toastMessages, }; } private async getRuntime(): Promise { if (!this.runtimePromise) { this.runtimePromise = (async () => { const pyodideModule = await import('pyodide'); return pyodideModule.loadPyodide(); })(); } return this.runtimePromise; } } class PythonWorkerBlogmarkTransformExecutor implements BlogmarkTransformExecutor { async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise { return getBlogmarkPythonWorkerRuntime().executeTransform({ scriptContent: script.content, entrypoint: resolveTransformEntrypoint(script.entrypoint), payloadJson: JSON.stringify(input), }); } } const mainThreadExecutor = new PythonBlogmarkTransformExecutor(); const workerExecutor = new PythonWorkerBlogmarkTransformExecutor(); export class BlogmarkTransformService { constructor( private readonly dependencies: { provider?: BlogmarkTransformScriptProvider; executor?: BlogmarkTransformExecutor; resolvePythonRuntimeMode?: () => Promise; executors?: Partial>; } = {}, ) {} async applyTransforms(input: BlogmarkTransformInput): Promise { const parsedInput = transformPostSchema.parse(input.post); const transformInput: BlogmarkTransformInput = { ...input, post: parsedInput, }; const provider = this.dependencies.provider ?? scriptEngineBackedProvider; const executor = this.dependencies.executor ?? await this.resolveExecutorForConfiguredRuntime(); const scripts = await provider.getScripts(); const activeTransforms = scripts .filter((script) => script.enabled && script.kind === 'transform') .sort((left, right) => { const byUpdatedAt = toTimestamp(left.updatedAt) - toTimestamp(right.updatedAt); if (byUpdatedAt !== 0) { return byUpdatedAt; } const bySlug = left.slug.localeCompare(right.slug); if (bySlug !== 0) { return bySlug; } return left.id.localeCompare(right.id); }); let currentPost = transformInput.post; const appliedScriptIds: string[] = []; const errors: BlogmarkTransformError[] = []; const toasts: string[] = []; for (const script of activeTransforms) { try { const execution = await executor.runTransform(script, { ...transformInput, post: currentPost, }); const executionData = toExecutionData(execution); const nextToasts = executionData.toasts .map((message) => normalizeToastMessage(message)) .filter((message): message is string => message !== null); if (nextToasts.length > 0 && toasts.length < MAX_TOASTS_TOTAL) { const remaining = MAX_TOASTS_TOTAL - toasts.length; toasts.push(...nextToasts.slice(0, remaining)); } const normalizedPost = normalizePost(executionData.output); if (!normalizedPost) { throw new Error('Transform output validation failed'); } currentPost = normalizedPost; appliedScriptIds.push(script.id); } catch (error) { const message = toErrorMessage(error); errors.push({ scriptId: script.id, scriptSlug: script.slug, message, }); console.error(`[blogmark-transform] ${script.slug}: ${message}`); } } return { post: currentPost, appliedScriptIds, errors, toasts, }; } private async resolveExecutorForConfiguredRuntime(): Promise { const resolveMode = this.dependencies.resolvePythonRuntimeMode ?? getConfiguredPythonRuntimeMode; const mode = await resolveMode(); const executors = this.dependencies.executors ?? {}; if (mode === 'main-thread') { return executors['main-thread'] ?? mainThreadExecutor; } return executors.webworker ?? workerExecutor; } } let blogmarkTransformServiceInstance: BlogmarkTransformService | null = null; export function getBlogmarkTransformService(): BlogmarkTransformService { if (!blogmarkTransformServiceInstance) { blogmarkTransformServiceInstance = new BlogmarkTransformService(); } return blogmarkTransformServiceInstance; }