From 6c2d2c48bf410916d3442672881e6ebf4a55a0e3 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 27 Feb 2026 20:31:25 +0100 Subject: [PATCH] fix: hooked up python API for templates --- API.md | 312 +++++++++++++++++- src/main/shared/pythonApiContractV1.ts | 28 +- tests/engine/TemplateEngine.test.ts | 46 +++ tests/ipc/handlers.test.ts | 145 ++++++++ .../components/TemplatesView.styles.test.ts | 23 ++ .../components/TemplatesView.test.tsx | 211 ++++++++++++ .../python/pythonApiContractV1.test.ts | 2 +- 7 files changed, 764 insertions(+), 3 deletions(-) create mode 100644 tests/renderer/components/TemplatesView.styles.test.ts create mode 100644 tests/renderer/components/TemplatesView.test.tsx diff --git a/API.md b/API.md index db388ab..7da5d1e 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ # API Documentation -Contract version: 1.7.0 +Contract version: 1.8.0 This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide. @@ -21,6 +21,7 @@ project = await bds.meta.get_project_metadata() - [posts](#posts) - [media](#media) - [scripts](#scripts) +- [templates](#templates) - [tasks](#tasks) - [app](#app) - [meta](#meta) @@ -1949,6 +1950,295 @@ None [↑ Back to Table of contents](#table-of-contents) +## templates + +**Module APIs** + +- [templates.create](#templatescreate) +- [templates.update](#templatesupdate) +- [templates.delete](#templatesdelete) +- [templates.get](#templatesget) +- [templates.getAll](#templatesgetall) +- [templates.getEnabledByKind](#templatesgetenabledbykind) +- [templates.validate](#templatesvalidate) +- [templates.rebuildFromFiles](#templatesrebuildfromfiles) + +### templates.create + +Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool). + +**Parameters** + +- data (dict, required) + +**Response specification** + +- Return type: `TemplateData` +- Data structures: `TemplateData` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.create(data={}) +``` + +**Example response** + +```python +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': 'post', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### templates.update + +Update template by id. data may include any of: title, kind, content, slug, enabled. + +**Parameters** + +- id (str, required) +- data (dict, required) + +**Response specification** + +- Return type: `TemplateData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `TemplateData` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.update(id='id-1', data={}) +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': 'post', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### templates.delete + +Delete template by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.delete(id='id-1') +``` + +**Example response** + +```python +True +``` + +### templates.get + +Fetch template by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `TemplateData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `TemplateData` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.get(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': 'post', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### templates.getAll + +Fetch all templates. + +**Parameters** + +- None + +**Response specification** + +- Return type: `TemplateData[]` +- Data structures: `TemplateData` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.get_all() +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': 'post', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +] +``` + +### templates.getEnabledByKind + +Fetch enabled templates filtered by kind. + +**Parameters** + +- kind (str, required) + +**Response specification** + +- Return type: `TemplateData[]` +- Data structures: `TemplateData` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.get_enabled_by_kind(kind='kind') +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': 'post', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +] +``` + +### templates.validate + +Validate Liquid template syntax. + +**Parameters** + +- content (str, required) + +**Response specification** + +- Return type: `{ valid: boolean; errors: string[] }` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.validate(content='content') +``` + +**Example response** + +```python +{} +``` + +### templates.rebuildFromFiles + +Rebuild templates from files. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.templates.rebuild_from_files() +``` + +**Example response** + +```python +None +``` + +[↑ Back to Table of contents](#table-of-contents) + ## tasks **Module APIs** @@ -3579,6 +3869,26 @@ Script definition for Python macros, utilities, and transforms. [↑ Back to Table of contents](#table-of-contents) +### TemplateData + +Liquid template definition for posts, lists, not-found pages, and partials. + +**Fields** + +- id (`string`, required): Unique template identifier. +- projectId (`string`, required): Owning project id. +- slug (`string`, required): Stable template slug. +- title (`string`, required): Human-readable template title. +- kind (`'post' | 'list' | 'not-found' | 'partial'`, required): Template category. +- enabled (`boolean`, required): Whether template is enabled. +- version (`number`, required): Incrementing template version. +- filePath (`string`, required): Filesystem path to template file. +- content (`string`, required): Liquid template source code. +- createdAt (`string`, required): Creation timestamp (ISO string). +- updatedAt (`string`, required): Last update timestamp (ISO string). + +[↑ Back to Table of contents](#table-of-contents) + ### TaskProgress Task queue status object for long-running operations. diff --git a/src/main/shared/pythonApiContractV1.ts b/src/main/shared/pythonApiContractV1.ts index 4f4e1e2..71227b4 100644 --- a/src/main/shared/pythonApiContractV1.ts +++ b/src/main/shared/pythonApiContractV1.ts @@ -129,6 +129,15 @@ const METHODS_V1: PythonApiMethodContractV1[] = [ method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'), method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'), + method('templates.create', 'Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).', [requiredObject('data')], 'TemplateData'), + method('templates.update', 'Update template by id. data may include any of: title, kind, content, slug, enabled.', [requiredString('id'), requiredObject('data')], 'TemplateData | null'), + method('templates.delete', 'Delete template by id.', [requiredString('id')], 'boolean'), + method('templates.get', 'Fetch template by id.', [requiredString('id')], 'TemplateData | null'), + method('templates.getAll', 'Fetch all templates.', [], 'TemplateData[]'), + method('templates.getEnabledByKind', 'Fetch enabled templates filtered by kind.', [requiredString('kind')], 'TemplateData[]'), + method('templates.validate', 'Validate Liquid template syntax.', [requiredString('content')], '{ valid: boolean; errors: string[] }'), + method('templates.rebuildFromFiles', 'Rebuild templates 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'), @@ -276,6 +285,23 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, ], }, + { + name: 'TemplateData', + description: 'Liquid template definition for posts, lists, not-found pages, and partials.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Unique template identifier.' }, + { name: 'projectId', type: 'string', required: true, description: 'Owning project id.' }, + { name: 'slug', type: 'string', required: true, description: 'Stable template slug.' }, + { name: 'title', type: 'string', required: true, description: 'Human-readable template title.' }, + { name: 'kind', type: "'post' | 'list' | 'not-found' | 'partial'", required: true, description: 'Template category.' }, + { name: 'enabled', type: 'boolean', required: true, description: 'Whether template is enabled.' }, + { name: 'version', type: 'number', required: true, description: 'Incrementing template version.' }, + { name: 'filePath', type: 'string', required: true, description: 'Filesystem path to template file.' }, + { name: 'content', type: 'string', required: true, description: 'Liquid template 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.', @@ -370,7 +396,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ ]; export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = { - version: '1.7.0', + version: '1.8.0', generatedAt: '2026-02-27T00:00:00.000Z', methods: METHODS_V1, dataStructures: DATA_STRUCTURES_V1, diff --git a/tests/engine/TemplateEngine.test.ts b/tests/engine/TemplateEngine.test.ts index 1958ac0..6923cc9 100644 --- a/tests/engine/TemplateEngine.test.ts +++ b/tests/engine/TemplateEngine.test.ts @@ -343,4 +343,50 @@ describe('TemplateEngine', () => { expect(created.slug).toBe('my_custom_slug'); }); }); + + describe('getTemplateBySlug', () => { + it('retrieves an enabled template by exact slug', async () => { + await templateEngine.createTemplate({ + title: 'Custom Post', + kind: 'post', + content: '
Post
', + enabled: true, + }); + + const found = await templateEngine.getTemplateBySlug('custom_post'); + expect(found).not.toBeNull(); + expect(found?.slug).toBe('custom_post'); + expect(found?.title).toBe('Custom Post'); + }); + + it('matches slugs case-insensitively', async () => { + await templateEngine.createTemplate({ + title: 'Custom Post', + kind: 'post', + content: '
Post
', + enabled: true, + }); + + const found = await templateEngine.getTemplateBySlug('CUSTOM_POST'); + expect(found).not.toBeNull(); + expect(found?.slug).toBe('custom_post'); + }); + + it('returns null for non-existent slug', async () => { + const found = await templateEngine.getTemplateBySlug('does_not_exist'); + expect(found).toBeNull(); + }); + + it('does not return disabled templates', async () => { + await templateEngine.createTemplate({ + title: 'Disabled Template', + kind: 'post', + content: '
Disabled
', + enabled: false, + }); + + const found = await templateEngine.getTemplateBySlug('disabled_template'); + expect(found).toBeNull(); + }); + }); }); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 65d431f..d80da41 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -2786,6 +2786,151 @@ describe('IPC Handlers', () => { }); }); + // ============ Template Handlers ============ + describe('Template Handlers', () => { + describe('templates:create', () => { + it('should call TemplateEngine.createTemplate with payload', async () => { + const payload = { + title: 'Custom Post', + kind: 'post', + content: '{{ post.title }}', + }; + const expected = { + id: 'template-1', + projectId: 'default', + ...payload, + slug: 'custom_post', + enabled: true, + version: 1, + filePath: '/mock/userData/projects/default/templates/custom_post.liquid', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + mockTemplateEngine.createTemplate.mockResolvedValue(expected); + + const result = await invokeHandler('templates:create', payload); + + expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith(payload); + expect(result).toEqual(expected); + }); + }); + + describe('templates:update', () => { + it('should call TemplateEngine.updateTemplate with id and updates', async () => { + const updates = { title: 'Updated Template', content: '{{ post.content }}' }; + const expected = { + id: 'template-1', + projectId: 'default', + slug: 'updated_template', + title: 'Updated Template', + kind: 'post', + enabled: true, + version: 2, + filePath: '/mock/userData/projects/default/templates/updated_template.liquid', + content: '{{ post.content }}', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + mockTemplateEngine.updateTemplate.mockResolvedValue(expected); + + const result = await invokeHandler('templates:update', 'template-1', updates); + + expect(mockTemplateEngine.updateTemplate).toHaveBeenCalledWith('template-1', updates); + expect(result).toEqual(expected); + }); + }); + + describe('templates:delete', () => { + it('should call TemplateEngine.deleteTemplate with id', async () => { + mockTemplateEngine.deleteTemplate.mockResolvedValue(true); + + const result = await invokeHandler('templates:delete', 'template-1'); + + expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1'); + expect(result).toBe(true); + }); + }); + + describe('templates:get', () => { + it('should call TemplateEngine.getTemplate with id', async () => { + const expected = { + id: 'template-1', + projectId: 'default', + slug: 'custom_post', + title: 'Custom Post', + kind: 'post', + enabled: true, + version: 1, + filePath: '/mock/userData/projects/default/templates/custom_post.liquid', + content: '{{ post.title }}', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + mockTemplateEngine.getTemplate.mockResolvedValue(expected); + + const result = await invokeHandler('templates:get', 'template-1'); + + expect(mockTemplateEngine.getTemplate).toHaveBeenCalledWith('template-1'); + expect(result).toEqual(expected); + }); + }); + + describe('templates:getAll', () => { + it('should call TemplateEngine.getAllTemplates', async () => { + const expected = [{ id: 'template-1' }, { id: 'template-2' }]; + mockTemplateEngine.getAllTemplates.mockResolvedValue(expected); + + const result = await invokeHandler('templates:getAll'); + + expect(mockTemplateEngine.getAllTemplates).toHaveBeenCalled(); + expect(result).toEqual(expected); + }); + }); + + describe('templates:getEnabledByKind', () => { + it('should call TemplateEngine.getEnabledTemplatesByKind with kind', async () => { + const expected = [{ id: 'template-1', kind: 'post' }]; + mockTemplateEngine.getEnabledTemplatesByKind.mockResolvedValue(expected); + + const result = await invokeHandler('templates:getEnabledByKind', 'post'); + + expect(mockTemplateEngine.getEnabledTemplatesByKind).toHaveBeenCalledWith('post'); + expect(result).toEqual(expected); + }); + }); + + describe('templates:validate', () => { + it('should call TemplateEngine.validateTemplate with content', async () => { + const expected = { valid: true, errors: [] }; + mockTemplateEngine.validateTemplate.mockResolvedValue(expected); + + const result = await invokeHandler('templates:validate', '{{ post.title }}'); + + expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('{{ post.title }}'); + expect(result).toEqual(expected); + }); + }); + + describe('templates:rebuildFromFiles', () => { + it('should set project context and trigger TemplateEngine rebuild', async () => { + mockProjectEngine.getActiveProject.mockResolvedValue({ + id: 'project-1', + dataPath: '/external/data', + }); + mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data'); + mockTemplateEngine.rebuildDatabaseFromFiles.mockResolvedValue(undefined); + + const result = await invokeHandler('templates:rebuildFromFiles'); + + expect(mockTemplateEngine.setProjectContext).toHaveBeenCalledWith('project-1', '/resolved/project-data'); + expect(mockTemplateEngine.rebuildDatabaseFromFiles).toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + }); + // ============ Error Handling ============ describe('Error Handling', () => { it('should silently handle "Database is closing" errors', async () => { diff --git a/tests/renderer/components/TemplatesView.styles.test.ts b/tests/renderer/components/TemplatesView.styles.test.ts new file mode 100644 index 0000000..73251d2 --- /dev/null +++ b/tests/renderer/components/TemplatesView.styles.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +describe('TemplatesView styles', () => { + const cssPath = path.resolve( + __dirname, + '../../../src/renderer/components/TemplatesView/TemplatesView.css' + ); + + it('uses full editor area layout for the templates container', () => { + const css = fs.readFileSync(cssPath, 'utf8'); + + expect(css).toMatch(/\.templates-view\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s); + }); + + it('keeps editor and monaco stretched to fill available space', () => { + const css = fs.readFileSync(cssPath, 'utf8'); + + expect(css).toMatch(/\.templates-editor\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s); + expect(css).toMatch(/\.templates-monaco\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s); + }); +}); diff --git a/tests/renderer/components/TemplatesView.test.tsx b/tests/renderer/components/TemplatesView.test.tsx new file mode 100644 index 0000000..9fa9493 --- /dev/null +++ b/tests/renderer/components/TemplatesView.test.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TemplatesView } from '../../../src/renderer/components/TemplatesView/TemplatesView'; +import { useAppStore } from '../../../src/renderer/store'; + +const monacoPropsSpy = vi.fn(); + +vi.mock('@monaco-editor/react', () => ({ + default: (props: { + value?: string; + defaultValue?: string; + onChange?: (value?: string) => void; + language?: string; + }) => { + monacoPropsSpy(props); + return ( +