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 (
+