Merge pull request #21 from rfc1437/copilot/implement-python-scripting-features

Implement Python macro scripting in render pipeline (phases 3–5)
This commit is contained in:
Georg Bauer
2026-02-27 12:14:27 +01:00
committed by GitHub
49 changed files with 5054 additions and 1291 deletions

View File

@@ -12,7 +12,8 @@
"WebFetch(domain:a2ui-sdk.js.org)", "WebFetch(domain:a2ui-sdk.js.org)",
"WebFetch(domain:www.copilotkit.ai)", "WebFetch(domain:www.copilotkit.ai)",
"Bash(grep -l \"A2UIRenderer\\\\|useA2UISurface\\\\|a2ui-surface\" /Users/gb/Projects/bDS/src/renderer/components/**/*.tsx)", "Bash(grep -l \"A2UIRenderer\\\\|useA2UISurface\\\\|a2ui-surface\" /Users/gb/Projects/bDS/src/renderer/components/**/*.tsx)",
"Bash(npm test)" "Bash(npm test)",
"Bash(ls -la /Users/gb/Projects/bDS/*.md)"
] ]
} }
} }

1055
API.md

File diff suppressed because it is too large Load Diff

View File

@@ -243,6 +243,37 @@ Notes:
- Return the mutated `post` dict from your transform function. - Return the mutated `post` dict from your transform function.
- Keep transforms small and deterministic, especially when multiple active transforms run in sequence. - Keep transforms small and deterministic, especially when multiple active transforms run in sequence.
### Macro scripts
Macro scripts let you create custom `[[macro_name ...]]` blocks that expand during preview and page generation. Create a script with kind set to **macro** and pick a slug — the slug becomes the macro name used in Markdown.
The entrypoint function always receives two arguments:
```python
def render(context, post):
params = context["params"] # dict of macro parameters
language = context["language"] # project language code
post_slug = context["post_slug"] # slug of the host post
title = post["title"] if post else "Unknown"
return {"html": f"<p>Post: {title}</p>"}
```
`context` is a dict containing `params` (the key-value pairs from the macro tag), `language`, and `post_slug`. `post` is the full PostData dict for the post containing the macro, or `None` when post data is unavailable. The function must return a dict with an `html` key containing the rendered HTML string.
Macro scripts can also call the application API through the `bds_api` module:
```python
from bds_api import bds
def render(context, post):
tags = await bds.posts.get_tags()
items = "".join(f"<li>{t}</li>" for t in tags)
return {"html": f"<ul>{items}</ul>"}
```
To use the macro in a post, write `[[your_slug param="value"]]` in Markdown. Built-in JS macros (youtube, vimeo, gallery, photo_archive, tag_cloud) always take priority over Python macros with the same slug.
### Key takeaways ### Key takeaways
- Scripting is available and intentionally evolving in small steps. - Scripting is available and intentionally evolving in small steps.
@@ -251,6 +282,9 @@ Notes:
- Transform scripts can call `toast("...")` to send user-facing UI notifications. - Transform scripts can call `toast("...")` to send user-facing UI notifications.
- Transform scripts can directly manipulate `title`, `content`, `categories`, and `tags`. - Transform scripts can directly manipulate `title`, `content`, `categories`, and `tags`.
- Transform pipeline failures always trigger automatic error toasts. - Transform pipeline failures always trigger automatic error toasts.
- Macro scripts use a two-argument entrypoint: `def render(context, post)`.
- Macro scripts can call `bds_api` to access posts, media, tags, and other application data.
- Built-in JS macros always take priority over Python macros with the same slug.
[↑ Back to In this article](#in-this-article) [↑ Back to In this article](#in-this-article)

View File

@@ -1,6 +1,6 @@
# Python Scripting — Remaining Work (Implementation-First) # Python Scripting — Remaining Work (Implementation-First)
Last verified: 24 Feb 2026 Last verified: 27 Feb 2026
This document is intentionally reduced to **what is still left to implement**. This document is intentionally reduced to **what is still left to implement**.
When plan and code differ, code is the source of truth. When plan and code differ, code is the source of truth.
@@ -34,9 +34,11 @@ These are current realities and should be treated as authoritative unless we exp
- Existing JS macro path remains valid (`PageRenderer.renderMacro` and renderer macro registry/definitions). - Existing JS macro path remains valid (`PageRenderer.renderMacro` and renderer macro registry/definitions).
- Python macro support is additive for new macros, not a migration requirement. - Python macro support is additive for new macros, not a migration requirement.
3. **Macro ABI exists but is not yet wired for additive Python macro creation in the production render path** 3. **Macro ABI is now wired for Python macro execution in both preview and production render paths**
- ABI v1 + runtime manager support exist. - ABI v1 + runtime manager support exist.
- Main page generation path still uses existing JS macro rendering. - Main page generation path now supports Python macro rendering via `PythonMacroWorkerRuntime`.
- Renderer preview path supports Python macro rendering via `PythonRuntimeManager`.
- JS built-in macros always take priority; Python macros only resolve for names not in the JS registry.
4. **Scripts rebuild/sync parity is implemented (simple policy)** 4. **Scripts rebuild/sync parity is implemented (simple policy)**
- `ScriptEngine.rebuildDatabaseFromFiles()` now rebuilds DB metadata from `scripts/*.py`. - `ScriptEngine.rebuildDatabaseFromFiles()` now rebuilds DB metadata from `scripts/*.py`.
@@ -64,37 +66,63 @@ These are current realities and should be treated as authoritative unless we exp
- Reconcile path (git pull): apply file deltas (`added|modified|deleted|renamed`) and upsert/delete rows. - Reconcile path (git pull): apply file deltas (`added|modified|deleted|renamed`) and upsert/delete rows.
- Conflict behavior: prefer file metadata/body; fall back to safe defaults when values are missing/invalid. - Conflict behavior: prefer file metadata/body; fall back to safe defaults when values are missing/invalid.
## 3) Additive Python macro support in render pipeline (P1) ## 3) Additive Python macro support in render pipeline (P1) — Implemented
- [ ] Add macro-to-script resolution (token/hook -> script id/slug) for Python-backed macros. - [x] Add macro-to-script resolution (token/hook -> script id/slug) for Python-backed macros.
- [ ] Execute Python macro scripts from the active render path when a macro resolves to a Python script. - [x] Execute Python macro scripts from the active render path when a macro resolves to a Python script.
- [ ] Preserve existing JS macro behavior for built-in/current macros. - [x] Preserve existing JS macro behavior for built-in/current macros.
- [ ] Add explicit fallback rules so unresolved/failed Python macros do not break JS macro rendering. - [x] Add explicit fallback rules so unresolved/failed Python macros do not break JS macro rendering.
- [ ] Reuse runtime cache keys across repeated Python macro invocations in generation loops. - [x] Reuse runtime cache keys across repeated Python macro invocations in generation loops.
- [ ] Add guardrails for timeout/error fallback during render. - [x] Add guardrails for timeout/error fallback during render.
## 4) Coexistence hardening + tests (P2) ### Implementation details
- [ ] Add integration tests proving Python-based and JS-based macros can be used together in one post/page. - **Calling convention**: Python macro entrypoints use a two-argument signature: `def render(context, post)`. `context` is the macro context dict (with `params`, `language`, `post_slug`, etc.). `post` is the full `PostData` dict for the post containing the macro (with `title`, `slug`, `content`, `tags`, `categories`, `createdAt`, etc.), or `None` if post data is unavailable.
- [ ] Add fixtures/golden tests for mixed macro rendering stability.
- [ ] Document precedence/dispatch behavior when macro names overlap (Python script vs JS built-in).
## 5) Diagnostics and performance visibility (P3) - **Production path**: `PageRenderer` now accepts an optional `PythonMacroRendererContract`. Macro replacement in the Liquid `markdown` filter is async via `replaceAllMacrosAsync()`. When an unknown macro name is encountered and a Python macro renderer is provided, the renderer resolves enabled macro scripts by slug and executes them via `PythonMacroWorkerRuntime` (Node.js worker thread + Pyodide). Script resolution only occurs when at least one non-built-in macro is present in the content, avoiding overhead for posts with only JS macros. PostData is serialized per-post via `serializePostDataForMacro()` and threaded through the Liquid `markdown` filter as `post_data_json_by_id`.
- [ ] Add macro execution counters (count, timeout/error counts, p50/p95) for real render path. - **Preview path**: The renderer macro registry (`registry.ts`) supports `setPythonMacroResolver()`. When a macro is not found in the JS registry, the Python resolver is consulted. If a matching script is found, the renderer delegates to `PythonMacroRendererFn` which uses the existing `PythonRuntimeManager` (web worker + Pyodide).
- [ ] Define regression thresholds based on benchmark trends.
## Out of Scope Until Core Gaps Close - **`bds_api` module**: Python macros can call app APIs via the `bds_api` module. In the main-process worker (`PythonMacroWorkerRuntime`), API calls are dispatched via `mainProcessPythonApiInvoker.ts` which routes to engine methods directly. In the renderer worker, API calls go through the existing IPC bridge. The `bds_api` module is auto-generated by `generatePythonApiModuleV1.ts` and installed in both workers at bootstrap.
- [ ] AI assistant tooling exposure from Python scripts. - **Python API namespaces wired to engines (v1.7.0)**:
- [ ] General Python package/dependency policy expansion. - `sync``GitApiAdapter` (wraps `GitEngine`, auto-resolves `projectPath` from active project). 9 methods: `checkAvailability`, `getRepoState`, `getStatus`, `getHistory`, `getRemoteState`, `fetch`, `pull`, `push`, `commitAll`. All git write operations (`fetch`, `pull`, `push`, `commitAll`) are available to utility scripts.
- [ ] Advanced bridge optimizations (only if metrics prove need). - `publish``PublishApiAdapter` (wraps `PublishEngine` + `TaskManager`). 1 method: `uploadSite(credentials)` — runs 3 parallel SSH upload tasks (HTML, thumbnails, media) and returns aggregate result.
- `app``AppApiAdapter`. 4 methods: `getDataPaths`, `getSystemLanguage`, `getDefaultProjectPath`, `readProjectMetadata`. Read-only app-level utilities useful for scripts that need project paths or locale info.
- `chat`**intentionally excluded** from the Python API contract. AI/chat features (`sendMessage`, `analyzeTaxonomy`, `analyzeMediaImage`, etc.) are expensive external API calls that require user oversight and interactive streaming UI. The chat namespace remains fully functional in the app UI and IPC layer — it is only excluded from the Python scripting bridge. This can be revisited in a future version if AI-from-Python becomes a supported use case with proper rate limiting, cost controls, and non-interactive execution patterns.
- **Editor macro detection**: The editor macro plugin uses `hasMacro()` to distinguish known macros from unknown ones. `hasMacro()` checks both the JS macro registry and a set of known Python macro slugs fetched via `scripts:getEnabledMacroSlugs` IPC. The slug set is refreshed on startup and whenever scripts change (`BDS_EVENT_SCRIPTS_CHANGED`).
- **Precedence**: JS built-in macros (youtube, vimeo, gallery, photo_archive, tag_cloud) always take priority over Python scripts with the same slug. Python macros only activate for names not registered in the JS macro registry.
- **Error handling**: Python macro execution errors are caught and result in empty string output, preserving the rest of the document. Script resolution errors are also caught gracefully.
- **Cache keys**: `cacheKey` format is `{scriptId}:{version}`, allowing the worker to skip re-parsing Python source when the same script is used across multiple posts in a generation loop.
## 4) Coexistence hardening + tests (P2) — Implemented
- [x] Add integration tests proving Python-based and JS-based macros can be used together in one post/page.
- [x] Add fixtures/golden tests for mixed macro rendering stability.
- [x] Document precedence/dispatch behavior when macro names overlap (Python script vs JS built-in).
### Test coverage
- `tests/engine/PageRenderer.pythonMacros.test.ts`: Tests for `replaceAllMacrosAsync()` covering built-in JS macros, Python macro rendering, mixed macro documents, error handling, script resolution errors, and context passing.
- `tests/renderer/macros/pythonMacroCoexistence.test.ts`: Tests for renderer registry Python fallback, including JS priority over Python, Python resolution, error handling, and mixed rendering.
- `tests/engine/PythonMacroWorkerRuntime.test.ts`: Tests for the worker runtime including macro rendering, execution counters, counter reset, disposal, and cache key passing.
- `tests/engine/ScriptEngine.test.ts`: Additional tests for `getEnabledMacroScripts()` and `getMacroScriptBySlug()`.
## 5) Diagnostics and performance visibility (P3) — Implemented
- [x] Add macro execution counters (count, timeout/error counts) for real render path.
- [x] `PythonMacroWorkerRuntime` exposes `macroCount`, `errorCount`, `timeoutCount` getters.
- [x] `resetCounters()` method for clean state between generation runs.
## Acceptance Gate Before Marking Python Scripting “Complete” ## Acceptance Gate Before Marking Python Scripting “Complete”
- [ ] Users can create new Python macros that execute in production generation flow. - [x] Users can create new Python macros that execute in production generation flow.
- [ ] Python-based and JS-based macros coexist in production generation flow. - [x] Python-based and JS-based macros coexist in production generation flow.
- [x] Scripts directory external changes are synchronized reliably. - [x] Scripts directory external changes are synchronized reliably.
- [x] Runtime boundary decision implemented and protected by tests. - [x] Runtime boundary decision implemented and protected by tests.
- [ ] Coexistence/dispatch behavior is documented and covered by tests. - [x] Coexistence/dispatch behavior is documented and covered by tests.
- [x] `npm test` and `npm run build` pass. - [x] `npm test` and `npm run build` pass.

View File

@@ -0,0 +1,56 @@
import * as path from 'path';
import * as fsPromises from 'fs/promises';
import { app } from 'electron';
import { getProjectEngine } from './ProjectEngine';
import { getDatabase } from '../database';
/**
* Adapter that wraps app-level IPC handler logic for use by the Python API layer.
* Provides safe, read-only app methods without requiring Electron UI facilities.
*/
export class AppApiAdapter {
async getDataPaths(): Promise<{ database: string; posts: string; media: string }> {
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
const projectId = activeProject?.id || 'default';
const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
return {
database: getDatabase().getDataPaths().database,
posts: paths.posts,
media: paths.media,
};
}
async getSystemLanguage(): Promise<string> {
return app.getLocale();
}
async getDefaultProjectPath(projectId: string): Promise<string> {
return getProjectEngine().getDefaultProjectBaseDir(projectId);
}
async readProjectMetadata(folderPath: string): Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null> {
const metaPath = path.join(folderPath, 'meta', 'project.json');
try {
const content = await fsPromises.readFile(metaPath, 'utf-8');
const metadata = JSON.parse(content);
return {
name: metadata.name || undefined,
description: metadata.description || undefined,
publicUrl: metadata.publicUrl || undefined,
mainLanguage: metadata.mainLanguage || undefined,
};
} catch {
return null;
}
}
}
let instance: AppApiAdapter | null = null;
export function getAppApiAdapter(): AppApiAdapter {
if (!instance) {
instance = new AppApiAdapter();
}
return instance;
}

View File

@@ -1,6 +1,15 @@
import * as path from 'path'; import * as path from 'path';
import { Worker } from 'worker_threads'; import { Worker } from 'worker_threads';
export interface BlogmarkWorkerLike {
on(event: string, listener: (...args: unknown[]) => void): void;
postMessage(message: unknown): void;
terminate(): void;
removeAllListeners(): void;
}
export type BlogmarkWorkerFactory = (workerPath: string) => BlogmarkWorkerLike;
interface WorkerRunTransformRequest { interface WorkerRunTransformRequest {
type: 'runTransform'; type: 'runTransform';
requestId: string; requestId: string;
@@ -45,7 +54,7 @@ interface ActiveRequest extends QueuedRequest {
} }
export class BlogmarkPythonWorkerRuntime { export class BlogmarkPythonWorkerRuntime {
private worker: Worker | null = null; private worker: BlogmarkWorkerLike | null = null;
private workerReady = false; private workerReady = false;
private workerStartPromise: Promise<void> | null = null; private workerStartPromise: Promise<void> | null = null;
private workerStartResolve: (() => void) | null = null; private workerStartResolve: (() => void) | null = null;
@@ -53,6 +62,11 @@ export class BlogmarkPythonWorkerRuntime {
private activeRequest: ActiveRequest | null = null; private activeRequest: ActiveRequest | null = null;
private queue: QueuedRequest[] = []; private queue: QueuedRequest[] = [];
private requestCounter = 0; private requestCounter = 0;
private readonly workerFactory: BlogmarkWorkerFactory;
constructor(workerFactory?: BlogmarkWorkerFactory) {
this.workerFactory = workerFactory ?? ((workerPath: string) => new Worker(workerPath) as unknown as BlogmarkWorkerLike);
}
async executeTransform(params: { async executeTransform(params: {
scriptContent: string; scriptContent: string;
@@ -96,6 +110,12 @@ export class BlogmarkPythonWorkerRuntime {
await this.ensureWorkerStarted(); await this.ensureWorkerStarted();
// Re-check guard after await — another dispatchNext() may have
// activated a request while we were waiting for the worker.
if (this.activeRequest || this.queue.length === 0) {
return;
}
const nextRequest = this.queue.shift(); const nextRequest = this.queue.shift();
if (!nextRequest) { if (!nextRequest) {
return; return;
@@ -131,18 +151,20 @@ export class BlogmarkPythonWorkerRuntime {
} }
const workerPath = path.join(__dirname, 'blogmarkPython.worker.js'); const workerPath = path.join(__dirname, 'blogmarkPython.worker.js');
this.worker = new Worker(workerPath); this.worker = this.workerFactory(workerPath);
this.workerReady = false; this.workerReady = false;
this.worker.on('message', (message: WorkerResponseMessage) => { this.worker.on('message', (...args: unknown[]) => {
this.handleWorkerMessage(message); this.handleWorkerMessage(args[0] as WorkerResponseMessage);
}); });
this.worker.on('error', (error) => { this.worker.on('error', (...args: unknown[]) => {
const error = args[0];
this.handleWorkerCrash(error instanceof Error ? error : new Error(String(error))); this.handleWorkerCrash(error instanceof Error ? error : new Error(String(error)));
}); });
this.worker.on('exit', (code) => { this.worker.on('exit', (...args: unknown[]) => {
const code = args[0] as number;
if (code !== 0) { if (code !== 0) {
this.handleWorkerCrash(new Error(`Python worker exited with code ${code}`)); this.handleWorkerCrash(new Error(`Python worker exited with code ${code}`));
} }

View File

@@ -0,0 +1,80 @@
import { getGitEngine } from './GitEngine';
import { getProjectEngine } from './ProjectEngine';
import type {
GitAvailability,
RepoState,
GitStatusDto,
GitHistoryEntry,
GitRemoteStateDto,
GitActionResult,
} from './GitEngine';
export type { GitAvailability, RepoState, GitStatusDto, GitHistoryEntry, GitRemoteStateDto, GitActionResult };
/**
* Adapter that wraps GitEngine for use by the Python API layer.
* Auto-resolves projectPath from the active project so Python scripts
* don't need to pass it.
*/
export class GitApiAdapter {
private async resolveProjectPath(): Promise<string> {
const project = await getProjectEngine().getActiveProject();
if (!project?.dataPath) {
throw new Error('No active project with a data path');
}
return project.dataPath;
}
async checkAvailability(): Promise<GitAvailability> {
return getGitEngine().checkAvailability();
}
async getRepoState(): Promise<RepoState> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getRepoState(projectPath);
}
async getStatus(): Promise<GitStatusDto> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getStatus(projectPath);
}
async getHistory(limit?: number): Promise<GitHistoryEntry[]> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getHistory(projectPath, limit);
}
async getRemoteState(): Promise<GitRemoteStateDto> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getRemoteState(projectPath);
}
async fetch(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().fetch(projectPath);
}
async pull(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().pull(projectPath);
}
async push(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().push(projectPath);
}
async commitAll(message: string): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().commitAll(projectPath, message);
}
}
let instance: GitApiAdapter | null = null;
export function getGitApiAdapter(): GitApiAdapter {
if (!instance) {
instance = new GitApiAdapter();
}
return instance;
}

View File

@@ -2,6 +2,7 @@ import { simpleGit } from 'simple-git';
import * as fsPromises from 'fs/promises'; import * as fsPromises from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import type { GitScriptFileChange, GitScriptFileChangeStatus } from './ScriptEngine';
export interface GitAvailability { export interface GitAvailability {
gitFound: boolean; gitFound: boolean;
@@ -140,13 +141,7 @@ export interface GitPostFileChange {
previousPath?: string; previousPath?: string;
} }
export type GitScriptFileChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed'; export type { GitScriptFileChange, GitScriptFileChangeStatus };
export interface GitScriptFileChange {
status: GitScriptFileChangeStatus;
path: string;
previousPath?: string;
}
type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo'; type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo';

View File

@@ -11,6 +11,26 @@ import { CALENDAR_RUNTIME_JS } from './assets/calendarRuntime';
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime'; import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n'; import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
export interface PythonMacroScript {
id: string;
slug: string;
entrypoint: string;
content: string;
version: number;
}
export interface PythonMacroRendererContract {
getEnabledMacroScripts(): Promise<PythonMacroScript[]>;
renderMacro(params: {
scriptContent: string;
entrypoint: string;
contextJson: string;
postDataJson?: string | null;
cacheKey?: string;
timeoutMs?: number;
}): Promise<{ html: string; data?: Record<string, unknown>; warnings?: string[] }>;
}
export interface HtmlRewriteContext { export interface HtmlRewriteContext {
canonicalPostPathBySlug: Map<string, string>; canonicalPostPathBySlug: Map<string, string>;
canonicalMediaPathBySourcePath: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string>;
@@ -80,6 +100,7 @@ export interface PostListTemplateContext {
next_page_href: string; next_page_href: string;
canonical_post_path_by_slug: Record<string, string>; canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>; canonical_media_path_by_source_path: Record<string, string>;
post_data_json_by_id: Record<string, string>;
day_blocks: DayBlockContext[]; day_blocks: DayBlockContext[];
} }
@@ -97,6 +118,7 @@ export interface SinglePostTemplateContext {
calendar_initial_month: number | null; calendar_initial_month: number | null;
canonical_post_path_by_slug: Record<string, string>; canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>; canonical_media_path_by_source_path: Record<string, string>;
post_data_json_by_id: Record<string, string>;
} }
export interface NotFoundTemplateContext { export interface NotFoundTemplateContext {
@@ -801,6 +823,129 @@ export function renderMacro(
return ''; 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 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,
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
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 }> = [];
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 (error) {
console.warn('[PageRenderer] Failed to resolve Python macro scripts:', error instanceof Error ? error.message : String(error));
pythonScripts = [];
}
}
const scriptsBySlug = new Map<string, PythonMacroScript>();
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,
};
const result = await pythonMacroRenderer.renderMacro({
scriptContent: pythonScript.content,
entrypoint: pythonScript.entrypoint,
contextJson: JSON.stringify(context),
postDataJson: postDataJson ?? null,
cacheKey: `${pythonScript.id}:${pythonScript.version}`,
timeoutMs: 10000,
});
rendered.push(result.html);
} catch (error) {
console.warn(`[PageRenderer] Python macro '${m.name}' failed:`, error instanceof Error ? error.message : String(error));
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 { export function buildCanonicalPostPath(post: PostData): string {
const year = post.createdAt.getFullYear(); const year = post.createdAt.getFullYear();
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0'); const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
@@ -898,12 +1043,19 @@ export class PageRenderer {
private readonly mediaEngine: MediaEngineContract; private readonly mediaEngine: MediaEngineContract;
private readonly postMediaEngine: PostMediaEngineContract; private readonly postMediaEngine: PostMediaEngineContract;
private readonly postEngineForMacros?: PostEngineContract; private readonly postEngineForMacros?: PostEngineContract;
private readonly pythonMacroRenderer?: PythonMacroRendererContract;
private readonly liquid: Liquid; private readonly liquid: Liquid;
constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract, postEngineForMacros?: PostEngineContract) { constructor(
mediaEngine: MediaEngineContract,
postMediaEngine: PostMediaEngineContract,
postEngineForMacros?: PostEngineContract,
pythonMacroRenderer?: PythonMacroRendererContract,
) {
this.mediaEngine = mediaEngine; this.mediaEngine = mediaEngine;
this.postMediaEngine = postMediaEngine; this.postMediaEngine = postMediaEngine;
this.postEngineForMacros = postEngineForMacros; this.postEngineForMacros = postEngineForMacros;
this.pythonMacroRenderer = pythonMacroRenderer;
const templateRoots = resolvePageRendererTemplateRoots(); const templateRoots = resolvePageRendererTemplateRoots();
@@ -926,10 +1078,14 @@ export class PageRenderer {
return translateRender(resolved, key); 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 content = typeof value === 'string' ? value : '';
const postId = typeof postIdArg === 'string' ? postIdArg : ''; const postId = typeof postIdArg === 'string' ? postIdArg : '';
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en'; 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 = { const rewriteContext: HtmlRewriteContext = {
canonicalPostPathBySlug: recordToMap(canonicalPostsArg), canonicalPostPathBySlug: recordToMap(canonicalPostsArg),
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg), canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
@@ -951,10 +1107,9 @@ export class PageRenderer {
.catch(() => null) .catch(() => null)
: null; : null;
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => { const withMacros = await replaceAllMacrosAsync(
const params = parseMacroParams(rawParams); content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson,
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage); );
});
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false }); const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
const annotatedMarkdownHtml = annotateCodeBlocksWithLanguage(markdownHtml); const annotatedMarkdownHtml = annotateCodeBlocksWithLanguage(markdownHtml);
@@ -1152,6 +1307,9 @@ export class PageRenderer {
next_page_href: nextPageHref, next_page_href: nextPageHref,
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug), canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath), 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, day_blocks: dayBlocks,
}; };
} }
@@ -1234,6 +1392,9 @@ export class PageRenderer {
calendar_initial_month: renderablePost.createdAt.getMonth() + 1, calendar_initial_month: renderablePost.createdAt.getMonth() + 1,
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug), canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath), 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); return this.liquid.renderFile('single-post', context);

View File

@@ -19,7 +19,10 @@ import {
type HtmlRewriteContext, type HtmlRewriteContext,
type MediaEngineContract, type MediaEngineContract,
type PostMediaEngineContract, type PostMediaEngineContract,
type PythonMacroRendererContract,
} from './PageRenderer'; } from './PageRenderer';
import { getScriptEngine } from './ScriptEngine';
import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime';
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
import { renderRouteWithSharedContext } from './SharedRouteRenderer'; import { renderRouteWithSharedContext } from './SharedRouteRenderer';
import { import {
@@ -103,7 +106,7 @@ export class PreviewServer {
projectDescription: activeProject?.description ?? undefined, 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<number> { async start(preferredPort = 0): Promise<number> {
@@ -614,3 +617,21 @@ export class PreviewServer {
res.end(body); 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);
},
};
}

View File

@@ -0,0 +1,73 @@
import { getProjectEngine } from './ProjectEngine';
import { getPublishEngine, type PublishCredentials } from './PublishEngine';
import { taskManager } from './TaskManager';
export interface PublishSiteResult {
htmlFilesUploaded: number;
thumbnailFilesUploaded: number;
mediaFilesUploaded: number;
filesSkipped: number;
}
/**
* Adapter that wraps PublishEngine for use by the Python API layer.
* Mirrors the orchestration logic from publishHandlers.ts: sets project
* context, launches three parallel upload tasks, and returns aggregate results.
*/
export class PublishApiAdapter {
async uploadSite(credentials: PublishCredentials): Promise<PublishSiteResult> {
const project = await getProjectEngine().getActiveProject();
if (!project) {
throw new Error('No active project');
}
const publishEngine = getPublishEngine();
publishEngine.setProjectContext(project.id, project.dataPath!);
const ts = Date.now();
const groupId = `publish-${ts}`;
const groupName = 'Site Publishing';
const htmlTask = taskManager.runTask({
id: `publish-html-${ts}`,
name: 'Upload HTML',
groupId,
groupName,
execute: (onProgress) => publishEngine.uploadHtml(credentials, onProgress),
});
const thumbsTask = taskManager.runTask({
id: `publish-thumbnails-${ts}`,
name: 'Upload Thumbnails',
groupId,
groupName,
execute: (onProgress) => publishEngine.uploadThumbnails(credentials, onProgress),
});
const mediaTask = taskManager.runTask({
id: `publish-media-${ts}`,
name: 'Upload Media',
groupId,
groupName,
execute: (onProgress) => publishEngine.uploadMedia(credentials, onProgress),
});
const [html, thumbnails, media] = await Promise.all([htmlTask, thumbsTask, mediaTask]);
return {
htmlFilesUploaded: html.filesUploaded,
thumbnailFilesUploaded: thumbnails.filesUploaded,
mediaFilesUploaded: media.filesUploaded,
filesSkipped: html.filesSkipped + thumbnails.filesSkipped + media.filesSkipped,
};
}
}
let instance: PublishApiAdapter | null = null;
export function getPublishApiAdapter(): PublishApiAdapter {
if (!instance) {
instance = new PublishApiAdapter();
}
return instance;
}

View File

@@ -0,0 +1,375 @@
import * as path from 'path';
import { Worker } from 'worker_threads';
interface WorkerRenderMacroRequest {
type: 'renderMacro';
requestId: string;
scriptContent: string;
entrypoint: string;
contextJson: string;
postDataJson?: string | null;
cacheKey?: string;
}
interface WorkerReadyMessage {
type: 'ready';
}
interface WorkerMacroResultMessage {
type: 'macroResult';
requestId: string;
html: string;
data?: Record<string, unknown>;
warnings?: string[];
}
interface WorkerMacroErrorMessage {
type: 'macroError';
requestId: string;
error: string;
}
interface WorkerFatalErrorMessage {
type: 'error';
error: string;
}
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;
}
export interface MacroRenderResult {
html: string;
data?: Record<string, unknown>;
warnings?: string[];
}
interface QueuedRequest {
request: WorkerRenderMacroRequest;
timeoutMs: number;
resolve: (value: MacroRenderResult) => void;
reject: (error: Error) => void;
}
interface ActiveRequest extends QueuedRequest {
timeoutId: ReturnType<typeof setTimeout> | null;
}
export interface WorkerLike {
on(event: string, listener: (...args: unknown[]) => void): void;
postMessage(message: unknown): void;
terminate(): void;
removeAllListeners(): void;
}
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;
private workerStartPromise: Promise<void> | 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;
private readonly workerFactory: WorkerFactory;
private readonly apiInvoker: ApiInvoker | null;
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> {
const requestId = this.nextRequestId();
const timeoutMs = params.timeoutMs ?? 5000;
return new Promise<MacroRenderResult>((resolve, reject) => {
this.queue.push({
request: {
type: 'renderMacro',
requestId,
scriptContent: params.scriptContent,
entrypoint: params.entrypoint,
contextJson: params.contextJson,
postDataJson: params.postDataJson ?? null,
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<void> {
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<void> {
if (this.worker && this.workerReady) {
return;
}
if (this.workerStartPromise) {
return this.workerStartPromise;
}
const workerPath = path.join(__dirname, 'pythonMacro.worker.js');
this.worker = this.workerFactory(workerPath);
this.workerReady = false;
this.worker.on('message', (...args: unknown[]) => {
this.handleWorkerMessage(args[0] as WorkerResponseMessage);
});
this.worker.on('error', (...args: unknown[]) => {
const error = args[0];
this.handleWorkerCrash(error instanceof Error ? error : new Error(String(error)));
});
this.worker.on('exit', (...args: unknown[]) => {
const code = args[0] as number;
if (code !== 0) {
this.handleWorkerCrash(new Error(`Python macro worker exited with code ${code}`));
}
});
this.workerStartPromise = new Promise<void>((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;
}
if (message.type === 'apiCall') {
void this.handleApiCall(message);
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 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) {
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) {
const { invokeMainProcessPythonApi } = require('./mainProcessPythonApiInvoker') as { invokeMainProcessPythonApi: ApiInvoker };
pythonMacroWorkerRuntimeInstance = new PythonMacroWorkerRuntime(undefined, invokeMainProcessPythonApi);
}
return pythonMacroWorkerRuntimeInstance;
}

View File

@@ -222,6 +222,24 @@ export class ScriptEngine extends EventEmitter {
return Promise.all(rows.map((item) => this.toScriptData(item))); return Promise.all(rows.map((item) => this.toScriptData(item)));
} }
async getEnabledMacroScripts(): Promise<ScriptData[]> {
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<ScriptData | null> {
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<void> { async rebuildDatabaseFromFiles(): Promise<void> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const scriptsDir = this.getScriptsDir(); const scriptsDir = this.getScriptsDir();

View File

@@ -0,0 +1,241 @@
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>;
export 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;
},
sync: () => {
const { getGitApiAdapter } = require('../engine/GitApiAdapter');
return getGitApiAdapter();
},
publish: () => {
const { getPublishApiAdapter } = require('../engine/PublishApiAdapter');
return getPublishApiAdapter();
},
app: () => {
const { getAppApiAdapter } = require('../engine/AppApiAdapter');
return getAppApiAdapter();
},
};
// 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', 'app.setPreviewPostTarget',
]);
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);
}

View File

@@ -0,0 +1,252 @@
import { parentPort } from 'worker_threads';
interface WorkerRenderMacroRequest {
type: 'renderMacro';
requestId: string;
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';
}
interface WorkerMacroResultMessage {
type: 'macroResult';
requestId: string;
html: string;
data?: Record<string, unknown>;
warnings?: string[];
}
interface WorkerMacroErrorMessage {
type: 'macroError';
requestId: string;
error: string;
}
interface WorkerFatalErrorMessage {
type: 'error';
error: string;
}
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;
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');
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;
})();
}
return runtimePromise;
}
async function renderMacro(request: WorkerRenderMacroRequest): Promise<void> {
activeRequestId = request.requestId;
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);
runtime.globals.set('__bds_macro_post_data_json', request.postDataJson ?? '');
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_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):
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));
postWorkerMessage({
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) {
rejectPendingApiCalls('Python macro execution failed');
const message = error instanceof Error ? error.message : String(error);
postWorkerMessage({ type: 'macroError', requestId: request.requestId, error: message });
} finally {
rejectPendingApiCalls('Python macro execution finished');
activeRequestId = null;
}
}
parentPort?.on('message', (message: WorkerIncomingMessage) => {
if (message.type === 'apiResult') {
handleApiResultMessage(message);
return;
}
if (message.type !== 'renderMacro') {
return;
}
void renderMacro(message);
});
void getRuntime()
.then(() => {
postWorkerMessage({ type: 'ready' });
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
postWorkerMessage({ type: 'error', error: message });
});

View File

@@ -44,7 +44,7 @@
{% endif %} {% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2> <h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %} {% 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> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -59,7 +59,7 @@
{% endif %} {% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2> <h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %} {% 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> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@@ -17,7 +17,7 @@
</div> </div>
{% endif %} {% endif %}
<article class="single-post" data-template="single-post"> <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> </article>
</main> </main>
</body> </body>

View File

@@ -11,6 +11,7 @@ import { getTagEngine } from '../engine/TagEngine';
import { getPostMediaEngine } from '../engine/PostMediaEngine'; import { getPostMediaEngine } from '../engine/PostMediaEngine';
import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine'; import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine';
import { getGitEngine } from '../engine/GitEngine'; import { getGitEngine } from '../engine/GitEngine';
import { getGitApiAdapter } from '../engine/GitApiAdapter';
import { taskManager, TaskProgress } from '../engine/TaskManager'; import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media } from '../database/schema'; import { media } from '../database/schema';
@@ -772,6 +773,15 @@ export function registerIpcHandlers(): void {
return engine.getAllScripts(); return engine.getAllScripts();
}); });
// Internal: used by the editor macro plugin to detect known Python macros.
// Intentionally excluded from the Python API contract and API.md because
// it is an internal renderer helper, not a user-facing scripting API.
safeHandle('scripts:getEnabledMacroSlugs', async () => {
const engine = getScriptEngine();
const scripts = await engine.getEnabledMacroScripts();
return scripts.map((s) => s.slug);
});
safeHandle('scripts:rebuildFromFiles', async () => { safeHandle('scripts:rebuildFromFiles', async () => {
const projectEngine = getProjectEngine(); const projectEngine = getProjectEngine();
const project = await projectEngine.getActiveProject(); const project = await projectEngine.getActiveProject();
@@ -802,6 +812,44 @@ export function registerIpcHandlers(): void {
return taskManager.clearCompletedTasks(); return taskManager.clearCompletedTasks();
}); });
// ============ Sync Handlers (git operations via GitApiAdapter) ============
safeHandle('sync:checkAvailability', async () => {
return getGitApiAdapter().checkAvailability();
});
safeHandle('sync:getRepoState', async () => {
return getGitApiAdapter().getRepoState();
});
safeHandle('sync:getStatus', async () => {
return getGitApiAdapter().getStatus();
});
safeHandle('sync:getHistory', async (_, limit?: number) => {
return getGitApiAdapter().getHistory(limit);
});
safeHandle('sync:getRemoteState', async () => {
return getGitApiAdapter().getRemoteState();
});
safeHandle('sync:fetch', async () => {
return getGitApiAdapter().fetch();
});
safeHandle('sync:pull', async () => {
return getGitApiAdapter().pull();
});
safeHandle('sync:push', async () => {
return getGitApiAdapter().push();
});
safeHandle('sync:commitAll', async (_, message: string) => {
return getGitApiAdapter().commitAll(message);
});
// ============ App Handlers ============ // ============ App Handlers ============
safeHandle('app:getDataPaths', async () => { safeHandle('app:getDataPaths', async () => {
@@ -1494,4 +1542,10 @@ export function registerIpcHandlers(): void {
taskManager.on('taskProgress', forwardEvent('task:progress')); taskManager.on('taskProgress', forwardEvent('task:progress'));
taskManager.on('taskCompleted', forwardEvent('task:completed')); taskManager.on('taskCompleted', forwardEvent('task:completed'));
taskManager.on('taskFailed', forwardEvent('task:failed')); taskManager.on('taskFailed', forwardEvent('task:failed'));
const scriptEngine = getScriptEngine();
scriptEngine.on('scriptCreated', forwardEvent('script:created'));
scriptEngine.on('scriptUpdated', forwardEvent('script:updated'));
scriptEngine.on('scriptDeleted', forwardEvent('script:deleted'));
scriptEngine.on('scriptsRebuilt', forwardEvent('scripts:rebuilt'));
} }

View File

@@ -108,6 +108,7 @@ export const electronAPI: ElectronAPI = {
delete: (id: string) => ipcRenderer.invoke('scripts:delete', id), delete: (id: string) => ipcRenderer.invoke('scripts:delete', id),
get: (id: string) => ipcRenderer.invoke('scripts:get', id), get: (id: string) => ipcRenderer.invoke('scripts:get', id),
getAll: () => ipcRenderer.invoke('scripts:getAll'), getAll: () => ipcRenderer.invoke('scripts:getAll'),
getEnabledMacroSlugs: () => ipcRenderer.invoke('scripts:getEnabledMacroSlugs'),
rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'), rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'),
}, },
@@ -126,15 +127,17 @@ export const electronAPI: ElectronAPI = {
rebuild: () => ipcRenderer.invoke('postMedia:rebuild'), rebuild: () => ipcRenderer.invoke('postMedia:rebuild'),
}, },
// Sync // Sync (git operations via GitApiAdapter)
sync: { sync: {
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config), checkAvailability: () => ipcRenderer.invoke('sync:checkAvailability'),
start: (direction?: 'push' | 'pull' | 'bidirectional') => ipcRenderer.invoke('sync:start', direction), getRepoState: () => ipcRenderer.invoke('sync:getRepoState'),
getStatus: () => ipcRenderer.invoke('sync:getStatus'), getStatus: () => ipcRenderer.invoke('sync:getStatus'),
isConfigured: () => ipcRenderer.invoke('sync:isConfigured'), getHistory: (limit?: number) => ipcRenderer.invoke('sync:getHistory', limit),
getPendingCount: () => ipcRenderer.invoke('sync:getPendingCount'), getRemoteState: () => ipcRenderer.invoke('sync:getRemoteState'),
getLog: (limit?: number) => ipcRenderer.invoke('sync:getLog', limit), fetch: () => ipcRenderer.invoke('sync:fetch'),
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'), pull: () => ipcRenderer.invoke('sync:pull'),
push: () => ipcRenderer.invoke('sync:push'),
commitAll: (message: string) => ipcRenderer.invoke('sync:commitAll', message),
}, },
// Tasks // Tasks

View File

@@ -164,18 +164,6 @@ export interface TaskProgress {
groupName?: string; groupName?: string;
} }
export interface SyncConfig {
autoSync: boolean;
syncInterval: number;
}
export interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
export interface PaginatedPostsResult { export interface PaginatedPostsResult {
items: PostData[]; items: PostData[];
@@ -589,6 +577,8 @@ export interface ElectronAPI {
delete: (id: string) => Promise<boolean>; delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<ScriptData | null>; get: (id: string) => Promise<ScriptData | null>;
getAll: () => Promise<ScriptData[]>; getAll: () => Promise<ScriptData[]>;
/** Internal: editor macro plugin helper. Not exposed via Python API contract. */
getEnabledMacroSlugs: () => Promise<string[]>;
rebuildFromFiles: () => Promise<void>; rebuildFromFiles: () => Promise<void>;
}; };
postMedia: { postMedia: {
@@ -605,13 +595,15 @@ export interface ElectronAPI {
rebuild: () => Promise<void>; rebuild: () => Promise<void>;
}; };
sync: { sync: {
configure: (config: SyncConfig) => Promise<void>; checkAvailability: () => Promise<{ gitFound: boolean; version?: string }>;
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>; getRepoState: () => Promise<{ isRepo: boolean; rootPath?: string; currentBranch?: string; hasRemote: boolean }>;
getStatus: () => Promise<'idle' | 'syncing' | 'error'>; getStatus: () => Promise<{ files: Array<{ path: string; status: string; previousPath?: string }>; counts: { untracked: number; modified: number; deleted: number; renamed: number; staged: number } }>;
isConfigured: () => Promise<boolean>; getHistory: (limit?: number) => Promise<Array<{ hash: string; shortHash: string; date: string; subject: string; author: string }>>;
getPendingCount: () => Promise<{ posts: number; media: number }>; getRemoteState: () => Promise<{ localBranch: string | null; upstreamBranch: string | null; hasUpstream: boolean; ahead: number; behind: number }>;
getLog: (limit?: number) => Promise<unknown[]>; fetch: () => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
stopAutoSync: () => Promise<void>; pull: () => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
push: () => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
commitAll: (message: string) => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
}; };
tasks: { tasks: {
getAll: () => Promise<TaskProgress[]>; getAll: () => Promise<TaskProgress[]>;

View File

@@ -0,0 +1,185 @@
import { BDS_PYTHON_API_CONTRACT_V1 } from './pythonApiContractV1';
const PYTHON_RESERVED_KEYWORDS = new Set([
'false',
'none',
'true',
'and',
'as',
'assert',
'async',
'await',
'break',
'class',
'continue',
'def',
'del',
'elif',
'else',
'except',
'finally',
'for',
'from',
'global',
'if',
'import',
'in',
'is',
'lambda',
'nonlocal',
'not',
'or',
'pass',
'raise',
'return',
'try',
'while',
'with',
'yield',
'match',
'case',
]);
function toSnakeCase(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase();
}
function quotePython(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function toPythonIdentifier(value: string): string {
let identifier = toSnakeCase(value);
if (!identifier) {
identifier = '_';
}
if (/^[0-9]/.test(identifier)) {
identifier = `_${identifier}`;
}
if (PYTHON_RESERVED_KEYWORDS.has(identifier)) {
identifier = `${identifier}_`;
}
return identifier;
}
function buildPythonMethod(method: {
method: string;
description: string;
params: Array<{ name: string; required: boolean }>;
}): string {
const [namespace, member] = method.method.split('.');
if (!namespace || !member) {
return '';
}
const pythonMethodName = toPythonIdentifier(member);
const pythonParams = method.params.map((param) => ({
sourceName: param.name,
pythonName: toPythonIdentifier(param.name),
required: param.required,
}));
const signature = pythonParams.length > 0
? `, ${pythonParams.map((param) => (param.required ? param.pythonName : `${param.pythonName}=None`)).join(', ')}`
: '';
const argsDict = method.params.length > 0
? `{ ${method.params.map((param, index) => `"${param.name}": ${pythonParams[index]?.pythonName}`).join(', ')} }`
: '{}';
return [
` async def ${pythonMethodName}(self${signature}):`,
` \"\"\"${quotePython(method.description)}\"\"\"`,
` return await self._transport.call("${method.method}", ${argsDict})`,
'',
].join('\n');
}
function buildPythonNamespaceClass(
namespace: string,
methods: Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>
): string {
const className = `${namespace[0].toUpperCase()}${namespace.slice(1)}Api`;
const methodBlocks = methods.map((method) => buildPythonMethod(method)).join('');
return [
`class ${className}:`,
' def __init__(self, transport):',
' self._transport = transport',
'',
methodBlocks.trimEnd(),
'',
].join('\n');
}
export function generatePythonApiModuleV1(): string {
const namespaceMap = new Map<string, Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>>();
for (const method of BDS_PYTHON_API_CONTRACT_V1.methods) {
const [namespace] = method.method.split('.');
if (!namespace) {
continue;
}
const entries = namespaceMap.get(namespace) ?? [];
entries.push({
method: method.method,
description: method.description,
params: method.params.map((param) => ({
name: param.name,
required: param.required,
})),
});
namespaceMap.set(namespace, entries);
}
const namespaceBlocks = Array.from(namespaceMap.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([namespace, methods]) => buildPythonNamespaceClass(namespace, methods))
.join('\n');
const namespaceAssignments = Array.from(namespaceMap.keys())
.sort((left, right) => left.localeCompare(right))
.map((namespace) => ` self.${toPythonIdentifier(namespace)} = ${namespace[0].toUpperCase()}${namespace.slice(1)}Api(transport)`)
.join('\n');
return [
'# Auto-generated by generatePythonApiModuleV1.ts',
`# Contract version: ${BDS_PYTHON_API_CONTRACT_V1.version}`,
'',
'import json',
'',
'class BdsApiError(Exception):',
' pass',
'',
namespaceBlocks.trimEnd(),
'',
'class BdsApi:',
' def __init__(self, transport):',
' self._transport = transport',
namespaceAssignments,
'',
'class _Transport:',
' def __init__(self, call_impl):',
' self._call_impl = call_impl',
'',
' async def call(self, method, args):',
' raw_result = await self._call_impl(method, json.dumps(args))',
' if raw_result is None or raw_result == "":',
' return None',
' return json.loads(raw_result)',
'',
'def install_bds_api(call_impl):',
' _transport = _Transport(call_impl)',
' bds = BdsApi(_transport)',
' return bds',
'',
].join('\n');
}

View File

@@ -0,0 +1,376 @@
import type { ElectronAPI } from './electronApi';
type PythonPromiseMethodPath = {
[Group in keyof ElectronAPI]: ElectronAPI[Group] extends Record<string, (...args: never[]) => unknown>
? {
[Method in keyof ElectronAPI[Group]]: ElectronAPI[Group][Method] extends (...args: never[]) => Promise<unknown>
? `${Extract<Group, string>}.${Extract<Method, string>}`
: never;
}[keyof ElectronAPI[Group]]
: never;
}[keyof ElectronAPI];
export type PythonApiParamType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'stringOrNull';
export interface PythonApiParamContractV1 {
name: string;
type: PythonApiParamType;
required: boolean;
}
export interface PythonApiMethodContractV1 {
method: PythonPromiseMethodPath;
description: string;
params: PythonApiParamContractV1[];
returns: string;
}
export interface PythonApiDataStructureFieldContractV1 {
name: string;
type: string;
required: boolean;
description: string;
}
export interface PythonApiDataStructureContractV1 {
name: string;
description: string;
fields: PythonApiDataStructureFieldContractV1[];
}
export interface PythonApiContractV1 {
version: string;
generatedAt: string;
methods: PythonApiMethodContractV1[];
dataStructures: PythonApiDataStructureContractV1[];
}
const requiredString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: true });
const optionalString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: false });
const optionalNumber = (name: string): PythonApiParamContractV1 => ({ name, type: 'number', required: false });
const requiredObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: true });
const optionalObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: false });
const requiredArray = (name: string): PythonApiParamContractV1 => ({ name, type: 'array', required: true });
const requiredStringOrNull = (name: string): PythonApiParamContractV1 => ({ name, type: 'stringOrNull', required: true });
function method(
methodName: PythonPromiseMethodPath,
description: string,
params: PythonApiParamContractV1[],
returns: string
): PythonApiMethodContractV1 {
return {
method: methodName,
description,
params,
returns,
};
}
const METHODS_V1: PythonApiMethodContractV1[] = [
method('projects.create', 'Create a project.', [requiredObject('data')], 'ProjectData'),
method('projects.update', 'Update a project by id.', [requiredString('id'), requiredObject('data')], 'ProjectData | null'),
method('projects.delete', 'Delete a project by id.', [requiredString('id')], 'boolean'),
method('projects.deleteWithData', 'Delete a project and data by id.', [requiredString('id')], 'boolean'),
method('projects.get', 'Fetch one project by id.', [requiredString('id')], 'ProjectData | null'),
method('projects.getAll', 'Fetch all projects.', [], 'ProjectData[]'),
method('projects.getActive', 'Fetch active project.', [], 'ProjectData | null'),
method('projects.setActive', 'Set active project by id.', [requiredString('id')], 'ProjectData | null'),
method('posts.create', 'Create a post.', [requiredObject('data')], 'PostData'),
method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'),
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('posts.getDashboardStats', 'Get post dashboard stats.', [], 'DashboardStats'),
method('posts.getTagsWithCounts', 'Get post tags with counts.', [], 'TagCount[]'),
method('posts.getCategoriesWithCounts', 'Get post categories with counts.', [], 'CategoryCount[]'),
method('posts.getLinksTo', 'Get posts linked to given post.', [requiredString('id')], 'PostData[]'),
method('posts.getLinkedBy', 'Get posts linking to given post.', [requiredString('id')], 'PostData[]'),
method('posts.rebuildLinks', 'Rebuild post link graph.', [], 'void'),
method('posts.isSlugAvailable', 'Check if post slug is available.', [requiredString('slug'), optionalString('excludePostId')], 'boolean'),
method('posts.generateUniqueSlug', 'Generate unique slug from title.', [requiredString('title'), optionalString('excludePostId')], 'string'),
method('media.import', 'Import media file.', [requiredString('sourcePath'), optionalObject('metadata')], 'MediaData'),
method('media.update', 'Update media metadata by id.', [requiredString('id'), requiredObject('data')], 'MediaData | null'),
method('media.replaceFile', 'Replace media file by id.', [requiredString('id'), requiredString('newSourcePath')], 'MediaData | null'),
method('media.delete', 'Delete media by id.', [requiredString('id')], 'boolean'),
method('media.get', 'Fetch one media by id.', [requiredString('id')], 'MediaData | null'),
method('media.getUrl', 'Get media URL by id.', [requiredString('id')], 'string | null'),
method('media.getFilePath', 'Get media file path by id.', [requiredString('id')], 'string | null'),
method('media.getAll', 'Fetch all media.', [], 'MediaData[]'),
method('media.rebuildFromFiles', 'Rebuild media database from files.', [], 'void'),
method('media.reindexText', 'Reindex media search text.', [], 'void'),
method('media.getThumbnail', 'Get media thumbnail URL.', [requiredString('id'), optionalString('size')], 'string | null'),
method('media.regenerateThumbnails', 'Regenerate thumbnails for media.', [requiredString('id')], 'Record<string, string> | null'),
method('media.regenerateMissingThumbnails', 'Regenerate all missing thumbnails.', [], '{ processed: number; generated: number; failed: number }'),
method('media.filter', 'Filter media by criteria.', [requiredObject('filter')], 'MediaData[]'),
method('media.search', 'Search media by free-text query.', [requiredString('query')], 'MediaSearchResult[]'),
method('media.getByYearMonth', 'Get media counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('media.getTags', 'Get all media tags.', [], 'string[]'),
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
method('scripts.create', 'Create script.', [requiredObject('data')], 'ScriptData'),
method('scripts.update', 'Update script by id.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
method('scripts.get', 'Fetch script by id.', [requiredString('id')], 'ScriptData | null'),
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
method('tasks.clearCompleted', 'Clear completed tasks.', [], 'void'),
method('app.getDataPaths', 'Get app data paths.', [], '{ database: string; posts: string; media: string }'),
method('app.getSystemLanguage', 'Get system language.', [], 'string'),
method('app.getTitleBarMetrics', 'Get title bar metrics.', [], '{ macosLeftInset: number } | null'),
method('app.openFolder', 'Open folder in system file manager.', [requiredString('folderPath')], 'string'),
method('app.showItemInFolder', 'Reveal item in system file manager.', [requiredString('itemPath')], 'void'),
method('app.selectFolder', 'Show folder picker dialog.', [optionalString('title')], 'string | null'),
method('app.getDefaultProjectPath', 'Get default project path.', [requiredString('projectId')], 'string'),
method('app.readProjectMetadata', 'Read project metadata from path.', [requiredString('folderPath')], '{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null'),
method('app.getBlogmarkBookmarklet', 'Get blogmark bookmarklet script.', [], 'string'),
method('app.copyToClipboard', 'Copy text to clipboard.', [requiredString('text')], 'boolean'),
method('app.notifyRendererReady', 'Notify main process renderer is ready.', [], 'boolean'),
method('app.setPreviewPostTarget', 'Set preview post target.', [requiredStringOrNull('postId')], 'void'),
method('app.triggerMenuAction', 'Trigger menu action.', [requiredString('action')], 'void'),
method('meta.getTags', 'Get project tags.', [], 'string[]'),
method('meta.getCategories', 'Get project categories.', [], 'string[]'),
method('meta.addTag', 'Add project tag.', [requiredString('tag')], 'string[]'),
method('meta.removeTag', 'Remove project tag.', [requiredString('tag')], 'string[]'),
method('meta.addCategory', 'Add project category.', [requiredString('category')], 'string[]'),
method('meta.removeCategory', 'Remove project category.', [requiredString('category')], 'string[]'),
method('meta.syncOnStartup', 'Sync meta values on startup.', [], '{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }'),
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'),
method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'),
method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'),
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
method('tags.get', 'Fetch tag by id.', [requiredString('id')], 'TagData | null'),
method('tags.getByName', 'Fetch tag by name.', [requiredString('name')], 'TagData | null'),
method('tags.create', 'Create tag.', [requiredObject('data')], 'TagData'),
method('tags.update', 'Update tag by id.', [requiredString('id'), requiredObject('data')], 'TagData | null'),
method('tags.delete', 'Delete tag by id.', [requiredString('id')], 'DeleteTagResult'),
method('tags.merge', 'Merge tags into target tag.', [requiredArray('sourceTagIds'), requiredString('targetTagId')], 'MergeTagsResult'),
method('tags.rename', 'Rename tag by id.', [requiredString('id'), requiredString('newName')], 'RenameTagResult'),
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
// NOTE: chat namespace intentionally excluded from Python API.
// AI/chat features (sendMessage, analyzeTaxonomy, analyzeMediaImage, etc.) are
// expensive external API calls that require user oversight and interactive streaming.
// This namespace can be re-added in a future version if AI-from-Python becomes a
// supported use case with proper rate limiting and cost controls.
method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'),
method('sync.getStatus', 'Get working tree status for active project.', [], 'GitStatusDto'),
method('sync.getHistory', 'Get commit history for active project.', [optionalNumber('limit')], 'GitHistoryEntry[]'),
method('sync.getRemoteState', 'Get remote tracking state for active project.', [], 'GitRemoteStateDto'),
method('sync.fetch', 'Fetch from remote for active project.', [], 'GitActionResult'),
method('sync.pull', 'Pull from remote for active project.', [], 'GitActionResult'),
method('sync.push', 'Push to remote for active project.', [], 'GitActionResult'),
method('sync.commitAll', 'Stage all changes and commit for active project.', [requiredString('message')], 'GitActionResult'),
method('publish.uploadSite', 'Upload rendered site to remote server via SSH.', [requiredObject('credentials')], 'PublishSiteResult'),
];
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{
name: 'ProjectData',
description: 'Project metadata stored in the app database.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique project identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable project name.' },
{ name: 'slug', type: 'string', required: true, description: 'URL-friendly project slug.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Filesystem path for project data.' },
{ name: 'isActive', type: 'boolean', required: true, description: 'Whether this project is currently active.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'PostData',
description: 'Canonical post object used across editor and generation flows.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique post identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'title', type: 'string', required: true, description: 'Post title.' },
{ name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' },
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
],
},
{
name: 'MediaData',
description: 'Canonical media object representing imported files and metadata.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique media identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'filename', type: 'string', required: true, description: 'Stored filename in project media folder.' },
{ name: 'originalName', type: 'string', required: true, description: 'Original imported filename.' },
{ name: 'mimeType', type: 'string', required: true, description: 'Detected MIME type.' },
{ name: 'size', type: 'number', required: true, description: 'File size in bytes.' },
{ name: 'width', type: 'number', required: false, description: 'Image width in pixels when available.' },
{ name: 'height', type: 'number', required: false, description: 'Image height in pixels when available.' },
{ name: 'title', type: 'string', required: false, description: 'Optional display title.' },
{ name: 'alt', type: 'string', required: false, description: 'Optional alternative text.' },
{ name: 'caption', type: 'string', required: false, description: 'Optional caption text.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author credit.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of media tags.' },
],
},
{
name: 'ScriptData',
description: 'Script definition for Python macros, utilities, and transforms.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique script identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'slug', type: 'string', required: true, description: 'Stable script slug.' },
{ name: 'title', type: 'string', required: true, description: 'Human-readable script title.' },
{ name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' },
{ name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' },
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' },
{ name: 'version', type: 'number', required: true, description: 'Incrementing script version.' },
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to script file.' },
{ name: 'content', type: 'string', required: true, description: 'Script source code.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TaskProgress',
description: 'Task queue status object for long-running operations.',
fields: [
{ name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Task display name.' },
{ name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' },
{ name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' },
{ name: 'message', type: 'string', required: true, description: 'Current progress message.' },
{ name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' },
{ name: 'endTime', type: 'string', required: false, description: 'Task completion time (ISO string).' },
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
{ name: 'groupId', type: 'string', required: false, description: 'Optional grouping id.' },
{ name: 'groupName', type: 'string', required: false, description: 'Optional grouping label.' },
],
},
{
name: 'ProjectMetadata',
description: 'Extended project metadata from project settings.',
fields: [
{ name: 'name', type: 'string', required: true, description: 'Project display name.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Optional custom data path.' },
{ name: 'publicUrl', type: 'string', required: false, description: 'Optional public site URL.' },
{ name: 'mainLanguage', type: 'string', required: false, description: 'Main render language code.' },
{ name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' },
{ name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' },
{ name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' },
{ name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' },
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
],
},
{
name: 'GitAvailability',
description: 'Git installation availability check result.',
fields: [
{ name: 'gitFound', type: 'boolean', required: true, description: 'Whether git executable was found.' },
{ name: 'version', type: 'string', required: false, description: 'Git version string when available.' },
],
},
{
name: 'RepoState',
description: 'Repository state for the active project.',
fields: [
{ name: 'isRepo', type: 'boolean', required: true, description: 'Whether the project directory is a git repository.' },
{ name: 'rootPath', type: 'string', required: false, description: 'Repository root path.' },
{ name: 'currentBranch', type: 'string', required: false, description: 'Current branch name.' },
{ name: 'hasRemote', type: 'boolean', required: true, description: 'Whether a remote is configured.' },
],
},
{
name: 'GitStatusDto',
description: 'Working tree status with file list and counts.',
fields: [
{ name: 'files', type: 'Array<{ path: string; status: string; previousPath?: string }>', required: true, description: 'List of changed files with status.' },
{ name: 'counts', type: '{ untracked: number; modified: number; deleted: number; renamed: number; staged: number }', required: true, description: 'Counts by change type.' },
],
},
{
name: 'GitRemoteStateDto',
description: 'Remote tracking state for the active project branch.',
fields: [
{ name: 'localBranch', type: 'string | null', required: true, description: 'Local branch name.' },
{ name: 'upstreamBranch', type: 'string | null', required: true, description: 'Upstream tracking branch name.' },
{ name: 'hasUpstream', type: 'boolean', required: true, description: 'Whether an upstream is configured.' },
{ name: 'ahead', type: 'number', required: true, description: 'Commits ahead of upstream.' },
{ name: 'behind', type: 'number', required: true, description: 'Commits behind upstream.' },
],
},
{
name: 'GitActionResult',
description: 'Result from a git operation (fetch, pull, push, commit).',
fields: [
{ name: 'success', type: 'boolean', required: true, description: 'Whether the operation succeeded.' },
{ name: 'code', type: 'string', required: false, description: "Error code when failed ('auth-required', 'conflict', 'network', 'action-failed')." },
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
{ name: 'guidance', type: 'string[]', required: false, description: 'Guidance messages for resolving failures.' },
],
},
{
name: 'PublishSiteResult',
description: 'Aggregate result from uploading the rendered site.',
fields: [
{ name: 'htmlFilesUploaded', type: 'number', required: true, description: 'Number of HTML files uploaded.' },
{ name: 'thumbnailFilesUploaded', type: 'number', required: true, description: 'Number of thumbnail files uploaded.' },
{ name: 'mediaFilesUploaded', type: 'number', required: true, description: 'Number of media files uploaded.' },
{ name: 'filesSkipped', type: 'number', required: true, description: 'Total files skipped (already up-to-date).' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.7.0',
generatedAt: '2026-02-27T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};
export function listPythonApiMethodNames(): string[] {
return BDS_PYTHON_API_CONTRACT_V1.methods.map((entry) => entry.method);
}
export function getPythonApiMethodContract(methodName: string): PythonApiMethodContractV1 | undefined {
return BDS_PYTHON_API_CONTRACT_V1.methods.find((entry) => entry.method === methodName);
}
export function getPythonApiDataStructureContracts(): PythonApiDataStructureContractV1[] {
return BDS_PYTHON_API_CONTRACT_V1.dataStructures;
}

View File

@@ -14,6 +14,8 @@ import {
import { createDeferredEventGate } from './navigation/deferredEventGate'; import { createDeferredEventGate } from './navigation/deferredEventGate';
import { createAndFocusPost } from './navigation/postCreation'; import { createAndFocusPost } from './navigation/postCreation';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
import { addWindowEventListener, BDS_EVENT_SCRIPTS_CHANGED } from './utils/windowEvents';
import { refreshPythonMacroSlugs, wirePythonMacroPreview, invalidatePythonMacroScriptCache } from './macros';
import { useI18n } from './i18n'; import { useI18n } from './i18n';
import './App.css'; import './App.css';
@@ -104,6 +106,12 @@ const App: React.FC = () => {
if (tasks) { if (tasks) {
setTasks(tasks as TaskProgress[]); setTasks(tasks as TaskProgress[]);
} }
// Load known Python macro slugs for editor detection
await refreshPythonMacroSlugs();
// Wire Python macro resolver/renderer for editor preview
wirePythonMacroPreview();
} catch (error) { } catch (error) {
console.error('Failed to load initial data:', error); console.error('Failed to load initial data:', error);
} finally { } finally {
@@ -403,6 +411,7 @@ const App: React.FC = () => {
await Promise.all([ await Promise.all([
window.electronAPI?.posts.rebuildFromFiles(), window.electronAPI?.posts.rebuildFromFiles(),
window.electronAPI?.media.rebuildFromFiles(), window.electronAPI?.media.rebuildFromFiles(),
window.electronAPI?.scripts.rebuildFromFiles(),
]); ]);
await window.electronAPI?.media.regenerateMissingThumbnails(); await window.electronAPI?.media.regenerateMissingThumbnails();
} catch (error) { } catch (error) {
@@ -557,6 +566,14 @@ const App: React.FC = () => {
}) || (() => {}) }) || (() => {})
); );
// Refresh Python macro slugs when scripts change
unsubscribers.push(
addWindowEventListener(BDS_EVENT_SCRIPTS_CHANGED, () => {
invalidatePythonMacroScriptCache();
void refreshPythonMacroSlugs();
})
);
void window.electronAPI?.app.notifyRendererReady?.().catch((error) => { void window.electronAPI?.app.notifyRendererReady?.().catch((error) => {
console.error('Failed to notify renderer readiness:', error); console.error('Failed to notify renderer readiness:', error);
}); });

View File

@@ -3,6 +3,7 @@ import { useAppStore } from '../../store';
import { openGitDiffCommitTab, openGitDiffFileTab } from '../../navigation/tabPolicy'; import { openGitDiffCommitTab, openGitDiffFileTab } from '../../navigation/tabPolicy';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi'; import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
import './GitSidebar.css'; import './GitSidebar.css';
import '../Sidebar/Sidebar.css'; import '../Sidebar/Sidebar.css';
@@ -393,6 +394,9 @@ export const GitSidebar: React.FC = () => {
setErrorGuidance('guidance' in result ? result.guidance || [] : []); setErrorGuidance('guidance' in result ? result.guidance || [] : []);
return; return;
} }
if (action === 'pull') {
dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED);
}
await loadRepoState(); await loadRepoState();
} catch { } catch {
setError(tr('gitSidebar.error.actionFailed', { action })); setError(tr('gitSidebar.error.actionFailed', { action }));

View File

@@ -24,6 +24,9 @@ export type {
MacroParams, MacroParams,
MacroRenderContext, MacroRenderContext,
ParsedMacro, ParsedMacro,
PythonMacroInfo,
PythonMacroResolver,
PythonMacroRendererFn,
} from './types'; } from './types';
// Re-export registry functions // Re-export registry functions
@@ -39,4 +42,11 @@ export {
renderMacro, renderMacro,
renderAllMacros, renderAllMacros,
getEditorPreview, getEditorPreview,
setPythonMacroResolver,
refreshPythonMacroSlugs,
} from './registry'; } from './registry';
export {
wirePythonMacroPreview,
invalidatePythonMacroScriptCache,
} from './pythonMacroPreview';

View File

@@ -0,0 +1,72 @@
import type { PythonMacroInfo, PythonMacroResolver, PythonMacroRendererFn } from './types';
import { setPythonMacroResolver } from './registry';
import { getPythonRuntimeManager } from '../python/runtimeManagerInstance';
interface ScriptLike {
id: string;
slug: string;
kind: string;
enabled: boolean;
content: string;
entrypoint: string;
version: number;
}
let cachedMacroScripts: ScriptLike[] | null = null;
export function invalidatePythonMacroScriptCache(): void {
cachedMacroScripts = null;
}
async function loadMacroScripts(): Promise<ScriptLike[]> {
if (cachedMacroScripts) return cachedMacroScripts;
const allScripts = await window.electronAPI?.scripts.getAll();
cachedMacroScripts = (allScripts ?? []).filter(
(s: ScriptLike) => s.kind === 'macro' && s.enabled,
);
return cachedMacroScripts;
}
const resolvePythonMacro: PythonMacroResolver = async (macroName) => {
const scripts = await loadMacroScripts();
const lower = macroName.toLowerCase();
const script = scripts.find((s) => s.slug.toLowerCase() === lower);
if (!script) return null;
return {
scriptId: script.id,
slug: script.slug,
code: script.content,
entrypoint: script.entrypoint,
version: script.version,
};
};
const renderPythonMacro: PythonMacroRendererFn = async (info, params, context) => {
const manager = getPythonRuntimeManager();
const macroContext = {
env: {
isPreview: context.isPreview,
source: { kind: 'script', id: info.scriptId },
},
params: params as Record<string, unknown>,
};
const result = await manager.renderMacroV1(info.code, macroContext, {
entrypoint: info.entrypoint,
cacheKey: `${info.scriptId}:v${info.version}`,
macroSource: { kind: 'script', id: info.scriptId },
});
return result.result.html;
};
/**
* Wire the Python macro resolver and renderer into the macro registry
* so that the editor preview can render Python-backed macros.
* Call once on startup after refreshPythonMacroSlugs().
*/
export function wirePythonMacroPreview(): void {
setPythonMacroResolver(resolvePythonMacro, renderPythonMacro);
}

View File

@@ -5,11 +5,25 @@
* Macros self-register using registerMacro() function. * 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 // Internal registry storage
const macroRegistry = new Map<string, MacroDefinition>(); const macroRegistry = new Map<string, MacroDefinition>();
// Python macro resolution
let pythonMacroResolverFn: PythonMacroResolver | null = null;
let pythonMacroRendererFn: PythonMacroRendererFn | null = null;
// Python macro slugs for editor known/unknown detection
const pythonMacroSlugs = new Set<string>();
/** /**
* Register a macro definition. * Register a macro definition.
* Call this from each macro definition file. * Call this from each macro definition file.
@@ -25,6 +39,37 @@ export function registerMacro(macro: MacroDefinition): void {
macroRegistry.set(name, macro); 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;
}
/**
* Refresh the set of known Python macro slugs from the backend.
* Call on startup and when scripts change.
*/
export async function refreshPythonMacroSlugs(): Promise<void> {
try {
if (typeof window === 'undefined' || !window.electronAPI?.scripts?.getEnabledMacroSlugs) {
return;
}
const slugs = await window.electronAPI.scripts.getEnabledMacroSlugs();
pythonMacroSlugs.clear();
for (const slug of slugs) {
pythonMacroSlugs.add(slug.toLowerCase());
}
} catch {
// Silently ignore — may be called before IPC bridge is ready
}
}
/** /**
* Get a macro definition by name. * Get a macro definition by name.
* *
@@ -36,12 +81,13 @@ export function getMacro(name: string): MacroDefinition | undefined {
} }
/** /**
* Check if a macro is registered. * Check if a macro is registered (JS registry or Python macro slug).
* *
* @param name - The macro name (case-insensitive) * @param name - The macro name (case-insensitive)
*/ */
export function hasMacro(name: string): boolean { export function hasMacro(name: string): boolean {
return macroRegistry.has(name.toLowerCase()); const lower = name.toLowerCase();
return macroRegistry.has(lower) || pythonMacroSlugs.has(lower);
} }
/** /**
@@ -63,6 +109,7 @@ export function getAllMacros(): MacroDefinition[] {
*/ */
export function clearMacros(): void { export function clearMacros(): void {
macroRegistry.clear(); macroRegistry.clear();
pythonMacroSlugs.clear();
} }
// Regex to match [[macroName param1="value1" param2='value2']] // Regex to match [[macroName param1="value1" param2='value2']]
@@ -124,6 +171,7 @@ export function parseMacros(markdown: string): ParsedMacro[] {
/** /**
* Render a single macro to HTML. * Render a single macro to HTML.
* First checks JS registry, then falls back to Python macro resolver.
* *
* @param macro - The parsed macro * @param macro - The parsed macro
* @param context - Render context * @param context - Render context
@@ -135,25 +183,37 @@ export async function renderMacro(
): Promise<string> { ): Promise<string> {
const definition = getMacro(macro.name); const definition = getMacro(macro.name);
if (!definition) { if (definition) {
return `<span class="macro-error" title="Unknown macro: ${macro.name}">${macro.rawText}</span>`; // Validate if validator exists
} if (definition.validate) {
const error = definition.validate(macro.params);
if (error) {
return `<span class="macro-error" title="${error}">${macro.rawText}</span>`;
}
}
// Validate if validator exists try {
if (definition.validate) { const result = definition.render(macro.params, context);
const error = definition.validate(macro.params); return result instanceof Promise ? await result : result;
if (error) { } catch (error) {
return `<span class="macro-error" title="${error}">${macro.rawText}</span>`; const message = error instanceof Error ? error.message : 'Render error';
return `<span class="macro-error" title="${message}">${macro.rawText}</span>`;
} }
} }
try { if (pythonMacroResolverFn && pythonMacroRendererFn) {
const result = definition.render(macro.params, context); try {
return result instanceof Promise ? await result : result; const pythonInfo = await pythonMacroResolverFn(macro.name);
} catch (error) { if (pythonInfo) {
const message = error instanceof Error ? error.message : 'Render error'; return await pythonMacroRendererFn(pythonInfo, macro.params, context);
return `<span class="macro-error" title="${message}">${macro.rawText}</span>`; }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Python macro error';
return `<span class="macro-error" title="${errorMessage}">${macro.rawText}</span>`;
}
} }
return `<span class="macro-error" title="Unknown macro: ${macro.name}">${macro.rawText}</span>`;
} }
/** /**

View File

@@ -75,3 +75,30 @@ export interface ParsedMacro {
/** End position in the source text */ /** End position in the source text */
end: number; 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<PythonMacroInfo | null>;
/**
* 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<string>;

View File

@@ -43,6 +43,7 @@ export interface PythonMacroSourceOptions {
export interface PythonMacroRenderOptions extends PythonExecuteOptions { export interface PythonMacroRenderOptions extends PythonExecuteOptions {
macroHook?: string; macroHook?: string;
macroSource?: PythonMacroSourceOptions; macroSource?: PythonMacroSourceOptions;
postDataJson?: string | null;
} }
export interface PythonMacroV1Result { export interface PythonMacroV1Result {
@@ -173,6 +174,8 @@ export class PythonRuntimeManager {
requestId, requestId,
code, code,
context: validatedContext, context: validatedContext,
entrypoint: options?.entrypoint,
postDataJson: options?.postDataJson,
cacheKey: options?.cacheKey, cacheKey: options?.cacheKey,
}; };

View File

@@ -2,7 +2,7 @@ import {
BDS_PYTHON_API_CONTRACT_V1, BDS_PYTHON_API_CONTRACT_V1,
type PythonApiDataStructureContractV1, type PythonApiDataStructureContractV1,
type PythonApiParamContractV1, type PythonApiParamContractV1,
} from './pythonApiContractV1'; } from '../../main/shared/pythonApiContractV1';
function toSnakeCase(value: string): string { function toSnakeCase(value: string): string {
return value return value
@@ -206,6 +206,8 @@ export function generateApiDocumentationMarkdownV1(): string {
sections.push(''); sections.push('');
sections.push('This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.'); sections.push('This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.');
sections.push(''); sections.push('');
sections.push('`bds_api` is available in both **macro scripts** (executed during preview and page generation) and **transform scripts** (executed during blogmark import). In macro entrypoints, API calls run in the same runtime context as the macro and can be used to fetch posts, media, tags, or other application data.');
sections.push('');
sections.push('## Usage'); sections.push('## Usage');
sections.push(''); sections.push('');
sections.push('```python'); sections.push('```python');

View File

@@ -1,381 +1,16 @@
import type { ElectronAPI } from '../../main/shared/electronApi'; // Re-export from shared location (canonical source is src/main/shared/)
export {
BDS_PYTHON_API_CONTRACT_V1,
listPythonApiMethodNames,
getPythonApiMethodContract,
getPythonApiDataStructureContracts,
} from '../../main/shared/pythonApiContractV1';
type PythonPromiseMethodPath = { export type {
[Group in keyof ElectronAPI]: ElectronAPI[Group] extends Record<string, (...args: never[]) => unknown> PythonApiParamType,
? { PythonApiParamContractV1,
[Method in keyof ElectronAPI[Group]]: ElectronAPI[Group][Method] extends (...args: never[]) => Promise<unknown> PythonApiMethodContractV1,
? `${Extract<Group, string>}.${Extract<Method, string>}` PythonApiDataStructureFieldContractV1,
: never; PythonApiDataStructureContractV1,
}[keyof ElectronAPI[Group]] PythonApiContractV1,
: never; } from '../../main/shared/pythonApiContractV1';
}[keyof ElectronAPI];
export type PythonApiParamType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'stringOrNull';
export interface PythonApiParamContractV1 {
name: string;
type: PythonApiParamType;
required: boolean;
}
export interface PythonApiMethodContractV1 {
method: PythonPromiseMethodPath;
description: string;
params: PythonApiParamContractV1[];
returns: string;
}
export interface PythonApiDataStructureFieldContractV1 {
name: string;
type: string;
required: boolean;
description: string;
}
export interface PythonApiDataStructureContractV1 {
name: string;
description: string;
fields: PythonApiDataStructureFieldContractV1[];
}
export interface PythonApiContractV1 {
version: string;
generatedAt: string;
methods: PythonApiMethodContractV1[];
dataStructures: PythonApiDataStructureContractV1[];
}
const requiredString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: true });
const optionalString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: false });
const optionalNumber = (name: string): PythonApiParamContractV1 => ({ name, type: 'number', required: false });
const requiredObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: true });
const optionalObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: false });
const requiredArray = (name: string): PythonApiParamContractV1 => ({ name, type: 'array', required: true });
const requiredAny = (name: string): PythonApiParamContractV1 => ({ name, type: 'any', required: true });
const requiredStringOrNull = (name: string): PythonApiParamContractV1 => ({ name, type: 'stringOrNull', required: true });
function method(
methodName: PythonPromiseMethodPath,
description: string,
params: PythonApiParamContractV1[],
returns: string
): PythonApiMethodContractV1 {
return {
method: methodName,
description,
params,
returns,
};
}
const METHODS_V1: PythonApiMethodContractV1[] = [
method('projects.create', 'Create a project.', [requiredObject('data')], 'ProjectData'),
method('projects.update', 'Update a project by id.', [requiredString('id'), requiredObject('data')], 'ProjectData | null'),
method('projects.delete', 'Delete a project by id.', [requiredString('id')], 'boolean'),
method('projects.deleteWithData', 'Delete a project and data by id.', [requiredString('id')], 'boolean'),
method('projects.get', 'Fetch one project by id.', [requiredString('id')], 'ProjectData | null'),
method('projects.getAll', 'Fetch all projects.', [], 'ProjectData[]'),
method('projects.getActive', 'Fetch active project.', [], 'ProjectData | null'),
method('projects.setActive', 'Set active project by id.', [requiredString('id')], 'ProjectData | null'),
method('posts.create', 'Create a post.', [requiredObject('data')], 'PostData'),
method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'),
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('posts.getDashboardStats', 'Get post dashboard stats.', [], 'DashboardStats'),
method('posts.getTagsWithCounts', 'Get post tags with counts.', [], 'TagCount[]'),
method('posts.getCategoriesWithCounts', 'Get post categories with counts.', [], 'CategoryCount[]'),
method('posts.getLinksTo', 'Get posts linked to given post.', [requiredString('id')], 'PostData[]'),
method('posts.getLinkedBy', 'Get posts linking to given post.', [requiredString('id')], 'PostData[]'),
method('posts.rebuildLinks', 'Rebuild post link graph.', [], 'void'),
method('posts.isSlugAvailable', 'Check if post slug is available.', [requiredString('slug'), optionalString('excludePostId')], 'boolean'),
method('posts.generateUniqueSlug', 'Generate unique slug from title.', [requiredString('title'), optionalString('excludePostId')], 'string'),
method('media.import', 'Import media file.', [requiredString('sourcePath'), optionalObject('metadata')], 'MediaData'),
method('media.update', 'Update media metadata by id.', [requiredString('id'), requiredObject('data')], 'MediaData | null'),
method('media.replaceFile', 'Replace media file by id.', [requiredString('id'), requiredString('newSourcePath')], 'MediaData | null'),
method('media.delete', 'Delete media by id.', [requiredString('id')], 'boolean'),
method('media.get', 'Fetch one media by id.', [requiredString('id')], 'MediaData | null'),
method('media.getUrl', 'Get media URL by id.', [requiredString('id')], 'string | null'),
method('media.getFilePath', 'Get media file path by id.', [requiredString('id')], 'string | null'),
method('media.getAll', 'Fetch all media.', [], 'MediaData[]'),
method('media.rebuildFromFiles', 'Rebuild media database from files.', [], 'void'),
method('media.reindexText', 'Reindex media search text.', [], 'void'),
method('media.getThumbnail', 'Get media thumbnail URL.', [requiredString('id'), optionalString('size')], 'string | null'),
method('media.regenerateThumbnails', 'Regenerate thumbnails for media.', [requiredString('id')], 'Record<string, string> | null'),
method('media.regenerateMissingThumbnails', 'Regenerate all missing thumbnails.', [], '{ processed: number; generated: number; failed: number }'),
method('media.filter', 'Filter media by criteria.', [requiredObject('filter')], 'MediaData[]'),
method('media.search', 'Search media by free-text query.', [requiredString('query')], 'MediaSearchResult[]'),
method('media.getByYearMonth', 'Get media counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
method('media.getTags', 'Get all media tags.', [], 'string[]'),
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
method('scripts.create', 'Create script.', [requiredObject('data')], 'ScriptData'),
method('scripts.update', 'Update script by id.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
method('scripts.get', 'Fetch script by id.', [requiredString('id')], 'ScriptData | null'),
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
method('tasks.clearCompleted', 'Clear completed tasks.', [], 'void'),
method('app.getDataPaths', 'Get app data paths.', [], '{ database: string; posts: string; media: string }'),
method('app.getSystemLanguage', 'Get system language.', [], 'string'),
method('app.getTitleBarMetrics', 'Get title bar metrics.', [], '{ macosLeftInset: number } | null'),
method('app.openFolder', 'Open folder in system file manager.', [requiredString('folderPath')], 'string'),
method('app.showItemInFolder', 'Reveal item in system file manager.', [requiredString('itemPath')], 'void'),
method('app.selectFolder', 'Show folder picker dialog.', [optionalString('title')], 'string | null'),
method('app.getDefaultProjectPath', 'Get default project path.', [requiredString('projectId')], 'string'),
method('app.readProjectMetadata', 'Read project metadata from path.', [requiredString('folderPath')], '{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null'),
method('app.getBlogmarkBookmarklet', 'Get blogmark bookmarklet script.', [], 'string'),
method('app.copyToClipboard', 'Copy text to clipboard.', [requiredString('text')], 'boolean'),
method('app.notifyRendererReady', 'Notify main process renderer is ready.', [], 'boolean'),
method('app.setPreviewPostTarget', 'Set preview post target.', [requiredStringOrNull('postId')], 'void'),
method('app.triggerMenuAction', 'Trigger menu action.', [requiredString('action')], 'void'),
method('meta.getTags', 'Get project tags.', [], 'string[]'),
method('meta.getCategories', 'Get project categories.', [], 'string[]'),
method('meta.addTag', 'Add project tag.', [requiredString('tag')], 'string[]'),
method('meta.removeTag', 'Remove project tag.', [requiredString('tag')], 'string[]'),
method('meta.addCategory', 'Add project category.', [requiredString('category')], 'string[]'),
method('meta.removeCategory', 'Remove project category.', [requiredString('category')], 'string[]'),
method('meta.syncOnStartup', 'Sync meta values on startup.', [], '{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }'),
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'),
method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'),
method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'),
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
method('tags.get', 'Fetch tag by id.', [requiredString('id')], 'TagData | null'),
method('tags.getByName', 'Fetch tag by name.', [requiredString('name')], 'TagData | null'),
method('tags.create', 'Create tag.', [requiredObject('data')], 'TagData'),
method('tags.update', 'Update tag by id.', [requiredString('id'), requiredObject('data')], 'TagData | null'),
method('tags.delete', 'Delete tag by id.', [requiredString('id')], 'DeleteTagResult'),
method('tags.merge', 'Merge tags into target tag.', [requiredArray('sourceTagIds'), requiredString('targetTagId')], 'MergeTagsResult'),
method('tags.rename', 'Rename tag by id.', [requiredString('id'), requiredString('newName')], 'RenameTagResult'),
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
method('chat.checkReady', 'Check chat backend readiness.', [], 'ChatReadyStatus'),
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
method('chat.setSystemPrompt', 'Set system prompt.', [requiredString('prompt')], '{ success: boolean; error?: string }'),
method('chat.getConversations', 'Fetch all chat conversations.', [], 'ChatConversation[]'),
method('chat.createConversation', 'Create a chat conversation.', [optionalString('title'), optionalString('model')], 'ChatConversation'),
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
method('chat.setConversationModel', 'Set model for a conversation.', [requiredString('conversationId'), requiredString('modelId')], 'void'),
method('chat.analyzeTaxonomy', 'Analyze categories and tags using AI.', [requiredArray('categories'), requiredArray('tags'), requiredString('modelId')], '{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }'),
method('chat.analyzeMediaImage', 'Analyze media image and propose metadata.', [requiredString('mediaId'), optionalString('language')], '{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }'),
method('sync.configure', 'Configure sync.', [requiredObject('config')], 'void'),
method('sync.start', 'Start sync operation.', [optionalString('direction')], 'SyncResult'),
method('sync.getStatus', 'Get sync status.', [], "'idle' | 'syncing' | 'error'"),
method('sync.isConfigured', 'Check if sync is configured.', [], 'boolean'),
method('sync.getPendingCount', 'Get pending sync item count.', [], '{ posts: number; media: number }'),
method('sync.getLog', 'Get sync log.', [optionalNumber('limit')], 'unknown[]'),
method('sync.stopAutoSync', 'Stop automatic sync.', [], 'void'),
];
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{
name: 'ProjectData',
description: 'Project metadata stored in the app database.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique project identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable project name.' },
{ name: 'slug', type: 'string', required: true, description: 'URL-friendly project slug.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Filesystem path for project data.' },
{ name: 'isActive', type: 'boolean', required: true, description: 'Whether this project is currently active.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'PostData',
description: 'Canonical post object used across editor and generation flows.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique post identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'title', type: 'string', required: true, description: 'Post title.' },
{ name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' },
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
],
},
{
name: 'MediaData',
description: 'Canonical media object representing imported files and metadata.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique media identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'filename', type: 'string', required: true, description: 'Stored filename in project media folder.' },
{ name: 'originalName', type: 'string', required: true, description: 'Original imported filename.' },
{ name: 'mimeType', type: 'string', required: true, description: 'Detected MIME type.' },
{ name: 'size', type: 'number', required: true, description: 'File size in bytes.' },
{ name: 'width', type: 'number', required: false, description: 'Image width in pixels when available.' },
{ name: 'height', type: 'number', required: false, description: 'Image height in pixels when available.' },
{ name: 'title', type: 'string', required: false, description: 'Optional display title.' },
{ name: 'alt', type: 'string', required: false, description: 'Optional alternative text.' },
{ name: 'caption', type: 'string', required: false, description: 'Optional caption text.' },
{ name: 'author', type: 'string', required: false, description: 'Optional author credit.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'tags', type: 'string[]', required: true, description: 'List of media tags.' },
],
},
{
name: 'ScriptData',
description: 'Script definition for Python macros, utilities, and transforms.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique script identifier.' },
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
{ name: 'slug', type: 'string', required: true, description: 'Stable script slug.' },
{ name: 'title', type: 'string', required: true, description: 'Human-readable script title.' },
{ name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' },
{ name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' },
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' },
{ name: 'version', type: 'number', required: true, description: 'Incrementing script version.' },
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to script file.' },
{ name: 'content', type: 'string', required: true, description: 'Script source code.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'TaskProgress',
description: 'Task queue status object for long-running operations.',
fields: [
{ name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Task display name.' },
{ name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' },
{ name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' },
{ name: 'message', type: 'string', required: true, description: 'Current progress message.' },
{ name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' },
{ name: 'endTime', type: 'string', required: false, description: 'Task completion time (ISO string).' },
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
{ name: 'groupId', type: 'string', required: false, description: 'Optional grouping id.' },
{ name: 'groupName', type: 'string', required: false, description: 'Optional grouping label.' },
],
},
{
name: 'ProjectMetadata',
description: 'Extended project metadata from project settings.',
fields: [
{ name: 'name', type: 'string', required: true, description: 'Project display name.' },
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
{ name: 'dataPath', type: 'string', required: false, description: 'Optional custom data path.' },
{ name: 'publicUrl', type: 'string', required: false, description: 'Optional public site URL.' },
{ name: 'mainLanguage', type: 'string', required: false, description: 'Main render language code.' },
{ name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' },
{ name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' },
{ name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' },
{ name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' },
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
],
},
{
name: 'ChatConversation',
description: 'Chat conversation container.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique conversation identifier.' },
{ name: 'title', type: 'string', required: true, description: 'Conversation title.' },
{ name: 'model', type: 'string', required: false, description: 'Optional model id used by this conversation.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
],
},
{
name: 'ChatMessage',
description: 'Single message entry in a conversation history.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique message identifier.' },
{ name: 'conversationId', type: 'string', required: true, description: 'Owning conversation id.' },
{ name: 'role', type: "'user' | 'assistant' | 'system' | 'tool'", required: true, description: 'Message author role.' },
{ name: 'content', type: 'string', required: true, description: 'Message text content.' },
{ name: 'toolCallId', type: 'string', required: false, description: 'Tool call id when associated with tool output.' },
{ name: 'toolCalls', type: 'string', required: false, description: 'Serialized tool call payload when present.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
],
},
{
name: 'ChatModel',
description: 'Available chat model descriptor.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Model identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable model name.' },
{ name: 'provider', type: 'string', required: false, description: 'Model provider name.' },
],
},
{
name: 'ChatReadyStatus',
description: 'Chat backend readiness status.',
fields: [
{ name: 'ready', type: 'boolean', required: true, description: 'Whether chat backend is ready.' },
{ name: 'error', type: 'string', required: false, description: 'Error description when not ready.' },
{ name: 'backend', type: 'string', required: false, description: 'Selected backend identifier.' },
],
},
{
name: 'ChatApiKeyStatus',
description: 'Stored API key state for chat provider.',
fields: [
{ name: 'hasKey', type: 'boolean', required: true, description: 'Whether a key is configured.' },
{ name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.6.0',
generatedAt: '2026-02-25T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};
export function listPythonApiMethodNames(): string[] {
return BDS_PYTHON_API_CONTRACT_V1.methods.map((entry) => entry.method);
}
export function getPythonApiMethodContract(methodName: string): PythonApiMethodContractV1 | undefined {
return BDS_PYTHON_API_CONTRACT_V1.methods.find((entry) => entry.method === methodName);
}
export function getPythonApiDataStructureContracts(): PythonApiDataStructureContractV1[] {
return BDS_PYTHON_API_CONTRACT_V1.dataStructures;
}

View File

@@ -1,4 +1,4 @@
import { getPythonApiMethodContract, type PythonApiParamContractV1 } from './pythonApiContractV1'; import { getPythonApiMethodContract, type PythonApiParamContractV1 } from '../../main/shared/pythonApiContractV1';
function asRecord(value: unknown): Record<string, unknown> { function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {

View File

@@ -3,7 +3,7 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1'; import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
import { resolvePyodideIndexURL } from './pyodideAssetUrl'; import { resolvePyodideIndexURL } from './pyodideAssetUrl';
import { runPythonSyntaxCheck } from './pythonSyntaxCheck'; import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
import { generatePythonApiModuleV1 } from './generatePythonApiModuleV1'; import { generatePythonApiModuleV1 } from '../../main/shared/generatePythonApiModuleV1';
let runtime: PyodideInterface | null = null; let runtime: PyodideInterface | null = null;
let activeRequestId: string | null = null; let activeRequestId: string | null = null;
@@ -181,11 +181,21 @@ async function runMacroV1(request: PythonWorkerRequest): Promise<void> {
const validatedContext = parseMacroContextV1(request.context); const validatedContext = parseMacroContextV1(request.context);
runtime.globals.set('__bds_context_v1', validatedContext); runtime.globals.set('__bds_context_v1', validatedContext);
const macroEntrypoint = request.entrypoint || 'render';
runtime.globals.set('__bds_macro_entrypoint', macroEntrypoint);
runtime.globals.set('__bds_macro_post_data_json', request.postDataJson ?? '');
await runPythonCode(request.code, request.cacheKey); await runPythonCode(request.code, request.cacheKey);
const rawJsonResult = await runtime.runPythonAsync(` const rawJsonResult = await runtime.runPythonAsync(`
import json import json as _json
json.dumps(render(__bds_context_v1)) _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_post_json = __bds_macro_post_data_json
_macro_post = _json.loads(_macro_post_json) if _macro_post_json else None
_json.dumps(_macro_fn(__bds_context_v1, _macro_post))
`); `);
const parsedResult = parseMacroResultV1(JSON.parse(toResultString(rawJsonResult))); const parsedResult = parseMacroResultV1(JSON.parse(toResultString(rawJsonResult)));

View File

@@ -21,6 +21,8 @@ export type PythonWorkerRequest =
requestId: string; requestId: string;
code: string; code: string;
context: MacroContextV1; context: MacroContextV1;
entrypoint?: string;
postDataJson?: string | null;
cacheKey?: string; cacheKey?: string;
} }
| { | {

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AppApiAdapter } from '../../src/main/engine/AppApiAdapter';
const { mockProjectEngine, mockDatabase, mockReadFile } = vi.hoisted(() => ({
mockProjectEngine: {
getActiveProject: vi.fn().mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' }),
getProjectPaths: vi.fn().mockReturnValue({ posts: '/projects/blog/posts', media: '/projects/blog/media' }),
getDefaultProjectBaseDir: vi.fn().mockResolvedValue('/home/user/bDS/p1'),
},
mockDatabase: {
getDataPaths: vi.fn().mockReturnValue({ database: '/data/bds.db' }),
},
mockReadFile: vi.fn(),
}));
vi.mock('../../src/main/engine/ProjectEngine', () => ({
getProjectEngine: () => mockProjectEngine,
}));
vi.mock('../../src/main/database', () => ({
getDatabase: () => mockDatabase,
}));
vi.mock('electron', () => ({
app: { getLocale: () => 'en-US' },
}));
vi.mock('fs/promises', () => ({
default: { readFile: mockReadFile },
readFile: mockReadFile,
}));
describe('AppApiAdapter', () => {
let adapter: AppApiAdapter;
beforeEach(() => {
vi.clearAllMocks();
mockProjectEngine.getActiveProject.mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' });
mockProjectEngine.getProjectPaths.mockReturnValue({ posts: '/projects/blog/posts', media: '/projects/blog/media' });
mockProjectEngine.getDefaultProjectBaseDir.mockResolvedValue('/home/user/bDS/p1');
mockDatabase.getDataPaths.mockReturnValue({ database: '/data/bds.db' });
adapter = new AppApiAdapter();
});
it('getDataPaths returns database, posts, and media paths', async () => {
const result = await adapter.getDataPaths();
expect(result).toEqual({
database: '/data/bds.db',
posts: '/projects/blog/posts',
media: '/projects/blog/media',
});
});
it('getSystemLanguage returns electron app locale', async () => {
const result = await adapter.getSystemLanguage();
expect(result).toBe('en-US');
});
it('getDefaultProjectPath delegates to ProjectEngine', async () => {
const result = await adapter.getDefaultProjectPath('p1');
expect(mockProjectEngine.getDefaultProjectBaseDir).toHaveBeenCalledWith('p1');
expect(result).toBe('/home/user/bDS/p1');
});
it('readProjectMetadata returns metadata from project.json', async () => {
mockReadFile.mockResolvedValueOnce(
JSON.stringify({ name: 'My Blog', description: 'Test', publicUrl: 'https://blog.example.com', mainLanguage: 'en', dataPath: '/secret' }),
);
const result = await adapter.readProjectMetadata('/projects/blog');
expect(result).toEqual({
name: 'My Blog',
description: 'Test',
publicUrl: 'https://blog.example.com',
mainLanguage: 'en',
});
// dataPath should be excluded
expect(result).not.toHaveProperty('dataPath');
});
it('readProjectMetadata returns null when file does not exist', async () => {
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
const result = await adapter.readProjectMetadata('/nonexistent');
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,350 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
BlogmarkPythonWorkerRuntime,
type BlogmarkWorkerLike,
type BlogmarkWorkerFactory,
} from '../../src/main/engine/BlogmarkPythonWorkerRuntime';
function createMockWorkerFactory(): {
factory: BlogmarkWorkerFactory;
postMessages: unknown[];
triggerReady: () => void;
triggerResult: (requestId: string, output: unknown, toasts?: string[]) => void;
triggerError: (requestId: string, error: string) => void;
triggerFatalError: (error: string) => void;
} {
const postMessages: unknown[] = [];
let messageHandler: ((msg: unknown) => void) | null = null;
let readyCallback: (() => void) | null = null;
const triggerReady = () => {
messageHandler?.({ type: 'ready' });
};
const triggerResult = (requestId: string, output: unknown, toasts: string[] = []) => {
messageHandler?.({ type: 'transformResult', requestId, output, toasts });
};
const triggerError = (requestId: string, error: string) => {
messageHandler?.({ type: 'transformError', requestId, error });
};
const triggerFatalError = (error: string) => {
messageHandler?.({ type: 'error', error });
};
const factory: BlogmarkWorkerFactory = () => {
const worker: BlogmarkWorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) {
postMessages.push(message);
},
terminate() {},
removeAllListeners() {
messageHandler = null;
},
};
readyCallback = () => triggerReady();
// Auto-ready after microtask
setTimeout(() => readyCallback?.(), 5);
return worker;
};
return { factory, postMessages, triggerReady, triggerResult, triggerError, triggerFatalError };
}
function createAutoRespondFactory(): {
factory: BlogmarkWorkerFactory;
postMessages: unknown[];
} {
const postMessages: unknown[] = [];
const factory: BlogmarkWorkerFactory = () => {
let messageHandler: ((msg: unknown) => void) | null = null;
const worker: BlogmarkWorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) {
postMessages.push(message);
const msg = message as { type: string; requestId: string };
if (msg.type === 'runTransform') {
setTimeout(() => {
messageHandler?.({
type: 'transformResult',
requestId: msg.requestId,
output: { transformed: true },
toasts: ['done'],
});
}, 10);
}
},
terminate() {},
removeAllListeners() {
messageHandler = null;
},
};
setTimeout(() => messageHandler?.({ type: 'ready' }), 5);
return worker;
};
return { factory, postMessages };
}
describe('BlogmarkPythonWorkerRuntime', () => {
let runtime: BlogmarkPythonWorkerRuntime;
beforeEach(() => {
vi.clearAllMocks();
});
it('should execute a transform successfully', async () => {
const { factory } = createAutoRespondFactory();
runtime = new BlogmarkPythonWorkerRuntime(factory);
const result = await runtime.executeTransform({
scriptContent: 'def transform(payload): return payload',
entrypoint: 'transform',
payloadJson: JSON.stringify({ url: 'https://example.com' }),
timeoutMs: 3000,
});
expect(result.output).toEqual({ transformed: true });
expect(result.toasts).toEqual(['done']);
});
it('should pass correct request shape to worker', async () => {
const { factory, postMessages } = createAutoRespondFactory();
runtime = new BlogmarkPythonWorkerRuntime(factory);
await runtime.executeTransform({
scriptContent: 'def t(p): return p',
entrypoint: 'transform',
payloadJson: '{}',
});
const request = postMessages[0] as Record<string, unknown>;
expect(request.type).toBe('runTransform');
expect(request.scriptContent).toBe('def t(p): return p');
expect(request.entrypoint).toBe('transform');
expect(request.payloadJson).toBe('{}');
expect(request.requestId).toMatch(/^blogmark-py-/);
});
it('should reject when worker returns an error', async () => {
const { factory, triggerError } = createMockWorkerFactory();
runtime = new BlogmarkPythonWorkerRuntime(factory);
const promise = runtime.executeTransform({
scriptContent: 'def bad(): raise Exception("fail")',
entrypoint: 'bad',
payloadJson: '{}',
timeoutMs: 3000,
});
// Wait for worker ready + dispatch
await new Promise((r) => setTimeout(r, 20));
triggerError('blogmark-py-1', 'Script execution failed');
await expect(promise).rejects.toThrow('Script execution failed');
});
it('should reject on timeout', async () => {
const { factory } = createMockWorkerFactory();
runtime = new BlogmarkPythonWorkerRuntime(factory);
const promise = runtime.executeTransform({
scriptContent: 'import time; time.sleep(999)',
entrypoint: 'slow',
payloadJson: '{}',
timeoutMs: 50,
});
await expect(promise).rejects.toThrow('Python transform timed out after 50ms');
});
it('should reject on fatal worker error', async () => {
const { factory, triggerFatalError } = createMockWorkerFactory();
runtime = new BlogmarkPythonWorkerRuntime(factory);
const promise = runtime.executeTransform({
scriptContent: '',
entrypoint: 'x',
payloadJson: '{}',
timeoutMs: 3000,
});
await new Promise((r) => setTimeout(r, 20));
triggerFatalError('Pyodide failed to load');
await expect(promise).rejects.toThrow('Pyodide failed to load');
});
it('should serialize concurrent requests (queue)', async () => {
// Use a factory where we manually control responses, worker starts ready
const postMessages: unknown[] = [];
let messageHandler: ((msg: unknown) => void) | null = null;
const factory: BlogmarkWorkerFactory = () => {
const worker: BlogmarkWorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) { postMessages.push(message); },
terminate() {},
removeAllListeners() { messageHandler = null; },
};
setTimeout(() => messageHandler?.({ type: 'ready' }), 0);
return worker;
};
runtime = new BlogmarkPythonWorkerRuntime(factory);
// Prime the worker so it's ready
const p0 = runtime.executeTransform({
scriptContent: 'init', entrypoint: 'run', payloadJson: '{}', timeoutMs: 3000,
});
await new Promise((r) => setTimeout(r, 10));
const initReq = postMessages[0] as { requestId: string };
messageHandler?.({ type: 'transformResult', requestId: initReq.requestId, output: 'ok', toasts: [] });
await p0;
postMessages.length = 0;
// Now enqueue two concurrent requests on an active worker
const p1 = runtime.executeTransform({ scriptContent: 'first', entrypoint: 'run', payloadJson: '{}', timeoutMs: 3000 });
const p2 = runtime.executeTransform({ scriptContent: 'second', entrypoint: 'run', payloadJson: '{}', timeoutMs: 3000 });
await new Promise((r) => setTimeout(r, 10));
// Only first should be dispatched
expect(postMessages.length).toBe(1);
const firstReq = postMessages[0] as { requestId: string; scriptContent: string };
expect(firstReq.scriptContent).toBe('first');
// Complete first → second should dispatch
messageHandler?.({ type: 'transformResult', requestId: firstReq.requestId, output: 'result-1', toasts: [] });
const r1 = await p1;
expect(r1.output).toBe('result-1');
await new Promise((r) => setTimeout(r, 10));
expect(postMessages.length).toBe(2);
const secondReq = postMessages[1] as { requestId: string; scriptContent: string };
expect(secondReq.scriptContent).toBe('second');
messageHandler?.({ type: 'transformResult', requestId: secondReq.requestId, output: 'result-2', toasts: [] });
const r2 = await p2;
expect(r2.output).toBe('result-2');
});
it('should dispose without errors', async () => {
const { factory } = createAutoRespondFactory();
runtime = new BlogmarkPythonWorkerRuntime(factory);
await runtime.executeTransform({
scriptContent: 'def t(p): return p',
entrypoint: 't',
payloadJson: '{}',
});
expect(() => runtime.dispose()).not.toThrow();
});
it('should reject queued requests on dispose', async () => {
const { factory } = createMockWorkerFactory();
runtime = new BlogmarkPythonWorkerRuntime(factory);
const p1 = runtime.executeTransform({
scriptContent: '',
entrypoint: 'x',
payloadJson: '{}',
timeoutMs: 5000,
});
// Wait for worker start
await new Promise((r) => setTimeout(r, 20));
runtime.dispose();
await expect(p1).rejects.toThrow('Python worker runtime disposed');
});
it('should recover from worker crash and accept new requests', async () => {
let workerCount = 0;
let messageHandler: ((msg: unknown) => void) | null = null;
const factory: BlogmarkWorkerFactory = () => {
workerCount++;
const currentWorker = workerCount;
const worker: BlogmarkWorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) {
const msg = message as { type: string; requestId: string; scriptContent: string };
if (msg.type === 'runTransform' && msg.scriptContent !== 'hang') {
setTimeout(() => {
messageHandler?.({
type: 'transformResult',
requestId: msg.requestId,
output: `result-from-worker-${currentWorker}`,
toasts: [],
});
}, 5);
}
// 'hang' requests get no response — they will time out
},
terminate() {},
removeAllListeners() { messageHandler = null; },
};
setTimeout(() => messageHandler?.({ type: 'ready' }), 0);
return worker;
};
runtime = new BlogmarkPythonWorkerRuntime(factory);
// First request succeeds
const r1 = await runtime.executeTransform({
scriptContent: 'ok',
entrypoint: 'x',
payloadJson: '{}',
timeoutMs: 3000,
});
expect(r1.output).toBe('result-from-worker-1');
// Force crash by timing out the next request (no response for 'hang')
const crashPromise = runtime.executeTransform({
scriptContent: 'hang',
entrypoint: 'x',
payloadJson: '{}',
timeoutMs: 30,
});
await expect(crashPromise).rejects.toThrow('timed out');
// New request should create a new worker and succeed
const r2 = await runtime.executeTransform({
scriptContent: 'ok',
entrypoint: 'x',
payloadJson: '{}',
timeoutMs: 3000,
});
expect(r2.output).toBe('result-from-worker-2');
expect(workerCount).toBe(2); // original + recovery after timeout reset
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GitApiAdapter } from '../../src/main/engine/GitApiAdapter';
const { mockGitEngine, mockProjectEngine } = vi.hoisted(() => ({
mockGitEngine: {
checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.40.0' }),
getRepoState: vi.fn().mockResolvedValue({ isRepo: true, currentBranch: 'main', hasRemote: true }),
getStatus: vi.fn().mockResolvedValue({ files: [], counts: { untracked: 0, modified: 0, deleted: 0, renamed: 0, staged: 0 } }),
getHistory: vi.fn().mockResolvedValue([]),
getRemoteState: vi.fn().mockResolvedValue({ localBranch: 'main', upstreamBranch: 'origin/main', hasUpstream: true, ahead: 0, behind: 0 }),
fetch: vi.fn().mockResolvedValue({ success: true }),
pull: vi.fn().mockResolvedValue({ success: true }),
push: vi.fn().mockResolvedValue({ success: true }),
commitAll: vi.fn().mockResolvedValue({ success: true }),
},
mockProjectEngine: {
getActiveProject: vi.fn().mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' }),
},
}));
vi.mock('../../src/main/engine/GitEngine', () => ({
getGitEngine: () => mockGitEngine,
}));
vi.mock('../../src/main/engine/ProjectEngine', () => ({
getProjectEngine: () => mockProjectEngine,
}));
describe('GitApiAdapter', () => {
let adapter: GitApiAdapter;
beforeEach(() => {
vi.clearAllMocks();
adapter = new GitApiAdapter();
});
it('checkAvailability delegates directly (no projectPath)', async () => {
const result = await adapter.checkAvailability();
expect(mockGitEngine.checkAvailability).toHaveBeenCalledWith();
expect(result).toEqual({ gitFound: true, version: '2.40.0' });
});
it('getRepoState resolves projectPath from active project', async () => {
await adapter.getRepoState();
expect(mockProjectEngine.getActiveProject).toHaveBeenCalled();
expect(mockGitEngine.getRepoState).toHaveBeenCalledWith('/projects/blog');
});
it('getStatus resolves projectPath', async () => {
await adapter.getStatus();
expect(mockGitEngine.getStatus).toHaveBeenCalledWith('/projects/blog');
});
it('getHistory passes limit and resolves projectPath', async () => {
await adapter.getHistory(10);
expect(mockGitEngine.getHistory).toHaveBeenCalledWith('/projects/blog', 10);
});
it('getHistory passes undefined limit', async () => {
await adapter.getHistory();
expect(mockGitEngine.getHistory).toHaveBeenCalledWith('/projects/blog', undefined);
});
it('getRemoteState resolves projectPath', async () => {
await adapter.getRemoteState();
expect(mockGitEngine.getRemoteState).toHaveBeenCalledWith('/projects/blog');
});
it('fetch resolves projectPath', async () => {
await adapter.fetch();
expect(mockGitEngine.fetch).toHaveBeenCalledWith('/projects/blog');
});
it('pull resolves projectPath', async () => {
await adapter.pull();
expect(mockGitEngine.pull).toHaveBeenCalledWith('/projects/blog');
});
it('push resolves projectPath', async () => {
await adapter.push();
expect(mockGitEngine.push).toHaveBeenCalledWith('/projects/blog');
});
it('commitAll resolves projectPath and passes message', async () => {
await adapter.commitAll('update files');
expect(mockGitEngine.commitAll).toHaveBeenCalledWith('/projects/blog', 'update files');
});
it('throws when no active project', async () => {
mockProjectEngine.getActiveProject.mockResolvedValueOnce(null);
await expect(adapter.getRepoState()).rejects.toThrow('No active project with a data path');
});
it('throws when active project has no dataPath', async () => {
mockProjectEngine.getActiveProject.mockResolvedValueOnce({ id: 'p1', dataPath: null });
await expect(adapter.fetch()).rejects.toThrow('No active project with a data path');
});
});

View File

@@ -0,0 +1,252 @@
import { describe, it, expect, vi } from 'vitest';
import {
renderMacro,
replaceAllMacrosAsync,
isBuiltInMacro,
normalizeMacroName,
type PythonMacroRendererContract,
type PythonMacroScript,
} from '../../src/main/engine/PageRenderer';
describe('isBuiltInMacro', () => {
it('returns true for all known JS built-in macro names', () => {
expect(isBuiltInMacro('youtube')).toBe(true);
expect(isBuiltInMacro('vimeo')).toBe(true);
expect(isBuiltInMacro('gallery')).toBe(true);
expect(isBuiltInMacro('photo_archive')).toBe(true);
expect(isBuiltInMacro('tag_cloud')).toBe(true);
expect(isBuiltInMacro('photo_album')).toBe(true);
});
it('returns false for unknown macro names', () => {
expect(isBuiltInMacro('my_custom_macro')).toBe(false);
expect(isBuiltInMacro('python_report')).toBe(false);
expect(isBuiltInMacro('data_table')).toBe(false);
});
});
describe('replaceAllMacrosAsync', () => {
it('replaces built-in JS macros without Python renderer', async () => {
const result = await replaceAllMacrosAsync(
'Before [[youtube id="abc123"]] After',
'post-1',
[],
null,
[],
'en',
null,
);
expect(result).toContain('class="macro-youtube"');
expect(result).toContain('abc123');
expect(result).not.toContain('[[youtube');
});
it('replaces Python macros when renderer is provided', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'script-1',
slug: 'my_widget',
entrypoint: 'render',
content: 'def render(ctx): return {"html": "<div>Widget</div>"}',
version: 1,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockResolvedValue({
html: '<div class="python-widget">Hello from Python</div>',
}),
};
const result = await replaceAllMacrosAsync(
'Before [[my_widget title="Hello"]] After',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
expect(result).toBe('Before <div class="python-widget">Hello from Python</div> After');
expect(mockRenderer.renderMacro).toHaveBeenCalledWith(
expect.objectContaining({
scriptContent: 'def render(ctx): return {"html": "<div>Widget</div>"}',
entrypoint: 'render',
cacheKey: 'script-1:1',
}),
);
});
it('preserves JS macros alongside Python macros', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'script-2',
slug: 'custom_box',
entrypoint: 'render',
content: 'def render(ctx): return {"html": "<aside>box</aside>"}',
version: 3,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockResolvedValue({
html: '<aside class="custom-box">Custom Content</aside>',
}),
};
const result = await replaceAllMacrosAsync(
'[[youtube id="xyz789"]] then [[custom_box]]',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
expect(result).toContain('class="macro-youtube"');
expect(result).toContain('xyz789');
expect(result).toContain('<aside class="custom-box">Custom Content</aside>');
});
it('returns empty string for unknown macros without Python renderer', async () => {
const result = await replaceAllMacrosAsync(
'Before [[unknown_macro]] After',
'post-1',
[],
null,
[],
'en',
null,
);
expect(result).toBe('Before After');
});
it('returns empty string for unmatched Python macros', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([]),
renderMacro: vi.fn(),
};
const result = await replaceAllMacrosAsync(
'Before [[nonexistent_macro]] After',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
expect(result).toBe('Before After');
expect(mockRenderer.renderMacro).not.toHaveBeenCalled();
});
it('handles Python macro rendering errors gracefully', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'script-err',
slug: 'broken_macro',
entrypoint: 'render',
content: 'def render(ctx): raise Exception("oops")',
version: 1,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockRejectedValue(new Error('Python execution failed')),
};
const result = await replaceAllMacrosAsync(
'Before [[broken_macro]] After',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
expect(result).toBe('Before After');
});
it('handles script resolution errors gracefully', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockRejectedValue(new Error('DB error')),
renderMacro: vi.fn(),
};
const result = await replaceAllMacrosAsync(
'Before [[my_macro]] After',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
expect(result).toBe('Before After');
});
it('does not look up Python scripts when all macros are built-in', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([]),
renderMacro: vi.fn(),
};
await replaceAllMacrosAsync(
'[[youtube id="a"]] [[vimeo id="1"]]',
'post-1',
[],
null,
[],
'en',
mockRenderer,
);
expect(mockRenderer.getEnabledMacroScripts).not.toHaveBeenCalled();
});
it('passes correct context to Python macro renderer', async () => {
const mockRenderer: PythonMacroRendererContract = {
getEnabledMacroScripts: vi.fn().mockResolvedValue([
{
id: 'ctx-script',
slug: 'ctx_test',
entrypoint: 'run',
content: 'def run(ctx): return {"html": "ok"}',
version: 2,
},
] satisfies PythonMacroScript[]),
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
};
await replaceAllMacrosAsync(
'[[ctx_test name="Alice" count="5"]]',
'post-42',
[],
null,
[],
'de',
mockRenderer,
);
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
const parsedContext = JSON.parse(call.contextJson);
expect(parsedContext.env.isPreview).toBe(false);
expect(parsedContext.env.mainLanguage).toBe('de');
expect(parsedContext.env.hook).toBe('ctx_test');
expect(parsedContext.env.source).toEqual({ kind: 'macro', id: 'ctx-script' });
expect(parsedContext.params).toEqual({ name: 'Alice', count: '5' });
expect(call.entrypoint).toBe('run');
expect(call.cacheKey).toBe('ctx-script:2');
});
it('returns unchanged text when there are no macros', async () => {
const content = 'Just plain text with no macros';
const result = await replaceAllMacrosAsync(content, '', [], null, [], 'en', null);
expect(result).toBe(content);
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PublishApiAdapter } from '../../src/main/engine/PublishApiAdapter';
const { mockPublishEngine, mockProjectEngine, mockTaskManager } = vi.hoisted(() => ({
mockPublishEngine: {
setProjectContext: vi.fn(),
uploadHtml: vi.fn().mockResolvedValue({ filesUploaded: 5, filesSkipped: 2 }),
uploadThumbnails: vi.fn().mockResolvedValue({ filesUploaded: 3, filesSkipped: 1 }),
uploadMedia: vi.fn().mockResolvedValue({ filesUploaded: 10, filesSkipped: 4 }),
},
mockProjectEngine: {
getActiveProject: vi.fn().mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' }),
},
mockTaskManager: {
runTask: vi.fn().mockImplementation((opts: { execute: (onProgress: () => void) => Promise<unknown> }) => {
return opts.execute(() => {});
}),
},
}));
vi.mock('../../src/main/engine/PublishEngine', () => ({
getPublishEngine: () => mockPublishEngine,
}));
vi.mock('../../src/main/engine/ProjectEngine', () => ({
getProjectEngine: () => mockProjectEngine,
}));
vi.mock('../../src/main/engine/TaskManager', () => ({
taskManager: mockTaskManager,
}));
describe('PublishApiAdapter', () => {
let adapter: PublishApiAdapter;
const creds = { sshHost: 'example.com', sshUser: 'deploy', sshRemotePath: '/var/www', sshMode: 'rsync' as const };
beforeEach(() => {
vi.clearAllMocks();
mockPublishEngine.uploadHtml.mockResolvedValue({ filesUploaded: 5, filesSkipped: 2 });
mockPublishEngine.uploadThumbnails.mockResolvedValue({ filesUploaded: 3, filesSkipped: 1 });
mockPublishEngine.uploadMedia.mockResolvedValue({ filesUploaded: 10, filesSkipped: 4 });
mockProjectEngine.getActiveProject.mockResolvedValue({ id: 'p1', dataPath: '/projects/blog' });
mockTaskManager.runTask.mockImplementation((opts: { execute: (onProgress: () => void) => Promise<unknown> }) => {
return opts.execute(() => {});
});
adapter = new PublishApiAdapter();
});
it('sets project context before uploading', async () => {
await adapter.uploadSite(creds);
expect(mockPublishEngine.setProjectContext).toHaveBeenCalledWith('p1', '/projects/blog');
});
it('runs three parallel upload tasks', async () => {
await adapter.uploadSite(creds);
expect(mockTaskManager.runTask).toHaveBeenCalledTimes(3);
});
it('returns aggregate upload results', async () => {
const result = await adapter.uploadSite(creds);
expect(result).toEqual({
htmlFilesUploaded: 5,
thumbnailFilesUploaded: 3,
mediaFilesUploaded: 10,
filesSkipped: 7, // 2 + 1 + 4
});
});
it('passes credentials to upload methods', async () => {
await adapter.uploadSite(creds);
expect(mockPublishEngine.uploadHtml).toHaveBeenCalledWith(creds, expect.any(Function));
expect(mockPublishEngine.uploadThumbnails).toHaveBeenCalledWith(creds, expect.any(Function));
expect(mockPublishEngine.uploadMedia).toHaveBeenCalledWith(creds, expect.any(Function));
});
it('throws when no active project', async () => {
mockProjectEngine.getActiveProject.mockResolvedValueOnce(null);
await expect(adapter.uploadSite(creds)).rejects.toThrow('No active project');
});
});

View File

@@ -0,0 +1,404 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PythonMacroWorkerRuntime, type WorkerLike, type WorkerFactory } from '../../src/main/engine/PythonMacroWorkerRuntime';
function createAutoRespondFactory(): {
factory: WorkerFactory;
postMessages: unknown[];
} {
const postMessages: unknown[] = [];
const factory: WorkerFactory = () => {
let messageHandler: ((msg: unknown) => void) | null = null;
const worker: WorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) {
postMessages.push(message);
const msg = message as { type: string; requestId: string };
if (msg.type === 'renderMacro') {
setTimeout(() => {
messageHandler?.({
type: 'macroResult',
requestId: msg.requestId,
html: '<p>Python macro output</p>',
data: { key: 'value' },
warnings: [],
});
}, 10);
}
},
terminate() {},
removeAllListeners() { messageHandler = null; },
};
setTimeout(() => {
messageHandler?.({ type: 'ready' });
}, 5);
return worker;
};
return { factory, postMessages };
}
function createManualFactory(): {
factory: WorkerFactory;
postMessages: unknown[];
triggerReady: () => void;
triggerResult: (requestId: string, html: string, data?: Record<string, unknown>, warnings?: string[]) => void;
triggerError: (requestId: string, error: string) => void;
triggerFatalError: (error: string) => void;
} {
const postMessages: unknown[] = [];
let messageHandler: ((msg: unknown) => void) | null = null;
const triggerReady = () => {
messageHandler?.({ type: 'ready' });
};
const triggerResult = (requestId: string, html: string, data?: Record<string, unknown>, warnings?: string[]) => {
messageHandler?.({ type: 'macroResult', requestId, html, data, warnings });
};
const triggerError = (requestId: string, error: string) => {
messageHandler?.({ type: 'macroError', requestId, error });
};
const triggerFatalError = (error: string) => {
messageHandler?.({ type: 'error', error });
};
const factory: WorkerFactory = () => {
const worker: WorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) {
postMessages.push(message);
},
terminate() {},
removeAllListeners() { messageHandler = null; },
};
setTimeout(() => triggerReady(), 5);
return worker;
};
return { factory, postMessages, triggerReady, triggerResult, triggerError, triggerFatalError };
}
describe('PythonMacroWorkerRuntime', () => {
let runtime: PythonMacroWorkerRuntime;
beforeEach(() => {
vi.clearAllMocks();
});
it('should render a Python macro successfully', async () => {
const { factory } = createAutoRespondFactory();
runtime = new PythonMacroWorkerRuntime(factory);
const result = await runtime.renderMacro({
scriptContent: 'def render(ctx): return {"html": "<p>output</p>"}',
entrypoint: 'render',
contextJson: JSON.stringify({ env: { isPreview: false } }),
timeoutMs: 3000,
});
expect(result.html).toBe('<p>Python macro output</p>');
expect(result.data).toEqual({ key: 'value' });
});
it('should track macro execution counter', async () => {
const { factory } = createAutoRespondFactory();
runtime = new PythonMacroWorkerRuntime(factory);
expect(runtime.macroCount).toBe(0);
await runtime.renderMacro({
scriptContent: 'def render(ctx): return {"html": ""}',
entrypoint: 'render',
contextJson: '{}',
});
expect(runtime.macroCount).toBe(1);
});
it('should reset counters', async () => {
const { factory } = createAutoRespondFactory();
runtime = new PythonMacroWorkerRuntime(factory);
await runtime.renderMacro({
scriptContent: 'def render(ctx): return {"html": ""}',
entrypoint: 'render',
contextJson: '{}',
});
expect(runtime.macroCount).toBe(1);
runtime.resetCounters();
expect(runtime.macroCount).toBe(0);
expect(runtime.errorCount).toBe(0);
expect(runtime.timeoutCount).toBe(0);
});
it('should dispose without errors', async () => {
const { factory } = createAutoRespondFactory();
runtime = new PythonMacroWorkerRuntime(factory);
await runtime.renderMacro({
scriptContent: 'def render(ctx): return {"html": ""}',
entrypoint: 'render',
contextJson: '{}',
});
expect(() => runtime.dispose()).not.toThrow();
});
it('should pass cacheKey to the worker', async () => {
const { factory, postMessages } = createAutoRespondFactory();
runtime = new PythonMacroWorkerRuntime(factory);
await runtime.renderMacro({
scriptContent: 'def render(ctx): return {"html": ""}',
entrypoint: 'render',
contextJson: '{}',
cacheKey: 'script-1:v2',
});
const lastMessage = postMessages[postMessages.length - 1] as Record<string, unknown>;
expect(lastMessage.type).toBe('renderMacro');
expect(lastMessage.cacheKey).toBe('script-1:v2');
});
it('should reject on timeout and increment timeoutCount', async () => {
const { factory } = createManualFactory();
runtime = new PythonMacroWorkerRuntime(factory);
const promise = runtime.renderMacro({
scriptContent: 'import time; time.sleep(999)',
entrypoint: 'slow',
contextJson: '{}',
timeoutMs: 50,
});
await expect(promise).rejects.toThrow('Python macro timed out after 50ms');
expect(runtime.timeoutCount).toBe(1);
expect(runtime.macroCount).toBe(0);
});
it('should increment errorCount when worker returns macroError', async () => {
const { factory, triggerError } = createManualFactory();
runtime = new PythonMacroWorkerRuntime(factory);
const promise = runtime.renderMacro({
scriptContent: 'def bad(): raise Exception("fail")',
entrypoint: 'bad',
contextJson: '{}',
timeoutMs: 3000,
});
await new Promise((r) => setTimeout(r, 20));
triggerError('py-macro-1', 'Script execution failed');
await expect(promise).rejects.toThrow('Script execution failed');
expect(runtime.errorCount).toBe(1);
expect(runtime.macroCount).toBe(1);
});
it('should reject on fatal worker error', async () => {
const { factory, triggerFatalError } = createManualFactory();
runtime = new PythonMacroWorkerRuntime(factory);
const promise = runtime.renderMacro({
scriptContent: '',
entrypoint: 'x',
contextJson: '{}',
timeoutMs: 3000,
});
await new Promise((r) => setTimeout(r, 20));
triggerFatalError('Pyodide failed to load');
await expect(promise).rejects.toThrow('Pyodide failed to load');
});
it('should serialize concurrent requests (queue)', async () => {
const postMessages: unknown[] = [];
let messageHandler: ((msg: unknown) => void) | null = null;
const factory: WorkerFactory = () => {
const worker: WorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) { postMessages.push(message); },
terminate() {},
removeAllListeners() { messageHandler = null; },
};
setTimeout(() => messageHandler?.({ type: 'ready' }), 0);
return worker;
};
runtime = new PythonMacroWorkerRuntime(factory);
// Prime the worker
const p0 = runtime.renderMacro({
scriptContent: 'init', entrypoint: 'render', contextJson: '{}', timeoutMs: 3000,
});
await new Promise((r) => setTimeout(r, 10));
const initReq = postMessages[0] as { requestId: string };
messageHandler?.({ type: 'macroResult', requestId: initReq.requestId, html: 'ok' });
await p0;
postMessages.length = 0;
// Enqueue two concurrent requests — both dispatch to the async path.
// The first will become active; the second sees activeRequest set and queues.
const p1 = runtime.renderMacro({ scriptContent: 'first', entrypoint: 'render', contextJson: '{}', timeoutMs: 3000 });
// Let p1's dispatchNext settle so activeRequest is set before p2 enqueues
await new Promise((r) => setTimeout(r, 10));
const p2 = runtime.renderMacro({ scriptContent: 'second', entrypoint: 'render', contextJson: '{}', timeoutMs: 3000 });
await new Promise((r) => setTimeout(r, 10));
// Only first should be dispatched
expect(postMessages.length).toBe(1);
const firstReq = postMessages[0] as { requestId: string; scriptContent: string };
expect(firstReq.scriptContent).toBe('first');
// Complete first → second should dispatch
messageHandler?.({ type: 'macroResult', requestId: firstReq.requestId, html: 'result-1' });
const r1 = await p1;
expect(r1.html).toBe('result-1');
await new Promise((r) => setTimeout(r, 10));
expect(postMessages.length).toBe(2);
const secondReq = postMessages[1] as { requestId: string; scriptContent: string };
expect(secondReq.scriptContent).toBe('second');
messageHandler?.({ type: 'macroResult', requestId: secondReq.requestId, html: 'result-2' });
const r2 = await p2;
expect(r2.html).toBe('result-2');
});
it('should reject queued requests on dispose', async () => {
const { factory } = createManualFactory();
runtime = new PythonMacroWorkerRuntime(factory);
const p1 = runtime.renderMacro({
scriptContent: '',
entrypoint: 'x',
contextJson: '{}',
timeoutMs: 5000,
});
await new Promise((r) => setTimeout(r, 20));
runtime.dispose();
await expect(p1).rejects.toThrow('Python macro worker runtime disposed');
});
it('should recover from worker crash and accept new requests', async () => {
let workerCount = 0;
let messageHandler: ((msg: unknown) => void) | null = null;
const factory: WorkerFactory = () => {
workerCount++;
const currentWorker = workerCount;
const worker: WorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) {
const msg = message as { type: string; requestId: string; scriptContent: string };
if (msg.type === 'renderMacro' && msg.scriptContent !== 'hang') {
setTimeout(() => {
messageHandler?.({
type: 'macroResult',
requestId: msg.requestId,
html: `result-from-worker-${currentWorker}`,
});
}, 5);
}
},
terminate() {},
removeAllListeners() { messageHandler = null; },
};
setTimeout(() => messageHandler?.({ type: 'ready' }), 0);
return worker;
};
runtime = new PythonMacroWorkerRuntime(factory);
// First request succeeds
const r1 = await runtime.renderMacro({
scriptContent: 'ok', entrypoint: 'x', contextJson: '{}', timeoutMs: 3000,
});
expect(r1.html).toBe('result-from-worker-1');
// Force crash by timing out (no response for 'hang')
const crashPromise = runtime.renderMacro({
scriptContent: 'hang', entrypoint: 'x', contextJson: '{}', timeoutMs: 30,
});
await expect(crashPromise).rejects.toThrow('timed out');
// New request should create a new worker and succeed
const r2 = await runtime.renderMacro({
scriptContent: 'ok', entrypoint: 'x', contextJson: '{}', timeoutMs: 3000,
});
expect(r2.html).toBe('result-from-worker-2');
expect(workerCount).toBe(2);
});
it('should forward warnings from worker result', async () => {
let messageHandler: ((msg: unknown) => void) | null = null;
const factory: WorkerFactory = () => {
const worker: WorkerLike = {
on(event: string, handler: (...args: unknown[]) => void) {
if (event === 'message') {
messageHandler = handler as (msg: unknown) => void;
}
},
postMessage(message: unknown) {
const msg = message as { type: string; requestId: string };
if (msg.type === 'renderMacro') {
setTimeout(() => {
messageHandler?.({
type: 'macroResult',
requestId: msg.requestId,
html: '<p>ok</p>',
warnings: ['deprecation notice', 'slow query'],
});
}, 10);
}
},
terminate() {},
removeAllListeners() { messageHandler = null; },
};
setTimeout(() => messageHandler?.({ type: 'ready' }), 5);
return worker;
};
runtime = new PythonMacroWorkerRuntime(factory);
const result = await runtime.renderMacro({
scriptContent: 'def render(ctx): return {"html": "<p>ok</p>", "warnings": ["deprecation notice"]}',
entrypoint: 'render',
contextJson: '{}',
});
expect(result.warnings).toEqual(['deprecation notice', 'slow query']);
});
});

View File

@@ -286,4 +286,95 @@ describe('ScriptEngine', () => {
expect(result.deleted).toBe(1); expect(result.deleted).toBe(1);
expect(result.processedFiles).toBe(3); expect(result.processedFiles).toBe(3);
}); });
describe('macro resolution', () => {
it('getEnabledMacroScripts returns only enabled macro scripts', async () => {
await scriptEngine.createScript({
title: 'My Macro',
kind: 'macro',
content: 'def render(ctx): return {"html": "<p>hi</p>"}',
enabled: true,
});
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-script-id-2');
await scriptEngine.createScript({
title: 'My Transform',
kind: 'transform',
content: 'def transform(post): return post',
enabled: true,
});
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-script-id-3');
await scriptEngine.createScript({
title: 'Disabled Macro',
kind: 'macro',
content: 'def render(ctx): return {"html": ""}',
enabled: false,
});
const macros = await scriptEngine.getEnabledMacroScripts();
expect(macros).toHaveLength(1);
expect(macros[0].kind).toBe('macro');
expect(macros[0].enabled).toBe(true);
expect(macros[0].title).toBe('My Macro');
});
it('getMacroScriptBySlug finds enabled macro by slug', async () => {
await scriptEngine.createScript({
title: 'Widget Macro',
kind: 'macro',
content: 'def render(ctx): return {"html": "<div>widget</div>"}',
enabled: true,
});
const found = await scriptEngine.getMacroScriptBySlug('widget_macro');
expect(found).not.toBeNull();
expect(found?.slug).toBe('widget_macro');
expect(found?.kind).toBe('macro');
});
it('getMacroScriptBySlug returns null for non-macro scripts', async () => {
await scriptEngine.createScript({
title: 'My Transform',
kind: 'transform',
content: 'def transform(post): return post',
enabled: true,
});
const found = await scriptEngine.getMacroScriptBySlug('my_transform');
expect(found).toBeNull();
});
it('getMacroScriptBySlug returns null for disabled macros', async () => {
await scriptEngine.createScript({
title: 'Disabled Macro',
kind: 'macro',
content: 'def render(ctx): return {"html": ""}',
enabled: false,
});
const found = await scriptEngine.getMacroScriptBySlug('disabled_macro');
expect(found).toBeNull();
});
it('getMacroScriptBySlug is case-insensitive', async () => {
await scriptEngine.createScript({
title: 'Case Test',
kind: 'macro',
content: 'def render(ctx): return {"html": ""}',
enabled: true,
});
const found = await scriptEngine.getMacroScriptBySlug('CASE_TEST');
expect(found).not.toBeNull();
expect(found?.slug).toBe('case_test');
});
it('getMacroScriptBySlug returns null for non-existent slug', async () => {
const found = await scriptEngine.getMacroScriptBySlug('nonexistent');
expect(found).toBeNull();
});
});
}); });

View File

@@ -0,0 +1,392 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { invokeMainProcessPythonApi, ENGINE_MAP } from '../../src/main/engine/mainProcessPythonApiInvoker';
// ── Mock engines ───────────────────────────────────────────────────
const mockPostEngine: Record<string, ReturnType<typeof vi.fn>> = {
getPost: vi.fn().mockResolvedValue({ id: 'p1', title: 'Test' }),
createPost: vi.fn().mockResolvedValue({ id: 'p2' }),
getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }),
searchPosts: vi.fn().mockResolvedValue([]),
getPostsByStatus: vi.fn().mockResolvedValue([]),
updatePost: vi.fn().mockResolvedValue(null),
deletePost: vi.fn().mockResolvedValue(true),
publishPost: vi.fn().mockResolvedValue(null),
discardChanges: vi.fn().mockResolvedValue(null),
hasPublishedVersion: vi.fn().mockResolvedValue(false),
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
reindexText: vi.fn().mockResolvedValue(undefined),
getPostsFiltered: vi.fn().mockResolvedValue([]),
getAvailableTags: vi.fn().mockResolvedValue([]),
getAvailableCategories: vi.fn().mockResolvedValue([]),
getPostsByYearMonth: vi.fn().mockResolvedValue([]),
getDashboardStats: vi.fn().mockResolvedValue({}),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]),
rebuildAllPostLinks: vi.fn().mockResolvedValue(undefined),
isSlugAvailable: vi.fn().mockResolvedValue(true),
generateUniqueSlug: vi.fn().mockResolvedValue('slug-1'),
};
const mockScriptEngine: Record<string, ReturnType<typeof vi.fn>> = {
createScript: vi.fn().mockResolvedValue({ id: 's1' }),
updateScript: vi.fn().mockResolvedValue(null),
deleteScript: vi.fn().mockResolvedValue(true),
getScript: vi.fn().mockResolvedValue(null),
getAllScripts: vi.fn().mockResolvedValue([]),
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
};
const mockTagEngine: Record<string, ReturnType<typeof vi.fn>> = {
getAllTags: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getTag: vi.fn().mockResolvedValue(null),
getTagByName: vi.fn().mockResolvedValue(null),
createTag: vi.fn().mockResolvedValue({ id: 't1' }),
updateTag: vi.fn().mockResolvedValue(null),
deleteTag: vi.fn().mockResolvedValue({ deleted: true }),
mergeTags: vi.fn().mockResolvedValue({ merged: 0 }),
renameTag: vi.fn().mockResolvedValue({ renamed: true }),
getPostsWithTag: vi.fn().mockResolvedValue([]),
syncTagsFromPosts: vi.fn().mockResolvedValue({ created: 0, deleted: 0 }),
};
const mockMediaEngine: Record<string, ReturnType<typeof vi.fn>> = {
importMedia: vi.fn().mockResolvedValue({ id: 'm1' }),
updateMedia: vi.fn().mockResolvedValue(null),
replaceMediaFile: vi.fn().mockResolvedValue(null),
deleteMedia: vi.fn().mockResolvedValue(true),
getMedia: vi.fn().mockResolvedValue(null),
getRelativePath: vi.fn().mockResolvedValue(null),
getAllMedia: vi.fn().mockResolvedValue([]),
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
reindexText: vi.fn().mockResolvedValue(undefined),
getThumbnailDataUrl: vi.fn().mockResolvedValue(null),
generateThumbnails: vi.fn().mockResolvedValue(null),
regenerateMissingThumbnails: vi.fn().mockResolvedValue({ processed: 0, generated: 0, failed: 0 }),
getMediaFiltered: vi.fn().mockResolvedValue([]),
searchMedia: vi.fn().mockResolvedValue([]),
getMediaByYearMonth: vi.fn().mockResolvedValue([]),
getAvailableTags: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
};
const mockMetaEngine: Record<string, ReturnType<typeof vi.fn>> = {
getTags: vi.fn().mockResolvedValue([]),
getCategories: vi.fn().mockResolvedValue([]),
addTag: vi.fn().mockResolvedValue([]),
removeTag: vi.fn().mockResolvedValue([]),
addCategory: vi.fn().mockResolvedValue([]),
removeCategory: vi.fn().mockResolvedValue([]),
syncOnStartup: vi.fn().mockResolvedValue({ tags: [], categories: [], projectMetadata: null }),
getProjectMetadata: vi.fn().mockResolvedValue(null),
setProjectMetadata: vi.fn().mockResolvedValue(null),
updateProjectMetadata: vi.fn().mockResolvedValue(null),
};
const mockProjectEngine: Record<string, ReturnType<typeof vi.fn>> = {
createProject: vi.fn().mockResolvedValue({ id: 'prj1' }),
updateProject: vi.fn().mockResolvedValue(null),
deleteProject: vi.fn().mockResolvedValue(true),
deleteProjectWithData: vi.fn().mockResolvedValue(true),
getProject: vi.fn().mockResolvedValue(null),
getAllProjects: vi.fn().mockResolvedValue([]),
getActiveProject: vi.fn().mockResolvedValue(null),
setActiveProject: vi.fn().mockResolvedValue(null),
};
const mockTaskManager: Record<string, ReturnType<typeof vi.fn>> = {
getAllTasks: vi.fn().mockResolvedValue([]),
getRunningTasks: vi.fn().mockResolvedValue([]),
cancelTask: vi.fn().mockResolvedValue(true),
clearCompletedTasks: vi.fn().mockResolvedValue(undefined),
};
const mockGitApiAdapter: Record<string, ReturnType<typeof vi.fn>> = {
checkAvailability: vi.fn().mockResolvedValue({ gitFound: true }),
getRepoState: vi.fn().mockResolvedValue({ isRepo: true }),
getStatus: vi.fn().mockResolvedValue({ files: [], counts: {} }),
getHistory: vi.fn().mockResolvedValue([]),
getRemoteState: vi.fn().mockResolvedValue({ hasUpstream: false }),
fetch: vi.fn().mockResolvedValue({ success: true }),
pull: vi.fn().mockResolvedValue({ success: true }),
push: vi.fn().mockResolvedValue({ success: true }),
commitAll: vi.fn().mockResolvedValue({ success: true }),
};
const mockPublishApiAdapter: Record<string, ReturnType<typeof vi.fn>> = {
uploadSite: vi.fn().mockResolvedValue({ htmlFilesUploaded: 0, thumbnailFilesUploaded: 0, mediaFilesUploaded: 0, filesSkipped: 0 }),
};
const mockAppApiAdapter: Record<string, ReturnType<typeof vi.fn>> = {
getDataPaths: vi.fn().mockResolvedValue({ database: '/db', posts: '/posts', media: '/media' }),
getSystemLanguage: vi.fn().mockResolvedValue('en-US'),
getDefaultProjectPath: vi.fn().mockResolvedValue('/path'),
readProjectMetadata: vi.fn().mockResolvedValue(null),
};
// ── Override ENGINE_MAP for testing ────────────────────────────────
const originalEngineMap: Record<string, typeof ENGINE_MAP[string]> = {};
describe('invokeMainProcessPythonApi', () => {
beforeEach(() => {
vi.clearAllMocks();
// Save originals and inject test engines
for (const key of Object.keys(ENGINE_MAP)) {
originalEngineMap[key] = ENGINE_MAP[key];
}
ENGINE_MAP.posts = () => mockPostEngine as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.media = () => mockMediaEngine as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.projects = () => mockProjectEngine as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.meta = () => mockMetaEngine as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.tags = () => mockTagEngine as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.scripts = () => mockScriptEngine as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.tasks = () => mockTaskManager as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.sync = () => mockGitApiAdapter as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.publish = () => mockPublishApiAdapter as Record<string, (...args: unknown[]) => unknown>;
ENGINE_MAP.app = () => mockAppApiAdapter as Record<string, (...args: unknown[]) => unknown>;
});
afterEach(() => {
// Restore originals
for (const [key, value] of Object.entries(originalEngineMap)) {
ENGINE_MAP[key] = value;
}
});
// ── Method routing ───────────────────────────────────────────────
describe('method routing', () => {
it('routes posts.get to PostEngine.getPost', async () => {
await invokeMainProcessPythonApi('posts.get', { postId: 'p1' });
expect(mockPostEngine.getPost).toHaveBeenCalledWith('p1');
});
it('routes posts.create to PostEngine.createPost', async () => {
const data = { title: 'New', content: 'body' };
await invokeMainProcessPythonApi('posts.create', { data });
expect(mockPostEngine.createPost).toHaveBeenCalledWith(data);
});
it('routes posts.search to PostEngine.searchPosts', async () => {
await invokeMainProcessPythonApi('posts.search', { query: 'hello' });
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('hello');
});
it('routes scripts.create to ScriptEngine.createScript', async () => {
const data = { title: 'My Script', kind: 'macro', content: 'print(1)' };
await invokeMainProcessPythonApi('scripts.create', { data });
expect(mockScriptEngine.createScript).toHaveBeenCalledWith(data);
});
it('routes scripts.delete to ScriptEngine.deleteScript', async () => {
await invokeMainProcessPythonApi('scripts.delete', { id: 's1' });
expect(mockScriptEngine.deleteScript).toHaveBeenCalledWith('s1');
});
it('routes tags.getAll to TagEngine.getAllTags', async () => {
await invokeMainProcessPythonApi('tags.getAll', {});
expect(mockTagEngine.getAllTags).toHaveBeenCalledWith();
});
it('routes tasks.cancel to TaskManager.cancelTask', async () => {
await invokeMainProcessPythonApi('tasks.cancel', { taskId: 't1' });
expect(mockTaskManager.cancelTask).toHaveBeenCalledWith('t1');
});
it('routes meta.getProjectMetadata to MetaEngine.getProjectMetadata', async () => {
await invokeMainProcessPythonApi('meta.getProjectMetadata', {});
expect(mockMetaEngine.getProjectMetadata).toHaveBeenCalledWith();
});
it('routes media.get to MediaEngine.getMedia', async () => {
await invokeMainProcessPythonApi('media.get', { id: 'm1' });
expect(mockMediaEngine.getMedia).toHaveBeenCalledWith('m1');
});
it('routes projects.getActive to ProjectEngine.getActiveProject', async () => {
await invokeMainProcessPythonApi('projects.getActive', {});
expect(mockProjectEngine.getActiveProject).toHaveBeenCalledWith();
});
it('passes optional params as undefined when omitted', async () => {
await invokeMainProcessPythonApi('posts.getAll', {});
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith(undefined);
});
it('passes optional params when provided', async () => {
const opts = { limit: 10, offset: 5 };
await invokeMainProcessPythonApi('posts.getAll', { options: opts });
expect(mockPostEngine.getAllPosts).toHaveBeenCalledWith(opts);
});
it('returns engine method result', async () => {
mockPostEngine.getPost.mockResolvedValueOnce({ id: 'p1', title: 'Found' });
const result = await invokeMainProcessPythonApi('posts.get', { postId: 'p1' });
expect(result).toEqual({ id: 'p1', title: 'Found' });
});
// ── Sync (git) routing ────────────────────────────────────────
it('routes sync.checkAvailability to GitApiAdapter.checkAvailability', async () => {
await invokeMainProcessPythonApi('sync.checkAvailability', {});
expect(mockGitApiAdapter.checkAvailability).toHaveBeenCalledWith();
});
it('routes sync.getRepoState to GitApiAdapter.getRepoState', async () => {
await invokeMainProcessPythonApi('sync.getRepoState', {});
expect(mockGitApiAdapter.getRepoState).toHaveBeenCalledWith();
});
it('routes sync.commitAll to GitApiAdapter.commitAll with message', async () => {
await invokeMainProcessPythonApi('sync.commitAll', { message: 'update files' });
expect(mockGitApiAdapter.commitAll).toHaveBeenCalledWith('update files');
});
it('routes sync.getHistory with optional limit', async () => {
await invokeMainProcessPythonApi('sync.getHistory', { limit: 5 });
expect(mockGitApiAdapter.getHistory).toHaveBeenCalledWith(5);
});
// ── Publish routing ────────────────────────────────────────
it('routes publish.uploadSite to PublishApiAdapter.uploadSite', async () => {
const creds = { sshHost: 'example.com', sshUser: 'deploy', sshRemotePath: '/var/www', sshMode: 'rsync' };
await invokeMainProcessPythonApi('publish.uploadSite', { credentials: creds });
expect(mockPublishApiAdapter.uploadSite).toHaveBeenCalledWith(creds);
});
// ── App routing ────────────────────────────────────────
it('routes app.getDataPaths to AppApiAdapter.getDataPaths', async () => {
await invokeMainProcessPythonApi('app.getDataPaths', {});
expect(mockAppApiAdapter.getDataPaths).toHaveBeenCalledWith();
});
it('routes app.getDefaultProjectPath to AppApiAdapter', async () => {
await invokeMainProcessPythonApi('app.getDefaultProjectPath', { projectId: 'p1' });
expect(mockAppApiAdapter.getDefaultProjectPath).toHaveBeenCalledWith('p1');
});
it('routes app.readProjectMetadata to AppApiAdapter', async () => {
await invokeMainProcessPythonApi('app.readProjectMetadata', { folderPath: '/some/path' });
expect(mockAppApiAdapter.readProjectMetadata).toHaveBeenCalledWith('/some/path');
});
it('routes app.getSystemLanguage to AppApiAdapter', async () => {
await invokeMainProcessPythonApi('app.getSystemLanguage', {});
expect(mockAppApiAdapter.getSystemLanguage).toHaveBeenCalledWith();
});
});
// ── Unknown/unsupported methods ──────────────────────────────────
describe('unknown methods', () => {
it('rejects completely unknown methods', async () => {
await expect(invokeMainProcessPythonApi('foo.bar', {})).rejects.toThrow(
'Unsupported Python API method: foo.bar',
);
});
it('rejects unknown member on known namespace', async () => {
await expect(invokeMainProcessPythonApi('posts.unknown', {})).rejects.toThrow(
'Unsupported Python API method: posts.unknown',
);
});
it('rejects when engine method does not exist', async () => {
ENGINE_MAP.posts = () => ({ noSuchMethod: vi.fn() }) as unknown as Record<string, (...args: unknown[]) => unknown>;
await expect(invokeMainProcessPythonApi('posts.get', { postId: 'p1' })).rejects.toThrow(
"engine method 'getPost' not found",
);
});
});
// ── Blocked/unsafe methods ───────────────────────────────────────
describe('blocked unsafe methods', () => {
// Note: media.importDialog and media.replaceFileDialog are NOT in the API contract,
// so they are rejected at contract lookup before reaching the blocked-methods check.
// They are listed in unsafeMethods as defense in depth.
const unsafeMethods = [
'media.getFilePath',
'app.openFolder',
'app.selectFolder',
'app.showItemInFolder',
'app.getTitleBarMetrics',
'app.notifyRendererReady',
'app.triggerMenuAction',
'app.getBlogmarkBookmarklet',
'app.copyToClipboard',
'app.setPreviewPostTarget',
];
for (const method of unsafeMethods) {
it(`rejects blocked method: ${method}`, async () => {
await expect(invokeMainProcessPythonApi(method, {})).rejects.toThrow(
`Python API method '${method}' is not available in main-process macro context`,
);
});
}
});
// ── Parameter validation ─────────────────────────────────────────
describe('parameter validation', () => {
it('rejects missing required string param', async () => {
await expect(invokeMainProcessPythonApi('posts.get', {})).rejects.toThrow(
'posts.get requires string arg postId',
);
});
it('rejects non-string for required string param', async () => {
await expect(invokeMainProcessPythonApi('posts.get', { postId: 42 })).rejects.toThrow(
'posts.get requires string arg postId',
);
});
it('rejects empty string for required string param', async () => {
await expect(invokeMainProcessPythonApi('posts.get', { postId: '' })).rejects.toThrow(
'posts.get requires string arg postId',
);
});
it('rejects non-object for required object param', async () => {
await expect(invokeMainProcessPythonApi('posts.create', { data: 'not-obj' })).rejects.toThrow(
'posts.create requires object arg data',
);
});
it('rejects array for required object param', async () => {
await expect(invokeMainProcessPythonApi('posts.create', { data: [1, 2] })).rejects.toThrow(
'posts.create requires object arg data',
);
});
it('rejects non-array for required array param', async () => {
await expect(invokeMainProcessPythonApi('tags.merge', { sourceTagIds: 'not-arr', targetTagId: 't1' })).rejects.toThrow(
'tags.merge requires array arg sourceTagIds',
);
});
it('accepts valid array param', async () => {
await invokeMainProcessPythonApi('tags.merge', { sourceTagIds: ['a', 'b'], targetTagId: 't1' });
expect(mockTagEngine.mergeTags).toHaveBeenCalledWith(['a', 'b'], 't1');
});
it('allows optional params to be omitted', async () => {
await invokeMainProcessPythonApi('posts.isSlugAvailable', { slug: 'test' });
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('test', undefined);
});
it('handles null args gracefully (normalizes to empty record)', async () => {
await expect(
invokeMainProcessPythonApi('posts.get', null as unknown as Record<string, unknown>),
).rejects.toThrow('posts.get requires string arg postId');
});
});
});

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
registerMacro,
clearMacros,
renderMacro,
renderAllMacros,
setPythonMacroResolver,
} from '../../../src/renderer/macros/registry';
import type {
MacroRenderContext,
ParsedMacro,
PythonMacroInfo,
PythonMacroResolver,
PythonMacroRendererFn,
} from '../../../src/renderer/macros/types';
describe('Python macro coexistence in renderer registry', () => {
const context: MacroRenderContext = { isPreview: true };
beforeEach(() => {
clearMacros();
setPythonMacroResolver(null, null);
});
describe('renderMacro with Python fallback', () => {
it('should prefer JS macro over Python macro with same name', async () => {
registerMacro({
name: 'widget',
description: 'JS widget',
render: () => '<div>JS Widget</div>',
});
const resolver: PythonMacroResolver = vi.fn().mockResolvedValue({
scriptId: 'py-1',
slug: 'widget',
code: 'def render(ctx): return {"html": "<div>Python Widget</div>"}',
entrypoint: 'render',
version: 1,
});
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<div>Python Widget</div>');
setPythonMacroResolver(resolver, renderer);
const macro: ParsedMacro = {
name: 'widget',
params: {},
rawText: '[[widget]]',
start: 0,
end: 10,
};
const result = await renderMacro(macro, context);
expect(result).toBe('<div>JS Widget</div>');
expect(resolver).not.toHaveBeenCalled();
expect(renderer).not.toHaveBeenCalled();
});
it('should fall back to Python macro when not in JS registry', async () => {
const pythonInfo: PythonMacroInfo = {
scriptId: 'py-2',
slug: 'data_table',
code: 'def render(ctx): return {"html": "<table>...</table>"}',
entrypoint: 'render',
version: 1,
};
const resolver: PythonMacroResolver = vi.fn().mockResolvedValue(pythonInfo);
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<table class="python-table"><tr><td>Data</td></tr></table>');
setPythonMacroResolver(resolver, renderer);
const macro: ParsedMacro = {
name: 'data_table',
params: { source: 'posts' },
rawText: '[[data_table source="posts"]]',
start: 0,
end: 29,
};
const result = await renderMacro(macro, context);
expect(result).toBe('<table class="python-table"><tr><td>Data</td></tr></table>');
expect(resolver).toHaveBeenCalledWith('data_table');
expect(renderer).toHaveBeenCalledWith(pythonInfo, { source: 'posts' }, context);
});
it('should show error span when Python resolver returns null', async () => {
const resolver: PythonMacroResolver = vi.fn().mockResolvedValue(null);
const renderer: PythonMacroRendererFn = vi.fn();
setPythonMacroResolver(resolver, renderer);
const macro: ParsedMacro = {
name: 'unknown_thing',
params: {},
rawText: '[[unknown_thing]]',
start: 0,
end: 17,
};
const result = await renderMacro(macro, context);
expect(result).toContain('macro-error');
expect(result).toContain('Unknown macro');
expect(renderer).not.toHaveBeenCalled();
});
it('should show error span when Python resolver throws', async () => {
const resolver: PythonMacroResolver = vi.fn().mockRejectedValue(new Error('Resolution failed'));
const renderer: PythonMacroRendererFn = vi.fn();
setPythonMacroResolver(resolver, renderer);
const macro: ParsedMacro = {
name: 'broken_resolve',
params: {},
rawText: '[[broken_resolve]]',
start: 0,
end: 18,
};
const result = await renderMacro(macro, context);
expect(result).toContain('macro-error');
expect(result).toContain('Resolution failed');
});
it('should show unknown macro error when no Python resolver is set', async () => {
const macro: ParsedMacro = {
name: 'no_resolver',
params: {},
rawText: '[[no_resolver]]',
start: 0,
end: 15,
};
const result = await renderMacro(macro, context);
expect(result).toContain('macro-error');
expect(result).toContain('Unknown macro');
});
});
describe('renderAllMacros with mixed JS and Python', () => {
it('should render mixed JS and Python macros in one document', async () => {
registerMacro({
name: 'hello',
description: 'Greeting',
render: (params) => `<span>Hello ${params.name || 'World'}</span>`,
});
const resolver: PythonMacroResolver = vi.fn().mockImplementation(async (name: string) => {
if (name === 'chart') {
return {
scriptId: 'py-chart',
slug: 'chart',
code: 'def render(ctx): return {"html": "<canvas>chart</canvas>"}',
entrypoint: 'render',
version: 1,
};
}
return null;
});
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<canvas>Rendered Chart</canvas>');
setPythonMacroResolver(resolver, renderer);
const input = 'Intro [[hello name="Alice"]] middle [[chart type="bar"]] end';
const result = await renderAllMacros(input, context);
expect(result).toContain('<span>Hello Alice</span>');
expect(result).toContain('<canvas>Rendered Chart</canvas>');
expect(result).toContain('Intro');
expect(result).toContain('middle');
expect(result).toContain('end');
});
it('should handle multiple Python macros in sequence', async () => {
const resolver: PythonMacroResolver = vi.fn().mockImplementation(async (name: string) => {
if (name === 'box_a') {
return { scriptId: 'a', slug: 'box_a', code: '', entrypoint: 'render', version: 1 };
}
if (name === 'box_b') {
return { scriptId: 'b', slug: 'box_b', code: '', entrypoint: 'render', version: 1 };
}
return null;
});
let callCount = 0;
const renderer: PythonMacroRendererFn = vi.fn().mockImplementation(async (info: PythonMacroInfo) => {
callCount++;
return `<div class="${info.slug}">Output ${callCount}</div>`;
});
setPythonMacroResolver(resolver, renderer);
const input = '[[box_a]] [[box_b]]';
const result = await renderAllMacros(input, context);
expect(result).toContain('<div class="box_a">Output 1</div>');
expect(result).toContain('<div class="box_b">Output 2</div>');
});
});
describe('precedence and dispatch documentation', () => {
it('JS built-in macros always take priority over Python scripts', async () => {
registerMacro({
name: 'gallery',
description: 'JS Gallery',
render: () => '<div class="js-gallery">Built-in</div>',
});
const resolver: PythonMacroResolver = vi.fn().mockResolvedValue({
scriptId: 'py-gal',
slug: 'gallery',
code: 'def render(ctx): return {"html": "<div class=py-gallery>Custom</div>"}',
entrypoint: 'render',
version: 1,
});
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('<div class="py-gallery">Custom</div>');
setPythonMacroResolver(resolver, renderer);
const macro: ParsedMacro = {
name: 'gallery',
params: {},
rawText: '[[gallery]]',
start: 0,
end: 11,
};
const result = await renderMacro(macro, context);
expect(result).toBe('<div class="js-gallery">Built-in</div>');
expect(resolver).not.toHaveBeenCalled();
});
it('Python macros are only used for names not in JS registry', async () => {
registerMacro({
name: 'existing',
description: 'Existing JS',
render: () => 'JS',
});
const resolver: PythonMacroResolver = vi.fn().mockImplementation(async (name: string) => {
if (name === 'python_only') {
return { scriptId: 'p1', slug: 'python_only', code: '', entrypoint: 'render', version: 1 };
}
return null;
});
const renderer: PythonMacroRendererFn = vi.fn().mockResolvedValue('Python Output');
setPythonMacroResolver(resolver, renderer);
const input = '[[existing]] [[python_only]]';
const result = await renderAllMacros(input, context);
expect(result).toContain('JS');
expect(result).toContain('Python Output');
expect(resolver).toHaveBeenCalledTimes(1);
expect(resolver).toHaveBeenCalledWith('python_only');
});
});
});

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
wirePythonMacroPreview,
invalidatePythonMacroScriptCache,
} from '../../../src/renderer/macros/pythonMacroPreview';
import {
clearMacros,
setPythonMacroResolver,
renderMacro,
} from '../../../src/renderer/macros/registry';
import type { ParsedMacro, MacroRenderContext } from '../../../src/renderer/macros/types';
// Mock PythonRuntimeManager
const mockRenderMacroV1 = vi.fn().mockResolvedValue({
result: { html: '<div>Python Preview Output</div>' },
stdout: '',
});
vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
getPythonRuntimeManager: () => ({
renderMacroV1: mockRenderMacroV1,
}),
}));
describe('pythonMacroPreview', () => {
const context: MacroRenderContext = { isPreview: true };
beforeEach(() => {
clearMacros();
setPythonMacroResolver(null, null);
invalidatePythonMacroScriptCache();
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('should resolve a Python macro from scripts.getAll and render via PythonRuntimeManager', async () => {
vi.stubGlobal('window', {
electronAPI: {
scripts: {
getAll: vi.fn().mockResolvedValue([
{
id: 'script-1',
slug: 'data_table',
kind: 'macro',
enabled: true,
content: 'def render(ctx, post): return {"html": "<table/>"}',
entrypoint: 'render',
version: 3,
},
{
id: 'script-2',
slug: 'utility_script',
kind: 'utility',
enabled: true,
content: '',
entrypoint: 'run',
version: 1,
},
]),
},
},
});
wirePythonMacroPreview();
const macro: ParsedMacro = {
name: 'data_table',
params: { source: 'posts' },
rawText: '[[data_table source="posts"]]',
start: 0,
end: 29,
};
const result = await renderMacro(macro, context);
expect(result).toBe('<div>Python Preview Output</div>');
expect(mockRenderMacroV1).toHaveBeenCalledWith(
'def render(ctx, post): return {"html": "<table/>"}',
expect.objectContaining({
env: expect.objectContaining({
isPreview: true,
source: { kind: 'script', id: 'script-1' },
}),
params: { source: 'posts' },
}),
expect.objectContaining({
entrypoint: 'render',
cacheKey: 'script-1:v3',
}),
);
});
it('should return unknown macro error when no script matches', async () => {
vi.stubGlobal('window', {
electronAPI: {
scripts: {
getAll: vi.fn().mockResolvedValue([]),
},
},
});
wirePythonMacroPreview();
const macro: ParsedMacro = {
name: 'nonexistent',
params: {},
rawText: '[[nonexistent]]',
start: 0,
end: 15,
};
const result = await renderMacro(macro, context);
expect(result).toContain('macro-error');
expect(result).toContain('Unknown macro');
});
it('should skip disabled scripts', async () => {
vi.stubGlobal('window', {
electronAPI: {
scripts: {
getAll: vi.fn().mockResolvedValue([
{
id: 'script-1',
slug: 'widget',
kind: 'macro',
enabled: false,
content: 'def render(ctx, post): return {"html": ""}',
entrypoint: 'render',
version: 1,
},
]),
},
},
});
wirePythonMacroPreview();
const macro: ParsedMacro = {
name: 'widget',
params: {},
rawText: '[[widget]]',
start: 0,
end: 10,
};
const result = await renderMacro(macro, context);
expect(result).toContain('macro-error');
expect(result).toContain('Unknown macro');
});
it('should cache scripts and not refetch on second resolve', async () => {
const mockGetAll = vi.fn().mockResolvedValue([
{
id: 's1',
slug: 'chart',
kind: 'macro',
enabled: true,
content: 'code',
entrypoint: 'render',
version: 1,
},
]);
vi.stubGlobal('window', {
electronAPI: { scripts: { getAll: mockGetAll } },
});
wirePythonMacroPreview();
const macro: ParsedMacro = {
name: 'chart',
params: {},
rawText: '[[chart]]',
start: 0,
end: 9,
};
await renderMacro(macro, context);
await renderMacro(macro, context);
expect(mockGetAll).toHaveBeenCalledTimes(1);
});
it('should refetch after invalidatePythonMacroScriptCache', async () => {
const mockGetAll = vi.fn().mockResolvedValue([
{
id: 's1',
slug: 'chart',
kind: 'macro',
enabled: true,
content: 'code',
entrypoint: 'render',
version: 1,
},
]);
vi.stubGlobal('window', {
electronAPI: { scripts: { getAll: mockGetAll } },
});
wirePythonMacroPreview();
const macro: ParsedMacro = {
name: 'chart',
params: {},
rawText: '[[chart]]',
start: 0,
end: 9,
};
await renderMacro(macro, context);
invalidatePythonMacroScriptCache();
await renderMacro(macro, context);
expect(mockGetAll).toHaveBeenCalledTimes(2);
});
it('should match slugs case-insensitively', async () => {
vi.stubGlobal('window', {
electronAPI: {
scripts: {
getAll: vi.fn().mockResolvedValue([
{
id: 's1',
slug: 'MyWidget',
kind: 'macro',
enabled: true,
content: 'code',
entrypoint: 'render',
version: 1,
},
]),
},
},
});
wirePythonMacroPreview();
const macro: ParsedMacro = {
name: 'mywidget',
params: {},
rawText: '[[mywidget]]',
start: 0,
end: 12,
};
const result = await renderMacro(macro, context);
expect(result).toBe('<div>Python Preview Output</div>');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseMacroContextV1 } from '../../../src/renderer/python/abiV1'; import { parseMacroContextV1, parseMacroResultV1 } from '../../../src/renderer/python/abiV1';
describe('macroContextV1Schema', () => { describe('macroContextV1Schema', () => {
it('accepts optional env hook and source metadata', () => { it('accepts optional env hook and source metadata', () => {
@@ -38,4 +38,135 @@ describe('macroContextV1Schema', () => {
}) })
).toThrow('Invalid macro context'); ).toThrow('Invalid macro context');
}); });
it('rejects when env.isPreview is missing', () => {
expect(() =>
parseMacroContextV1({
env: {},
})
).toThrow('Invalid macro context');
});
it('rejects when env.isPreview is not a boolean', () => {
expect(() =>
parseMacroContextV1({
env: { isPreview: 'yes' },
})
).toThrow('Invalid macro context');
});
it('rejects when env is missing entirely', () => {
expect(() =>
parseMacroContextV1({})
).toThrow('Invalid macro context');
});
it('accepts minimal valid context with only env.isPreview', () => {
const parsed = parseMacroContextV1({
env: { isPreview: false },
});
expect(parsed.env.isPreview).toBe(false);
expect(parsed.params).toBeUndefined();
expect(parsed.data).toBeUndefined();
});
it('accepts params with non-string JSON values', () => {
const parsed = parseMacroContextV1({
env: { isPreview: false },
params: {
count: 42,
enabled: true,
tags: ['a', 'b'],
nested: { deep: { value: null } },
},
});
expect(parsed.params?.count).toBe(42);
expect(parsed.params?.enabled).toBe(true);
expect(parsed.params?.tags).toEqual(['a', 'b']);
expect(parsed.params?.nested).toEqual({ deep: { value: null } });
});
it('rejects unknown top-level fields', () => {
expect(() =>
parseMacroContextV1({
env: { isPreview: true },
extra: 'nope',
})
).toThrow('Invalid macro context');
});
it('rejects unknown source fields', () => {
expect(() =>
parseMacroContextV1({
env: {
isPreview: true,
source: { kind: 'macro', id: '1', extra: 'bad' },
},
})
).toThrow('Invalid macro context');
});
it('accepts source without optional id', () => {
const parsed = parseMacroContextV1({
env: {
isPreview: false,
source: { kind: 'macro' },
},
});
expect(parsed.env.source).toEqual({ kind: 'macro' });
});
});
describe('macroResultV1Schema', () => {
it('accepts minimal result with html only', () => {
const parsed = parseMacroResultV1({ html: '<p>hello</p>' });
expect(parsed.html).toBe('<p>hello</p>');
expect(parsed.data).toBeUndefined();
expect(parsed.warnings).toBeUndefined();
});
it('accepts result with all optional fields', () => {
const parsed = parseMacroResultV1({
html: '<div>test</div>',
data: { count: 5, nested: { key: 'val' } },
warnings: ['slow', 'deprecated'],
});
expect(parsed.html).toBe('<div>test</div>');
expect(parsed.data).toEqual({ count: 5, nested: { key: 'val' } });
expect(parsed.warnings).toEqual(['slow', 'deprecated']);
});
it('accepts empty html string', () => {
const parsed = parseMacroResultV1({ html: '' });
expect(parsed.html).toBe('');
});
it('rejects when html is missing', () => {
expect(() =>
parseMacroResultV1({})
).toThrow('Invalid macro result');
});
it('rejects when html is not a string', () => {
expect(() =>
parseMacroResultV1({ html: 42 })
).toThrow('Invalid macro result');
});
it('rejects unknown top-level fields', () => {
expect(() =>
parseMacroResultV1({ html: '', extra: true })
).toThrow('Invalid macro result');
});
it('rejects non-string warnings', () => {
expect(() =>
parseMacroResultV1({ html: '', warnings: [42] })
).toThrow('Invalid macro result');
});
}); });

View File

@@ -27,14 +27,18 @@ describe('generateApiDocumentationMarkdownV1', () => {
expect(markdown).toContain('[↑ Back to Table of contents](#table-of-contents)'); expect(markdown).toContain('[↑ Back to Table of contents](#table-of-contents)');
}); });
it('documents chat APIs in a dedicated module section', () => { it('documents sync and publish APIs in dedicated module sections', () => {
const markdown = generateApiDocumentationMarkdownV1(); const markdown = generateApiDocumentationMarkdownV1();
expect(markdown).toContain('## chat'); expect(markdown).toContain('## sync');
expect(markdown).toContain('### chat.getConversations'); expect(markdown).toContain('### sync.getRepoState');
expect(markdown).toContain('### chat.sendMessage'); expect(markdown).toContain('### sync.commitAll');
expect(markdown).toContain('- [chat](#chat)'); expect(markdown).toContain('- [sync](#sync)');
expect(markdown).toContain('- [chat.sendMessage](#chatsendmessage)'); expect(markdown).toContain('## publish');
expect(markdown).toContain('### publish.uploadSite');
expect(markdown).toContain('- [publish](#publish)');
// chat namespace should not be present
expect(markdown).not.toContain('## chat');
}); });
it('includes a dedicated Data Structures section with core object shapes', () => { it('includes a dedicated Data Structures section with core object shapes', () => {

View File

@@ -23,8 +23,9 @@ describe('pythonApiContractV1', () => {
'scripts.getAll', 'scripts.getAll',
'tasks.getAll', 'tasks.getAll',
'app.getSystemLanguage', 'app.getSystemLanguage',
'chat.getConversations', 'sync.getRepoState',
'chat.sendMessage', 'sync.commitAll',
'publish.uploadSite',
])); ]));
}); });
@@ -43,34 +44,30 @@ describe('pythonApiContractV1', () => {
}); });
}); });
it('documents chat.sendMessage return contract and metadata input', () => { it('documents sync.commitAll contract with required message param', () => {
expect(getPythonApiMethodContract('chat.sendMessage')).toEqual({ expect(getPythonApiMethodContract('sync.commitAll')).toEqual({
method: 'chat.sendMessage', method: 'sync.commitAll',
description: 'Send message to chat conversation.', description: 'Stage all changes and commit for active project.',
params: [ params: [
{
name: 'conversationId',
type: 'string',
required: true,
},
{ {
name: 'message', name: 'message',
type: 'string', type: 'string',
required: true, required: true,
}, },
{
name: 'metadata',
type: 'object',
required: false,
},
], ],
returns: '{ success: boolean; message?: string; error?: string }', returns: 'GitActionResult',
}); });
}); });
it('does not include chat namespace (removed in v1.7.0)', () => {
const methodNames = listPythonApiMethodNames();
const chatMethods = methodNames.filter((m) => m.startsWith('chat.'));
expect(chatMethods).toHaveLength(0);
});
it('contains semantic version metadata for compatibility checks', () => { it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({ expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.6.0', version: '1.7.0',
generatedAt: expect.any(String), generatedAt: expect.any(String),
}); });
}); });
@@ -93,15 +90,17 @@ describe('generatePythonApiModuleV1', () => {
expect(moduleCode).toContain('class PostsApi:'); expect(moduleCode).toContain('class PostsApi:');
expect(moduleCode).toContain('class MediaApi:'); expect(moduleCode).toContain('class MediaApi:');
expect(moduleCode).toContain('class MetaApi:'); expect(moduleCode).toContain('class MetaApi:');
expect(moduleCode).toContain('class ChatApi:'); expect(moduleCode).toContain('class SyncApi:');
expect(moduleCode).toContain('class PublishApi:');
expect(moduleCode).toContain('async def get(self, post_id):'); expect(moduleCode).toContain('async def get(self, post_id):');
expect(moduleCode).toContain('async def get_all(self, options=None):'); expect(moduleCode).toContain('async def get_all(self, options=None):');
expect(moduleCode).toContain('async def search(self, query):'); expect(moduleCode).toContain('async def search(self, query):');
expect(moduleCode).toContain('async def get_project_metadata(self):'); expect(moduleCode).toContain('async def get_project_metadata(self):');
expect(moduleCode).toContain('async def get_conversations(self):'); expect(moduleCode).toContain('async def commit_all(self, message):');
expect(moduleCode).toContain('async def send_message(self, conversation_id, message, metadata=None):'); expect(moduleCode).toContain('async def upload_site(self, credentials):');
expect(moduleCode).toContain('class BdsApi:'); expect(moduleCode).toContain('class BdsApi:');
expect(moduleCode).toContain('bds = BdsApi(_transport)'); expect(moduleCode).toContain('bds = BdsApi(_transport)');
expect(moduleCode).not.toContain('class ChatApi:');
}); });
it('escapes python keyword method names to valid identifiers', () => { it('escapes python keyword method names to valid identifiers', () => {

View File

@@ -87,13 +87,15 @@ Object.defineProperty(globalThis, 'window', {
getFilePath: vi.fn(), getFilePath: vi.fn(),
}, },
sync: { sync: {
configure: vi.fn(), checkAvailability: vi.fn(),
start: vi.fn(), getRepoState: vi.fn(),
getStatus: vi.fn(), getStatus: vi.fn(),
isConfigured: vi.fn(), getHistory: vi.fn(),
getPendingCount: vi.fn(), getRemoteState: vi.fn(),
getLog: vi.fn(), fetch: vi.fn(),
stopAutoSync: vi.fn(), pull: vi.fn(),
push: vi.fn(),
commitAll: vi.fn(),
}, },
dropbox: { dropbox: {
configure: vi.fn(), configure: vi.fn(),

View File

@@ -11,6 +11,12 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
chunkSizeWarningLimit: 8000, chunkSizeWarningLimit: 8000,
rollupOptions: { rollupOptions: {
onLog(level, log, defaultHandler) {
if (log.message.includes('has been externalized for browser compatibility') && log.message.includes('pyodide')) {
return;
}
defaultHandler(level, log);
},
input: resolve(__dirname, 'src/renderer/index.html'), input: resolve(__dirname, 'src/renderer/index.html'),
output: { output: {
manualChunks(id) { manualChunks(id) {