feat: user-managed templates

This commit is contained in:
2026-02-27 20:00:53 +01:00
parent e25a0d85a5
commit f3364999ee
47 changed files with 3664 additions and 40 deletions

View File

@@ -13,7 +13,9 @@
"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)" "Bash(ls -la /Users/gb/Projects/bDS/*.md)",
"Bash(grep -n \"templateSlug\\\\|postTemplateSlug\" /Users/gb/Projects/bDS/src/main/engine/*.ts)",
"Bash(npm test -- tests/renderer/i18nLocaleCompleteness.test.ts)"
] ]
} }
} }

View File

@@ -6,7 +6,7 @@ independently.
--- ---
## 1. Template Editor & Per-Entity Template Selection ## ~~1. Template Editor & Per-Entity Template Selection~~ ✅ Done
### Goal ### Goal

View File

@@ -0,0 +1,16 @@
CREATE TABLE `templates` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`slug` text NOT NULL,
`title` text NOT NULL,
`kind` text DEFAULT 'post' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
`file_path` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `templates_project_slug_idx` ON `templates` (`project_id`,`slug`);--> statement-breakpoint
ALTER TABLE `posts` ADD `template_slug` text;--> statement-breakpoint
ALTER TABLE `tags` ADD `post_template_slug` text;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1771792324840, "when": 1771792324840,
"tag": "0005_short_sally_floyd", "tag": "0005_short_sally_floyd",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1772213213016,
"tag": "0006_yummy_scorpion",
"breakpoints": true
} }
] ]
} }

View File

@@ -33,6 +33,7 @@ export const posts = sqliteTable('posts', {
checksum: text('checksum'), checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text categories: text('categories'), // JSON array stored as text
templateSlug: text('template_slug'), // Optional user template override for this post
// Legacy columns (kept for migration compatibility, no longer written) // Legacy columns (kept for migration compatibility, no longer written)
publishedTitle: text('published_title'), publishedTitle: text('published_title'),
publishedContent: text('published_content'), publishedContent: text('published_content'),
@@ -111,6 +112,7 @@ export const tags = sqliteTable('tags', {
projectId: text('project_id').notNull(), projectId: text('project_id').notNull(),
name: text('name').notNull(), name: text('name').notNull(),
color: text('color'), // Optional hex color like #ff0000 color: text('color'), // Optional hex color like #ff0000
postTemplateSlug: text('post_template_slug'), // Optional user template override for posts with this tag
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({ }, (table) => ({
@@ -169,6 +171,23 @@ export const scripts = sqliteTable('scripts', {
projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug), projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug),
})); }));
// Templates table - stores metadata for Liquid templates persisted in templates/*.liquid
export const templates = sqliteTable('templates', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
slug: text('slug').notNull(),
title: text('title').notNull(),
kind: text('kind', { enum: ['post', 'list', 'not-found', 'partial'] }).notNull().default('post'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
version: integer('version').notNull().default(1),
filePath: text('file_path').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
// Composite unique index: slug must be unique within each project
projectSlugIdx: uniqueIndex('templates_project_slug_idx').on(table.projectId, table.slug),
}));
// Types for TypeScript // Types for TypeScript
export type Project = typeof projects.$inferSelect; export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert; export type NewProject = typeof projects.$inferInsert;
@@ -194,3 +213,5 @@ export type ImportDefinition = typeof importDefinitions.$inferSelect;
export type NewImportDefinition = typeof importDefinitions.$inferInsert; export type NewImportDefinition = typeof importDefinitions.$inferInsert;
export type Script = typeof scripts.$inferSelect; export type Script = typeof scripts.$inferSelect;
export type NewScript = typeof scripts.$inferInsert; export type NewScript = typeof scripts.$inferInsert;
export type Template = typeof templates.$inferSelect;
export type NewTemplate = typeof templates.$inferInsert;

View File

@@ -1,3 +1,4 @@
import path from 'node:path';
import type { CategoryRenderSettings } from './PageRenderer'; import type { CategoryRenderSettings } from './PageRenderer';
import { buildCanonicalPostPath } from './PageRenderer'; import { buildCanonicalPostPath } from './PageRenderer';
import type { MenuDocument } from './MenuEngine'; import type { MenuDocument } from './MenuEngine';
@@ -210,6 +211,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
getMenu: async () => menu, getMenu: async () => menu,
}, },
getActiveProjectContext: async () => projectContext, getActiveProjectContext: async () => projectContext,
userTemplatesDir: path.join(params.options.dataDir, 'templates'),
}); });
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => { const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {

View File

@@ -3,6 +3,7 @@ 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'; import type { GitScriptFileChange, GitScriptFileChangeStatus } from './ScriptEngine';
import type { GitTemplateFileChange, GitTemplateFileChangeStatus } from './TemplateEngine';
export interface GitAvailability { export interface GitAvailability {
gitFound: boolean; gitFound: boolean;
@@ -142,6 +143,7 @@ export interface GitPostFileChange {
} }
export type { GitScriptFileChange, GitScriptFileChangeStatus }; export type { GitScriptFileChange, GitScriptFileChangeStatus };
export type { GitTemplateFileChange, GitTemplateFileChangeStatus };
type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo'; type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo';
@@ -534,6 +536,11 @@ export class GitEngine {
return normalized.startsWith('scripts/') && path.extname(normalized).toLowerCase() === '.py'; return normalized.startsWith('scripts/') && path.extname(normalized).toLowerCase() === '.py';
} }
private isTemplatesLiquidPath(value: string): boolean {
const normalized = this.normalizeRepoRelativePath(value);
return normalized.startsWith('templates/') && path.extname(normalized).toLowerCase() === '.liquid';
}
private parseNameStatusOutput(raw: string, pathMatcher: (value: string) => boolean): GitPostFileChange[] { private parseNameStatusOutput(raw: string, pathMatcher: (value: string) => boolean): GitPostFileChange[] {
const tokens = raw.split('\0').filter((token) => token.length > 0); const tokens = raw.split('\0').filter((token) => token.length > 0);
const changes: GitPostFileChange[] = []; const changes: GitPostFileChange[] = [];
@@ -1388,6 +1395,33 @@ export class GitEngine {
} }
} }
async getChangedTemplateFilesBetween(projectPath: string, fromCommit: string, toCommit: string): Promise<GitTemplateFileChange[]> {
const fromRef = fromCommit.trim();
const toRef = toCommit.trim();
if (!fromRef || !toRef || fromRef === toRef) {
return [];
}
const git = this.createNonInteractiveGit(projectPath);
const args = ['diff', '--name-status', '--find-renames', '-z', `${fromRef}..${toRef}`, '--', 'templates'];
try {
const output = await git.raw(args);
return this.parseNameStatusOutput(output, (value) => this.isTemplatesLiquidPath(value));
} catch (error) {
const message = error instanceof Error ? error.message : String(error ?? '');
if (this.isSpawnBadFileDescriptorError(message)) {
try {
const output = await this.runGitCli(projectPath, args);
return this.parseNameStatusOutput(output, (value) => this.isTemplatesLiquidPath(value));
} catch {
return [];
}
}
return [];
}
}
async pull(projectPath: string): Promise<GitActionResult> { async pull(projectPath: string): Promise<GitActionResult> {
const git = this.createNonInteractiveGit(projectPath); const git = this.createNonInteractiveGit(projectPath);
try { try {

View File

@@ -33,6 +33,8 @@ export interface ProjectMetadata {
export interface CategoryRenderSettings { export interface CategoryRenderSettings {
renderInLists: boolean; renderInLists: boolean;
showTitle: boolean; showTitle: boolean;
postTemplateSlug?: string;
listTemplateSlug?: string;
} }
/** /**
@@ -167,6 +169,8 @@ function normalizeCategoryMetadata(value: unknown): Record<string, CategoryMetad
renderInLists: settings.renderInLists !== false, renderInLists: settings.renderInLists !== false,
showTitle: settings.showTitle !== false, showTitle: settings.showTitle !== false,
title: sanitizeCategoryTitle(settings.title, category), title: sanitizeCategoryTitle(settings.title, category),
postTemplateSlug: typeof (settings as any).postTemplateSlug === 'string' ? (settings as any).postTemplateSlug : undefined,
listTemplateSlug: typeof (settings as any).listTemplateSlug === 'string' ? (settings as any).listTemplateSlug : undefined,
}; };
} }
@@ -178,7 +182,12 @@ function normalizeCategorySettings(value: unknown): Record<string, CategoryRende
return Object.fromEntries( return Object.fromEntries(
Object.entries(metadata).map(([category, data]) => [ Object.entries(metadata).map(([category, data]) => [
category, category,
{ renderInLists: data.renderInLists, showTitle: data.showTitle }, {
renderInLists: data.renderInLists,
showTitle: data.showTitle,
postTemplateSlug: data.postTemplateSlug,
listTemplateSlug: data.listTemplateSlug,
},
]), ]),
); );
} }

View File

@@ -47,6 +47,8 @@ export interface TemplatePostEntry {
export interface CategoryRenderSettings { export interface CategoryRenderSettings {
renderInLists: boolean; renderInLists: boolean;
showTitle: boolean; showTitle: boolean;
postTemplateSlug?: string;
listTemplateSlug?: string;
} }
export interface DayBlockContext { export interface DayBlockContext {
@@ -1021,6 +1023,7 @@ export function resolvePageRendererTemplateRoots(options?: {
moduleDir?: string; moduleDir?: string;
cwd?: string; cwd?: string;
resourcesPath?: string; resourcesPath?: string;
userTemplatesDir?: string;
}): string[] { }): string[] {
const moduleDir = options?.moduleDir ?? __dirname; const moduleDir = options?.moduleDir ?? __dirname;
const cwd = options?.cwd ?? process.cwd(); const cwd = options?.cwd ?? process.cwd();
@@ -1036,9 +1039,67 @@ export function resolvePageRendererTemplateRoots(options?: {
roots.unshift(path.resolve(resourcesPath, 'templates')); roots.unshift(path.resolve(resourcesPath, 'templates'));
} }
// User templates directory takes highest priority so user templates override built-ins
if (options?.userTemplatesDir) {
roots.unshift(options.userTemplatesDir);
}
return Array.from(new Set(roots)); return Array.from(new Set(roots));
} }
/**
* Resolve which template to use for rendering a single post.
* Priority: post.templateSlug -> first matching tag.postTemplateSlug -> category.postTemplateSlug -> default.
*/
export function resolvePostTemplateName(
post: { templateSlug?: string | null; tags?: string[]; categories?: string[] },
tagSettings?: Record<string, { postTemplateSlug?: string | null }>,
categorySettings?: Record<string, { postTemplateSlug?: string | null }>,
): string {
if (post.templateSlug) {
return post.templateSlug;
}
if (tagSettings && post.tags) {
for (const tag of post.tags) {
const normalizedTag = tag.toLowerCase().trim();
const setting = tagSettings[normalizedTag] || tagSettings[tag];
if (setting?.postTemplateSlug) {
return setting.postTemplateSlug;
}
}
}
if (categorySettings && post.categories) {
for (const category of post.categories) {
const setting = categorySettings[category];
if (setting?.postTemplateSlug) {
return setting.postTemplateSlug;
}
}
}
return 'single-post';
}
/**
* Resolve which template to use for rendering a post list.
* Priority: category.listTemplateSlug -> default.
*/
export function resolveListTemplateName(
routeCategory?: string,
categorySettings?: Record<string, { listTemplateSlug?: string | null }>,
): string {
if (routeCategory && categorySettings) {
const setting = categorySettings[routeCategory];
if (setting?.listTemplateSlug) {
return setting.listTemplateSlug;
}
}
return 'post-list';
}
export class PageRenderer { export class PageRenderer {
private readonly mediaEngine: MediaEngineContract; private readonly mediaEngine: MediaEngineContract;
private readonly postMediaEngine: PostMediaEngineContract; private readonly postMediaEngine: PostMediaEngineContract;
@@ -1051,13 +1112,14 @@ export class PageRenderer {
postMediaEngine: PostMediaEngineContract, postMediaEngine: PostMediaEngineContract,
postEngineForMacros?: PostEngineContract, postEngineForMacros?: PostEngineContract,
pythonMacroRenderer?: PythonMacroRendererContract, pythonMacroRenderer?: PythonMacroRendererContract,
userTemplatesDir?: string,
) { ) {
this.mediaEngine = mediaEngine; this.mediaEngine = mediaEngine;
this.postMediaEngine = postMediaEngine; this.postMediaEngine = postMediaEngine;
this.postEngineForMacros = postEngineForMacros; this.postEngineForMacros = postEngineForMacros;
this.pythonMacroRenderer = pythonMacroRenderer; this.pythonMacroRenderer = pythonMacroRenderer;
const templateRoots = resolvePageRendererTemplateRoots(); const templateRoots = resolvePageRendererTemplateRoots({ userTemplatesDir });
this.liquid = new Liquid({ this.liquid = new Liquid({
root: templateRoots, root: templateRoots,
@@ -1355,13 +1417,27 @@ export class PageRenderer {
options, options,
); );
return this.liquid.renderFile('post-list', templateContext); const routeCategory = options.archiveContext?.kind === 'category' ? options.archiveContext.name : undefined;
const listTemplateName = resolveListTemplateName(
routeCategory ?? undefined,
options.categorySettings as Record<string, { listTemplateSlug?: string | null }> | undefined,
);
return this.liquid.renderFile(listTemplateName, templateContext);
} }
async renderSinglePost( async renderSinglePost(
post: PostData, post: PostData,
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string; html_theme_attribute?: string; tag_color_by_name?: Record<string, string> }, pageContext: {
page_title: string;
language: string;
menu_items?: TemplateMenuItem[];
pico_stylesheet_href?: string;
html_theme_attribute?: string;
tag_color_by_name?: Record<string, string>;
tagSettings?: Record<string, { postTemplateSlug?: string | null }>;
categorySettings?: Record<string, { postTemplateSlug?: string | null }>;
},
postEngine?: PostEngineContract, postEngine?: PostEngineContract,
): Promise<string> { ): Promise<string> {
const renderablePost = postEngine const renderablePost = postEngine
@@ -1397,7 +1473,12 @@ export class PageRenderer {
}, },
}; };
return this.liquid.renderFile('single-post', context); const postTemplateName = resolvePostTemplateName(
renderablePost as { templateSlug?: string | null; tags?: string[]; categories?: string[] },
pageContext.tagSettings,
pageContext.categorySettings,
);
return this.liquid.renderFile(postTemplateName, context);
} }
async renderNotFound(context: NotFoundTemplateContext): Promise<string> { async renderNotFound(context: NotFoundTemplateContext): Promise<string> {

View File

@@ -22,6 +22,7 @@ import {
type PythonMacroRendererContract, type PythonMacroRendererContract,
} from './PageRenderer'; } from './PageRenderer';
import { getScriptEngine } from './ScriptEngine'; import { getScriptEngine } from './ScriptEngine';
import { getTemplateEngine } from './TemplateEngine';
import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime'; 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';
@@ -69,6 +70,7 @@ interface PreviewServerDependencies {
settingsEngine: MetaEngineContract; settingsEngine: MetaEngineContract;
menuEngine: MenuEngineContract; menuEngine: MenuEngineContract;
getActiveProjectContext: () => Promise<ActiveProjectContext>; getActiveProjectContext: () => Promise<ActiveProjectContext>;
userTemplatesDir?: string;
} }
interface SerializedTag { interface SerializedTag {
@@ -106,7 +108,13 @@ export class PreviewServer {
projectDescription: activeProject?.description ?? undefined, projectDescription: activeProject?.description ?? undefined,
}; };
}); });
this.pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine, buildPythonMacroRenderer()); this.pageRenderer = new PageRenderer(
this.mediaEngine,
this.postMediaEngine,
this.postEngine,
buildPythonMacroRenderer(),
dependencies?.userTemplatesDir ?? getTemplateEngine().getTemplatesDirectory(),
);
} }
async start(preferredPort = 0): Promise<number> { async start(preferredPort = 0): Promise<number> {
@@ -197,6 +205,7 @@ export class PreviewServer {
resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings), resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings),
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(), buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext), resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext),
resolveTagTemplateSettings: (projectContext) => this.resolveTagTemplateSettings(projectContext),
pageRenderer: this.pageRenderer, pageRenderer: this.pageRenderer,
postEngineForMacros: this.postEngine, postEngineForMacros: this.postEngine,
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination), loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
@@ -432,6 +441,39 @@ export class PreviewServer {
} }
} }
private async resolveTagTemplateSettings(projectContext: ActiveProjectContext): Promise<Record<string, { postTemplateSlug?: string | null }>> {
if (!projectContext.dataDir) {
return {};
}
const tagsPath = path.join(projectContext.dataDir, 'meta', 'tags.json');
try {
const source = await readFile(tagsPath, 'utf-8');
const parsed = JSON.parse(source);
if (!Array.isArray(parsed)) {
return {};
}
const settings: Record<string, { postTemplateSlug?: string | null }> = {};
for (const rawEntry of parsed as SerializedTag[]) {
const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : '';
const postTemplateSlug = typeof (rawEntry as Record<string, unknown>)?.postTemplateSlug === 'string'
? ((rawEntry as Record<string, unknown>).postTemplateSlug as string).trim()
: undefined;
if (!name || !postTemplateSlug) {
continue;
}
settings[name] = { postTemplateSlug };
}
return settings;
} catch {
return {};
}
}
private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> { private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
const match = pathname.match(/^\/assets\/([^/]+)$/); const match = pathname.match(/^\/assets\/([^/]+)$/);
if (!match) return null; if (!match) return null;
@@ -592,6 +634,8 @@ export class PreviewServer {
mergedSettings[category] = { mergedSettings[category] = {
renderInLists: value.renderInLists, renderInLists: value.renderInLists,
showTitle: value.showTitle, showTitle: value.showTitle,
postTemplateSlug: value.postTemplateSlug,
listTemplateSlug: value.listTemplateSlug,
}; };
} }
return mergedSettings; return mergedSettings;

View File

@@ -81,12 +81,73 @@ export class ProjectEngine extends EventEmitter {
// - If custom dataPath is provided, all project data lives there (allows cloud storage backup) // - If custom dataPath is provided, all project data lives there (allows cloud storage backup)
// - If no dataPath (default project), use internal userData storage // - If no dataPath (default project), use internal userData storage
const dataDir = this.getDataDir(projectId, dataPath); const dataDir = this.getDataDir(projectId, dataPath);
// Create all project directories in the data directory // Create all project directories in the data directory
await fs.mkdir(path.join(dataDir, 'posts'), { recursive: true }); await fs.mkdir(path.join(dataDir, 'posts'), { recursive: true });
await fs.mkdir(path.join(dataDir, 'media'), { recursive: true }); await fs.mkdir(path.join(dataDir, 'media'), { recursive: true });
await fs.mkdir(path.join(dataDir, 'meta'), { recursive: true }); await fs.mkdir(path.join(dataDir, 'meta'), { recursive: true });
await fs.mkdir(path.join(dataDir, 'thumbnails'), { recursive: true }); await fs.mkdir(path.join(dataDir, 'thumbnails'), { recursive: true });
await fs.mkdir(path.join(dataDir, 'templates'), { recursive: true });
}
private async copyStarterTemplates(projectId: string, dataPath?: string | null): Promise<void> {
const dataDir = this.getDataDir(projectId, dataPath);
const destDir = path.join(dataDir, 'templates');
// Resolve the bundled templates directory
const bundledRoots = [
path.resolve(__dirname, 'templates'),
path.resolve(process.cwd(), 'dist', 'main', 'engine', 'templates'),
path.resolve(process.cwd(), 'src', 'main', 'engine', 'templates'),
];
if (typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0) {
bundledRoots.unshift(path.resolve(process.resourcesPath, 'templates'));
}
let sourceDir: string | null = null;
for (const root of bundledRoots) {
try {
const stat = await fs.stat(root);
if (stat.isDirectory()) {
sourceDir = root;
break;
}
} catch {
// Directory doesn't exist, try next
}
}
if (!sourceDir) {
return;
}
try {
await this.copyDirectoryRecursive(sourceDir, destDir);
} catch (error) {
console.error('[ProjectEngine] Failed to copy starter templates:', error);
}
}
private async copyDirectoryRecursive(src: string, dest: string): Promise<void> {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await this.copyDirectoryRecursive(srcPath, destPath);
} else if (entry.name.endsWith('.liquid')) {
try {
await fs.access(destPath);
// File already exists, skip
} catch {
await fs.copyFile(srcPath, destPath);
}
}
}
} }
async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> { async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> {
@@ -119,6 +180,9 @@ export class ProjectEngine extends EventEmitter {
// Create directories using project ID (not slug) // Create directories using project ID (not slug)
await this.ensureProjectDirectories(id, data.dataPath); await this.ensureProjectDirectories(id, data.dataPath);
// Copy bundled templates as starter templates
await this.copyStarterTemplates(id, data.dataPath);
// Insert into database // Insert into database
const dbProject: NewProject = { const dbProject: NewProject = {
id: project.id, id: project.id,

View File

@@ -59,6 +59,7 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[]; resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[];
buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>; buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>;
resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise<Record<string, string>>; resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise<Record<string, string>>;
resolveTagTemplateSettings?: (projectContext: SharedActiveProjectContext) => Promise<Record<string, { postTemplateSlug?: string | null }>>;
pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>; pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>;
postEngineForMacros?: PostEngineContract; postEngineForMacros?: PostEngineContract;
loadPublishedSnapshotsPage: ( loadPublishedSnapshotsPage: (
@@ -96,6 +97,7 @@ async function resolveRouteWithSharedServices(
categorySettings: Record<string, CategoryRenderSettings>, categorySettings: Record<string, CategoryRenderSettings>,
categoryMetadata: Record<string, CategoryMetadata>, categoryMetadata: Record<string, CategoryMetadata>,
tagColorByName: Record<string, string>, tagColorByName: Record<string, string>,
tagTemplateSettings: Record<string, { postTemplateSlug?: string | null }>,
listExcludedCategories: string[], listExcludedCategories: string[],
services: SharedRouteRenderServices<CategoryMetadata>, services: SharedRouteRenderServices<CategoryMetadata>,
allowEmptyArchiveRender: boolean, allowEmptyArchiveRender: boolean,
@@ -187,6 +189,8 @@ async function resolveRouteWithSharedServices(
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute, html_theme_attribute: pageContext.htmlThemeAttribute,
tag_color_by_name: tagColorByName, tag_color_by_name: tagColorByName,
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
}, services.postEngineForMacros); }, services.postEngineForMacros);
} }
@@ -270,6 +274,8 @@ async function resolveRouteWithSharedServices(
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute, html_theme_attribute: pageContext.htmlThemeAttribute,
tag_color_by_name: tagColorByName, tag_color_by_name: tagColorByName,
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
}, services.postEngineForMacros); }, services.postEngineForMacros);
} }
@@ -310,6 +316,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext(); const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
const tagColorByName = await services.resolveTagColorByName(options.projectContext); const tagColorByName = await services.resolveTagColorByName(options.projectContext);
const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {};
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/'); const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, { return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
@@ -318,5 +325,5 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
menuItems, menuItems,
picoStylesheetHref, picoStylesheetHref,
htmlThemeAttribute: options.htmlThemeAttribute, htmlThemeAttribute: options.htmlThemeAttribute,
}, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.allowEmptyArchiveRender === true, options.singlePostOptions); }, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, tagTemplateSettings, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.allowEmptyArchiveRender === true, options.singlePostOptions);
} }

View File

@@ -18,6 +18,7 @@ export interface TagData {
projectId: string; projectId: string;
name: string; name: string;
color?: string; color?: string;
postTemplateSlug?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -45,6 +46,7 @@ export interface CreateTagInput {
export interface UpdateTagInput { export interface UpdateTagInput {
name?: string; name?: string;
color?: string | null; color?: string | null;
postTemplateSlug?: string | null;
} }
/** /**
@@ -110,6 +112,7 @@ function isValidHexColor(color: string): boolean {
interface SerializedTag { interface SerializedTag {
name: string; name: string;
color?: string; color?: string;
postTemplateSlug?: string;
} }
/** /**
@@ -400,19 +403,27 @@ export class TagEngine extends EventEmitter {
throw new Error('Invalid color format. Use hex format like #ff0000 or #f00'); throw new Error('Invalid color format. Use hex format like #ff0000 or #f00');
} }
if (input.color === undefined) { const hasColorUpdate = input.color !== undefined;
const hasTemplateUpdate = input.postTemplateSlug !== undefined;
if (!hasColorUpdate && !hasTemplateUpdate) {
// No updates // No updates
return this.rowToTagData(row); return this.rowToTagData(row);
} }
const now = new Date(); const now = new Date();
const setFields: Record<string, unknown> = { updatedAt: now };
if (hasColorUpdate) {
setFields.color = input.color;
}
if (hasTemplateUpdate) {
setFields.postTemplateSlug = input.postTemplateSlug;
}
await db await db
.update(tags) .update(tags)
.set({ .set(setFields)
color: input.color,
updatedAt: now,
})
.where(and( .where(and(
eq(tags.id, id), eq(tags.id, id),
eq(tags.projectId, this.currentProjectId) eq(tags.projectId, this.currentProjectId)
@@ -422,7 +433,8 @@ export class TagEngine extends EventEmitter {
id: row.id, id: row.id,
projectId: row.projectId, projectId: row.projectId,
name: row.name, name: row.name,
color: input.color !== undefined ? input.color || undefined : row.color || undefined, color: hasColorUpdate ? input.color || undefined : row.color || undefined,
postTemplateSlug: hasTemplateUpdate ? input.postTemplateSlug || undefined : row.postTemplateSlug || undefined,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: now, updatedAt: now,
}; };
@@ -817,6 +829,7 @@ export class TagEngine extends EventEmitter {
projectId: row.projectId, projectId: row.projectId,
name: row.name, name: row.name,
color: row.color || undefined, color: row.color || undefined,
postTemplateSlug: row.postTemplateSlug || undefined,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
@@ -838,6 +851,9 @@ export class TagEngine extends EventEmitter {
if (tag.color) { if (tag.color) {
entry.color = tag.color; entry.color = tag.color;
} }
if (tag.postTemplateSlug) {
entry.postTemplateSlug = tag.postTemplateSlug;
}
return entry; return entry;
}); });
@@ -867,6 +883,7 @@ export class TagEngine extends EventEmitter {
if (!name) continue; if (!name) continue;
const color = tag.color || null; const color = tag.color || null;
const postTemplateSlug = typeof tag.postTemplateSlug === 'string' ? tag.postTemplateSlug : null;
// Check if tag with this name already exists // Check if tag with this name already exists
const existing = await db const existing = await db
@@ -884,17 +901,22 @@ export class TagEngine extends EventEmitter {
projectId: this.currentProjectId, projectId: this.currentProjectId,
name, name,
color, color,
postTemplateSlug,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} else if (color) { } else if (color || postTemplateSlug) {
// Update color if provided and tag exists // Update color/postTemplateSlug if provided and tag exists
const setFields: Record<string, unknown> = { updatedAt: now };
if (color) {
setFields.color = color;
}
if (postTemplateSlug) {
setFields.postTemplateSlug = postTemplateSlug;
}
await db await db
.update(tags) .update(tags)
.set({ .set(setFields)
color,
updatedAt: now,
})
.where(and( .where(and(
eq(tags.projectId, this.currentProjectId), eq(tags.projectId, this.currentProjectId),
sql`LOWER(${tags.name}) = LOWER(${name})` sql`LOWER(${tags.name}) = LOWER(${name})`

View File

@@ -0,0 +1,762 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import { app } from 'electron';
import { and, desc, eq } from 'drizzle-orm';
import { Liquid } from 'liquidjs';
import { getDatabase } from '../database';
import { templates, type NewTemplate, type Template } from '../database/schema';
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
export interface TemplateData {
id: string;
projectId: string;
slug: string;
title: string;
kind: TemplateKind;
enabled: boolean;
version: number;
filePath: string;
content: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTemplateInput {
title: string;
kind: TemplateKind;
content: string;
slug?: string;
enabled?: boolean;
}
export interface UpdateTemplateInput {
title?: string;
kind?: TemplateKind;
content?: string;
slug?: string;
enabled?: boolean;
}
export type GitTemplateFileChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed';
export interface GitTemplateFileChange {
status: GitTemplateFileChangeStatus;
path: string;
previousPath?: string;
}
export interface TemplateReconcileResult {
created: number;
updated: number;
deleted: number;
processedFiles: number;
}
export interface TemplateValidationResult {
valid: boolean;
errors: string[];
}
interface ParsedTemplateFile {
metadata: {
id?: string;
projectId?: string;
slug?: string;
title?: string;
kind?: string;
enabled?: boolean;
version?: number;
createdAt?: string;
updatedAt?: string;
};
body: string;
}
export class TemplateEngine extends EventEmitter {
private currentProjectId = 'default';
private dataDir: string | null = null;
setProjectContext(projectId: string, dataDir?: string): void {
this.currentProjectId = projectId;
this.dataDir = dataDir || null;
}
getProjectContext(): string {
return this.currentProjectId;
}
getTemplatesDirectory(): string {
return this.getTemplatesDir();
}
async createTemplate(input: CreateTemplateInput): Promise<TemplateData> {
const now = new Date();
const allTemplates = await this.getAllTemplateRows();
const desiredSlug = this.normalizeSlug(input.slug || input.title || 'template');
const uniqueSlug = this.ensureUniqueSlug(desiredSlug, allTemplates);
const templateId = uuidv4();
const filePath = this.getTemplateFilePath(uniqueSlug);
const row: NewTemplate = {
id: templateId,
projectId: this.currentProjectId,
slug: uniqueSlug,
title: input.title,
kind: input.kind,
enabled: input.enabled ?? true,
version: 1,
filePath,
createdAt: now,
updatedAt: now,
};
await fs.mkdir(this.getTemplatesDir(), { recursive: true });
await fs.writeFile(filePath, this.serializeTemplateFile(row as Template, input.content), 'utf-8');
await getDatabase().getLocal().insert(templates).values(row);
const created = await this.toTemplateData(row as Template);
this.emit('templateCreated', created);
return created;
}
async updateTemplate(id: string, updates: UpdateTemplateInput): Promise<TemplateData | null> {
const existing = await this.getTemplateRow(id);
if (!existing) {
return null;
}
const allTemplates = await this.getAllTemplateRows();
const desiredSlug = typeof updates.slug === 'string'
? this.normalizeSlug(updates.slug)
: typeof updates.title === 'string'
? this.normalizeSlug(updates.title)
: existing.slug;
const nextSlug = this.ensureUniqueSlug(desiredSlug, allTemplates, existing.id);
const nextFilePath = this.getTemplateFilePath(nextSlug);
const now = new Date();
if (existing.filePath !== nextFilePath) {
await fs.mkdir(this.getTemplatesDir(), { recursive: true });
await fs.rename(existing.filePath, nextFilePath);
}
const nextTitle = updates.title ?? existing.title;
const nextKind = updates.kind ?? existing.kind;
const nextEnabled = updates.enabled ?? existing.enabled;
const nextVersion = existing.version + 1;
const nextContent = typeof updates.content === 'string'
? updates.content
: await this.readTemplateBody(nextFilePath);
const nextRow = {
...existing,
title: nextTitle,
slug: nextSlug,
kind: nextKind,
enabled: nextEnabled,
filePath: nextFilePath,
version: nextVersion,
updatedAt: now,
};
await fs.writeFile(nextFilePath, this.serializeTemplateFile(nextRow, nextContent), 'utf-8');
await getDatabase().getLocal()
.update(templates)
.set({
title: nextTitle,
slug: nextSlug,
kind: nextKind,
enabled: nextEnabled,
filePath: nextFilePath,
version: nextVersion,
updatedAt: now,
})
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
const updatedRow = await this.getTemplateRow(existing.id);
if (!updatedRow) {
return null;
}
const updated = await this.toTemplateData(updatedRow);
this.emit('templateUpdated', updated);
return updated;
}
async deleteTemplate(id: string): Promise<boolean> {
const existing = await this.getTemplateRow(id);
if (!existing) {
return false;
}
await getDatabase().getLocal()
.delete(templates)
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
try {
await fs.unlink(existing.filePath);
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code !== 'ENOENT') {
throw error;
}
}
this.emit('templateDeleted', id);
return true;
}
async getTemplate(id: string): Promise<TemplateData | null> {
const row = await this.getTemplateRow(id);
if (!row) {
return null;
}
return this.toTemplateData(row);
}
async getAllTemplates(): Promise<TemplateData[]> {
const rows = await this.getAllTemplateRows();
return Promise.all(rows.map((item) => this.toTemplateData(item)));
}
async getEnabledTemplatesByKind(kind: TemplateKind): Promise<TemplateData[]> {
const rows = await this.getAllTemplateRows();
const kindRows = rows.filter((row) => row.kind === kind && row.enabled);
return Promise.all(kindRows.map((item) => this.toTemplateData(item)));
}
async getTemplateBySlug(slug: string): Promise<TemplateData | null> {
const normalizedSlug = slug.toLowerCase();
const rows = await this.getAllTemplateRows();
const match = rows.find(
(row) => row.enabled && row.slug.toLowerCase() === normalizedSlug,
);
if (!match) {
return null;
}
return this.toTemplateData(match);
}
async validateTemplate(content: string): Promise<TemplateValidationResult> {
try {
const liquid = new Liquid({ strictVariables: false, strictFilters: false });
await liquid.parse(content);
return { valid: true, errors: [] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { valid: false, errors: [message] };
}
}
async rebuildDatabaseFromFiles(): Promise<void> {
const db = getDatabase().getLocal();
const templatesDir = this.getTemplatesDir();
await db.delete(templates).where(eq(templates.projectId, this.currentProjectId));
const liquidFiles = await this.scanTemplateFiles(templatesDir);
if (liquidFiles.length === 0) {
this.emit('templatesRebuilt');
return;
}
const usedIds = new Set<string>();
const insertedRows: Template[] = [];
for (const filePath of liquidFiles) {
const parsed = await this.readTemplateFileWithMetadata(filePath);
if (!parsed) {
continue;
}
const desiredSlug = this.normalizeSlug(parsed.metadata.slug || path.basename(filePath, '.liquid'));
const slug = this.ensureUniqueSlug(desiredSlug, insertedRows);
const desiredId = typeof parsed.metadata.id === 'string' && parsed.metadata.id.trim().length > 0
? parsed.metadata.id.trim()
: uuidv4();
const id = usedIds.has(desiredId) ? uuidv4() : desiredId;
const now = new Date();
const row: NewTemplate = {
id,
projectId: this.currentProjectId,
slug,
title: this.normalizeTitle(parsed.metadata.title, slug),
kind: this.normalizeKind(parsed.metadata.kind),
enabled: this.normalizeEnabled(parsed.metadata.enabled),
version: this.normalizeVersion(parsed.metadata.version),
filePath,
createdAt: this.normalizeDate(parsed.metadata.createdAt, now),
updatedAt: this.normalizeDate(parsed.metadata.updatedAt, now),
};
await db.insert(templates).values(row);
insertedRows.push(row as Template);
usedIds.add(id);
}
this.emit('templatesRebuilt');
}
async reconcileTemplatesFromGitChanges(projectPath: string, changes: GitTemplateFileChange[]): Promise<TemplateReconcileResult> {
const db = getDatabase().getLocal();
const normalizedProjectPath = path.resolve(projectPath);
const relevantChanges = changes.filter((change) => {
if (!this.isLiquidTemplatePath(change.path)) {
return false;
}
if (change.status === 'renamed' && change.previousPath && !this.isLiquidTemplatePath(change.previousPath) && !this.isLiquidTemplatePath(change.path)) {
return false;
}
return true;
});
if (relevantChanges.length === 0) {
return { created: 0, updated: 0, deleted: 0, processedFiles: 0 };
}
const templateRows = await this.getAllTemplateRows();
const templatesByPath = new Map<string, Template>();
for (const row of templateRows) {
templatesByPath.set(this.normalizePathForCompare(row.filePath), row);
}
let created = 0;
let updated = 0;
let deleted = 0;
let processedFiles = 0;
for (const change of relevantChanges) {
const absolutePath = this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.path));
const previousAbsolutePath = change.previousPath
? this.normalizePathForCompare(path.resolve(normalizedProjectPath, change.previousPath))
: null;
if (change.status === 'deleted') {
const existing = templatesByPath.get(absolutePath);
if (!existing) {
continue;
}
await db.delete(templates).where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
templatesByPath.delete(absolutePath);
this.emit('templateDeleted', existing.id);
deleted += 1;
processedFiles += 1;
continue;
}
let existing = previousAbsolutePath
? (templatesByPath.get(previousAbsolutePath) || templatesByPath.get(absolutePath))
: templatesByPath.get(absolutePath);
const parsed = await this.readTemplateFileWithMetadata(absolutePath);
if (!parsed) {
continue;
}
const allRows = await this.getAllTemplateRows();
const parsedId = typeof parsed.metadata.id === 'string' ? parsed.metadata.id.trim() : '';
if (!existing && parsedId.length > 0) {
const byId = allRows.find((row) => row.id === parsedId);
if (byId) {
existing = byId;
}
}
const desiredSlug = this.normalizeSlug(parsed.metadata.slug || path.basename(absolutePath, '.liquid'));
const slug = this.ensureUniqueSlug(desiredSlug, allRows, existing?.id);
if (existing) {
const updateNow = new Date();
const nextRow = {
title: this.normalizeTitle(parsed.metadata.title, slug, existing.title),
slug,
kind: this.normalizeKind(parsed.metadata.kind, existing.kind),
enabled: this.normalizeEnabled(parsed.metadata.enabled, existing.enabled),
version: this.normalizeVersion(parsed.metadata.version, existing.version),
filePath: absolutePath,
createdAt: this.normalizeDate(parsed.metadata.createdAt, existing.createdAt),
updatedAt: this.normalizeDate(parsed.metadata.updatedAt, updateNow),
};
await db.update(templates)
.set(nextRow)
.where(and(eq(templates.id, existing.id), eq(templates.projectId, this.currentProjectId)));
const updatedRow = await this.getTemplateRow(existing.id);
if (updatedRow) {
const updatedTemplate = await this.toTemplateData(updatedRow);
this.emit('templateUpdated', updatedTemplate);
}
if (previousAbsolutePath) {
templatesByPath.delete(previousAbsolutePath);
}
templatesByPath.set(absolutePath, {
...existing,
...nextRow,
});
updated += 1;
processedFiles += 1;
continue;
}
const desiredId = typeof parsed.metadata.id === 'string' && parsed.metadata.id.trim().length > 0
? parsed.metadata.id.trim()
: uuidv4();
const idExists = allRows.some((row) => row.id === desiredId);
const rowId = idExists ? uuidv4() : desiredId;
const now = new Date();
const newRow: NewTemplate = {
id: rowId,
projectId: this.currentProjectId,
slug,
title: this.normalizeTitle(parsed.metadata.title, slug),
kind: this.normalizeKind(parsed.metadata.kind),
enabled: this.normalizeEnabled(parsed.metadata.enabled),
version: this.normalizeVersion(parsed.metadata.version),
filePath: absolutePath,
createdAt: this.normalizeDate(parsed.metadata.createdAt, now),
updatedAt: this.normalizeDate(parsed.metadata.updatedAt, now),
};
await db.insert(templates).values(newRow);
const createdRow = await this.getTemplateRow(newRow.id);
if (createdRow) {
const createdTemplate = await this.toTemplateData(createdRow);
this.emit('templateCreated', createdTemplate);
}
templatesByPath.set(absolutePath, newRow as Template);
created += 1;
processedFiles += 1;
}
return {
created,
updated,
deleted,
processedFiles,
};
}
private async getTemplateRow(id: string): Promise<Template | null> {
const rows = await this.getAllTemplateRows();
return rows.find((item) => item.id === id) || null;
}
private async getAllTemplateRows(): Promise<Template[]> {
return getDatabase().getLocal()
.select()
.from(templates)
.where(eq(templates.projectId, this.currentProjectId))
.orderBy(desc(templates.updatedAt))
.all();
}
private async toTemplateData(row: Template): Promise<TemplateData> {
const content = await this.readTemplateBody(row.filePath);
return {
id: row.id,
projectId: row.projectId,
slug: row.slug,
title: row.title,
kind: row.kind,
enabled: row.enabled,
version: row.version,
filePath: row.filePath,
content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private getDataDir(): string {
if (this.dataDir) {
return this.dataDir;
}
return path.join(app.getPath('userData'), 'projects', this.currentProjectId);
}
private getTemplatesDir(): string {
return path.join(this.getDataDir(), 'templates');
}
private getTemplateFilePath(slug: string): string {
return path.join(this.getTemplatesDir(), `${slug}.liquid`);
}
private normalizePathForCompare(filePath: string): string {
return path.resolve(filePath).replace(/\\/g, '/');
}
private isLiquidTemplatePath(value: string): boolean {
const normalized = value.replace(/\\/g, '/').replace(/^\.\//, '');
return normalized.startsWith('templates/') && path.extname(normalized).toLowerCase() === '.liquid';
}
private normalizeSlug(value: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return normalized || 'template';
}
private ensureUniqueSlug(slug: string, rows: Template[], excludeId?: string): string {
const baseSlug = slug;
const taken = new Set(
rows
.filter((item) => item.id !== excludeId)
.map((item) => item.slug)
);
if (!taken.has(baseSlug)) {
return baseSlug;
}
let suffix = 2;
while (taken.has(`${baseSlug}_${suffix}`)) {
suffix += 1;
}
return `${baseSlug}_${suffix}`;
}
private serializeTemplateFile(row: Pick<Template, 'id' | 'projectId' | 'slug' | 'title' | 'kind' | 'enabled' | 'version' | 'createdAt' | 'updatedAt'>, content: string): string {
const lines = [
'---',
`id: ${this.toYamlString(row.id)}`,
`projectId: ${this.toYamlString(row.projectId)}`,
`slug: ${this.toYamlString(row.slug)}`,
`title: ${this.toYamlString(row.title)}`,
`kind: ${this.toYamlString(row.kind)}`,
`enabled: ${row.enabled ? 'true' : 'false'}`,
`version: ${row.version}`,
`createdAt: ${this.toYamlString(row.createdAt.toISOString())}`,
`updatedAt: ${this.toYamlString(row.updatedAt.toISOString())}`,
'---',
content,
];
return lines.join('\n');
}
private toYamlString(value: string): string {
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
return `"${escaped}"`;
}
private parseTemplateBody(rawContent: string): string {
const frontmatterPattern = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
if (!frontmatterPattern.test(rawContent)) {
return rawContent;
}
return rawContent.replace(frontmatterPattern, '');
}
private parseTemplateFile(rawContent: string): ParsedTemplateFile {
const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
const match = rawContent.match(frontmatterPattern);
if (!match) {
return {
metadata: {},
body: rawContent,
};
}
const metadataLines = (match[1] || '').split(/\r?\n/);
const metadata: ParsedTemplateFile['metadata'] = {};
for (const rawLine of metadataLines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf(':');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const valueRaw = line.slice(separatorIndex + 1).trim();
const value = this.parseYamlScalar(valueRaw);
if (key === 'enabled') {
if (typeof value === 'boolean') {
metadata.enabled = value;
}
continue;
}
if (key === 'version') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
metadata.version = parsed;
}
continue;
}
if (
key === 'id' ||
key === 'projectId' ||
key === 'slug' ||
key === 'title' ||
key === 'kind' ||
key === 'createdAt' ||
key === 'updatedAt'
) {
if (typeof value === 'string') {
metadata[key] = value;
}
}
}
return {
metadata,
body: rawContent.replace(frontmatterPattern, ''),
};
}
private parseYamlScalar(valueRaw: string): string | number | boolean {
if ((valueRaw.startsWith('"') && valueRaw.endsWith('"')) || (valueRaw.startsWith("'") && valueRaw.endsWith("'"))) {
return valueRaw.slice(1, -1)
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
if (valueRaw === 'true') {
return true;
}
if (valueRaw === 'false') {
return false;
}
const numeric = Number(valueRaw);
if (!Number.isNaN(numeric)) {
return numeric;
}
return valueRaw;
}
private normalizeKind(kind: string | undefined, fallback: TemplateKind = 'post'): TemplateKind {
if (kind === 'post' || kind === 'list' || kind === 'not-found' || kind === 'partial') {
return kind;
}
return fallback;
}
private normalizeEnabled(enabled: boolean | undefined, fallback = true): boolean {
if (typeof enabled === 'boolean') {
return enabled;
}
return fallback;
}
private normalizeVersion(version: number | undefined, fallback = 1): number {
if (typeof version === 'number' && Number.isFinite(version) && version > 0) {
return Math.floor(version);
}
return fallback;
}
private normalizeDate(value: string | undefined, fallback: Date): Date {
if (typeof value === 'string') {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return fallback;
}
private normalizeTitle(title: string | undefined, slug: string, fallback?: string): string {
if (typeof title === 'string' && title.trim().length > 0) {
return title.trim();
}
if (typeof fallback === 'string' && fallback.trim().length > 0) {
return fallback.trim();
}
return slug;
}
private async scanTemplateFiles(dir: string): Promise<string[]> {
const results: string[] = [];
const scan = async (currentDir: string): Promise<void> => {
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> = [];
try {
entries = await fs.readdir(currentDir, { withFileTypes: true }) as Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await scan(fullPath);
continue;
}
if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.liquid') {
results.push(fullPath);
}
}
};
await scan(dir);
return results;
}
private async readTemplateFileWithMetadata(filePath: string): Promise<ParsedTemplateFile | null> {
try {
const rawContent = await fs.readFile(filePath, 'utf-8');
return this.parseTemplateFile(rawContent);
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code !== 'ENOENT') {
throw error;
}
return null;
}
}
private async readTemplateBody(filePath: string): Promise<string> {
try {
const rawContent = await fs.readFile(filePath, 'utf-8');
return this.parseTemplateBody(rawContent);
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code !== 'ENOENT') {
throw error;
}
return '';
}
}
}
let templateEngineInstance: TemplateEngine | null = null;
export function getTemplateEngine(): TemplateEngine {
if (!templateEngineInstance) {
templateEngineInstance = new TemplateEngine();
}
return templateEngineInstance;
}

View File

@@ -10,6 +10,7 @@ import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
import { getTagEngine } from '../engine/TagEngine'; 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 { getTemplateEngine, type CreateTemplateInput, type UpdateTemplateInput } from '../engine/TemplateEngine';
import { getGitEngine } from '../engine/GitEngine'; import { getGitEngine } from '../engine/GitEngine';
import { getGitApiAdapter } from '../engine/GitApiAdapter'; import { getGitApiAdapter } from '../engine/GitApiAdapter';
import { taskManager, TaskProgress } from '../engine/TaskManager'; import { taskManager, TaskProgress } from '../engine/TaskManager';
@@ -191,11 +192,12 @@ export function registerIpcHandlers(): void {
return pullResult; return pullResult;
} }
const [changedPostFiles, changedScriptFiles] = await Promise.all([ const [changedPostFiles, changedScriptFiles, changedTemplateFiles] = await Promise.all([
engine.getChangedPostFilesBetween(projectPath, beforeHead, afterHead), engine.getChangedPostFilesBetween(projectPath, beforeHead, afterHead),
engine.getChangedScriptFilesBetween(projectPath, beforeHead, afterHead), engine.getChangedScriptFilesBetween(projectPath, beforeHead, afterHead),
engine.getChangedTemplateFilesBetween(projectPath, beforeHead, afterHead),
]); ]);
if (changedPostFiles.length === 0 && changedScriptFiles.length === 0) { if (changedPostFiles.length === 0 && changedScriptFiles.length === 0 && changedTemplateFiles.length === 0) {
return pullResult; return pullResult;
} }
@@ -204,11 +206,13 @@ export function registerIpcHandlers(): void {
const project = await projectEngine.getActiveProject(); const project = await projectEngine.getActiveProject();
const postEngine = getPostEngine(); const postEngine = getPostEngine();
const scriptEngine = getScriptEngine(); const scriptEngine = getScriptEngine();
const templateEngine = getTemplateEngine();
if (project) { if (project) {
const dataDir = projectEngine.getDataDir(project.id, project.dataPath); const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
postEngine.setProjectContext(project.id, dataDir); postEngine.setProjectContext(project.id, dataDir);
scriptEngine.setProjectContext(project.id, dataDir); scriptEngine.setProjectContext(project.id, dataDir);
templateEngine.setProjectContext(project.id, dataDir);
} }
await Promise.all([ await Promise.all([
@@ -218,9 +222,12 @@ export function registerIpcHandlers(): void {
changedScriptFiles.length > 0 changedScriptFiles.length > 0
? scriptEngine.reconcileScriptsFromGitChanges(projectPath, changedScriptFiles) ? scriptEngine.reconcileScriptsFromGitChanges(projectPath, changedScriptFiles)
: Promise.resolve(), : Promise.resolve(),
changedTemplateFiles.length > 0
? templateEngine.reconcileTemplatesFromGitChanges(projectPath, changedTemplateFiles)
: Promise.resolve(),
]); ]);
} catch (error) { } catch (error) {
console.error('Failed to reconcile published posts/scripts after git pull:', error); console.error('Failed to reconcile published posts/scripts/templates after git pull:', error);
} }
return pullResult; return pullResult;
@@ -304,12 +311,14 @@ export function registerIpcHandlers(): void {
const menuEngine = getMenuEngine(); const menuEngine = getMenuEngine();
const tagEngine = getTagEngine(); const tagEngine = getTagEngine();
const scriptEngine = getScriptEngine(); const scriptEngine = getScriptEngine();
const templateEngine = getTemplateEngine();
postEngine.setProjectContext(project.id, dataDir); postEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir); mediaEngine.setProjectContext(project.id, dataDir, dataDir);
metaEngine.setProjectContext(project.id, dataDir); metaEngine.setProjectContext(project.id, dataDir);
menuEngine.setProjectContext(project.id, dataDir); menuEngine.setProjectContext(project.id, dataDir);
tagEngine.setProjectContext(project.id, dataDir); tagEngine.setProjectContext(project.id, dataDir);
scriptEngine.setProjectContext(project.id, dataDir); scriptEngine.setProjectContext(project.id, dataDir);
templateEngine.setProjectContext(project.id, dataDir);
const postMediaEngine = getPostMediaEngine(); const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id); postMediaEngine.setProjectContext(project.id);
@@ -344,12 +353,14 @@ export function registerIpcHandlers(): void {
const menuEngine = getMenuEngine(); const menuEngine = getMenuEngine();
const tagEngine = getTagEngine(); const tagEngine = getTagEngine();
const scriptEngine = getScriptEngine(); const scriptEngine = getScriptEngine();
const templateEngine = getTemplateEngine();
postEngine.setProjectContext(project.id, dataDir); postEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir); mediaEngine.setProjectContext(project.id, dataDir, dataDir);
metaEngine.setProjectContext(project.id, dataDir); metaEngine.setProjectContext(project.id, dataDir);
menuEngine.setProjectContext(project.id, dataDir); menuEngine.setProjectContext(project.id, dataDir);
tagEngine.setProjectContext(project.id, dataDir); tagEngine.setProjectContext(project.id, dataDir);
scriptEngine.setProjectContext(project.id, dataDir); scriptEngine.setProjectContext(project.id, dataDir);
templateEngine.setProjectContext(project.id, dataDir);
const postMediaEngine = getPostMediaEngine(); const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id); postMediaEngine.setProjectContext(project.id);
@@ -794,6 +805,55 @@ export function registerIpcHandlers(): void {
return true; return true;
}); });
// ============ Template Handlers ============
safeHandle('templates:create', async (_, data: CreateTemplateInput) => {
const engine = getTemplateEngine();
return engine.createTemplate(data);
});
safeHandle('templates:update', async (_, id: string, data: UpdateTemplateInput) => {
const engine = getTemplateEngine();
return engine.updateTemplate(id, data);
});
safeHandle('templates:delete', async (_, id: string) => {
const engine = getTemplateEngine();
return engine.deleteTemplate(id);
});
safeHandle('templates:get', async (_, id: string) => {
const engine = getTemplateEngine();
return engine.getTemplate(id);
});
safeHandle('templates:getAll', async () => {
const engine = getTemplateEngine();
return engine.getAllTemplates();
});
safeHandle('templates:getEnabledByKind', async (_, kind: string) => {
const engine = getTemplateEngine();
return engine.getEnabledTemplatesByKind(kind as 'post' | 'list' | 'not-found' | 'partial');
});
safeHandle('templates:validate', async (_, content: string) => {
const engine = getTemplateEngine();
return engine.validateTemplate(content);
});
safeHandle('templates:rebuildFromFiles', async () => {
const projectEngine = getProjectEngine();
const project = await projectEngine.getActiveProject();
const engine = getTemplateEngine();
if (project) {
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
engine.setProjectContext(project.id, dataDir);
}
await engine.rebuildDatabaseFromFiles();
return true;
});
// ============ Task Handlers ============ // ============ Task Handlers ============
safeHandle('tasks:getAll', async () => { safeHandle('tasks:getAll', async () => {
@@ -1135,7 +1195,7 @@ export function registerIpcHandlers(): void {
return engine.createTag(data); return engine.createTag(data);
}); });
safeHandle('tags:update', async (_, id: string, data: { name?: string; color?: string | null }) => { safeHandle('tags:update', async (_, id: string, data: { name?: string; color?: string | null; postTemplateSlug?: string | null }) => {
const engine = getTagEngine(); const engine = getTagEngine();
return engine.updateTag(id, data); return engine.updateTag(id, data);
}); });
@@ -1566,4 +1626,10 @@ export function registerIpcHandlers(): void {
scriptEngine.on('scriptUpdated', forwardEvent('script:updated')); scriptEngine.on('scriptUpdated', forwardEvent('script:updated'));
scriptEngine.on('scriptDeleted', forwardEvent('script:deleted')); scriptEngine.on('scriptDeleted', forwardEvent('script:deleted'));
scriptEngine.on('scriptsRebuilt', forwardEvent('scripts:rebuilt')); scriptEngine.on('scriptsRebuilt', forwardEvent('scripts:rebuilt'));
const templateEngine = getTemplateEngine();
templateEngine.on('templateCreated', forwardEvent('template:created'));
templateEngine.on('templateUpdated', forwardEvent('template:updated'));
templateEngine.on('templateDeleted', forwardEvent('template:deleted'));
templateEngine.on('templatesRebuilt', forwardEvent('templates:rebuilt'));
} }

View File

@@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine'; import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine'; import { getPostEngine } from './engine/PostEngine';
import { getMetaEngine } from './engine/MetaEngine'; import { getMetaEngine } from './engine/MetaEngine';
import { getTemplateEngine } from './engine/TemplateEngine';
import { getBlogmarkTransformService } from './engine/BlogmarkTransformService'; import { getBlogmarkTransformService } from './engine/BlogmarkTransformService';
import { PreviewServer } from './engine/PreviewServer'; import { PreviewServer } from './engine/PreviewServer';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands'; import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
@@ -496,6 +497,11 @@ async function initializeActiveProjectContext(): Promise<void> {
mediaEngine.setProjectContext?.(project.id, dataDir, dataDir); mediaEngine.setProjectContext?.(project.id, dataDir, dataDir);
metaEngine.setProjectContext?.(project.id, dataDir); metaEngine.setProjectContext?.(project.id, dataDir);
const templateEngine = getTemplateEngine() as {
setProjectContext?: (projectId: string, dataDir?: string) => void;
};
templateEngine.setProjectContext?.(project.id, dataDir);
await metaEngine.syncOnStartup?.(); await metaEngine.syncOnStartup?.();
const metadata = await metaEngine.getProjectMetadata?.(); const metadata = await metaEngine.getProjectMetadata?.();

View File

@@ -112,6 +112,18 @@ export const electronAPI: ElectronAPI = {
rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'), rebuildFromFiles: () => ipcRenderer.invoke('scripts:rebuildFromFiles'),
}, },
// Templates
templates: {
create: (data: { title: string; kind: import('./shared/electronApi').TemplateKind; content: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:create', data),
update: (id: string, data: { title?: string; kind?: import('./shared/electronApi').TemplateKind; content?: string; slug?: string; enabled?: boolean }) => ipcRenderer.invoke('templates:update', id, data),
delete: (id: string) => ipcRenderer.invoke('templates:delete', id),
get: (id: string) => ipcRenderer.invoke('templates:get', id),
getAll: () => ipcRenderer.invoke('templates:getAll'),
getEnabledByKind: (kind: import('./shared/electronApi').TemplateKind) => ipcRenderer.invoke('templates:getEnabledByKind', kind),
validate: (content: string) => ipcRenderer.invoke('templates:validate', content),
rebuildFromFiles: () => ipcRenderer.invoke('templates:rebuildFromFiles'),
},
// Post-Media Links // Post-Media Links
postMedia: { postMedia: {
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId), link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
@@ -189,7 +201,7 @@ export const electronAPI: ElectronAPI = {
get: (id: string) => ipcRenderer.invoke('tags:get', id), get: (id: string) => ipcRenderer.invoke('tags:get', id),
getByName: (name: string) => ipcRenderer.invoke('tags:getByName', name), getByName: (name: string) => ipcRenderer.invoke('tags:getByName', name),
create: (data: { name: string; color?: string }) => ipcRenderer.invoke('tags:create', data), create: (data: { name: string; color?: string }) => ipcRenderer.invoke('tags:create', data),
update: (id: string, data: { name?: string; color?: string | null }) => ipcRenderer.invoke('tags:update', id, data), update: (id: string, data: { name?: string; color?: string | null; postTemplateSlug?: string | null }) => ipcRenderer.invoke('tags:update', id, data),
delete: (id: string) => ipcRenderer.invoke('tags:delete', id), delete: (id: string) => ipcRenderer.invoke('tags:delete', id),
merge: (sourceTagIds: string[], targetTagId: string) => ipcRenderer.invoke('tags:merge', sourceTagIds, targetTagId), merge: (sourceTagIds: string[], targetTagId: string) => ipcRenderer.invoke('tags:merge', sourceTagIds, targetTagId),
rename: (id: string, newName: string) => ipcRenderer.invoke('tags:rename', id, newName), rename: (id: string, newName: string) => ipcRenderer.invoke('tags:rename', id, newName),

View File

@@ -52,6 +52,8 @@ export interface ProjectMetadata {
export interface CategoryRenderSettings { export interface CategoryRenderSettings {
renderInLists: boolean; renderInLists: boolean;
showTitle: boolean; showTitle: boolean;
postTemplateSlug?: string;
listTemplateSlug?: string;
} }
export interface CategoryMetadata extends CategoryRenderSettings { export interface CategoryMetadata extends CategoryRenderSettings {
@@ -90,6 +92,7 @@ export interface PostData {
publishedAt?: string; publishedAt?: string;
tags: string[]; tags: string[];
categories: string[]; categories: string[];
templateSlug?: string;
} }
export interface PostFilter { export interface PostFilter {
@@ -158,6 +161,22 @@ export interface ScriptData {
updatedAt: string; updatedAt: string;
} }
export type TemplateKind = 'post' | 'list' | 'not-found' | 'partial';
export interface TemplateData {
id: string;
projectId: string;
slug: string;
title: string;
kind: TemplateKind;
enabled: boolean;
version: number;
filePath: string;
content: string;
createdAt: string;
updatedAt: string;
}
export interface TaskProgress { export interface TaskProgress {
taskId: string; taskId: string;
name: string; name: string;
@@ -200,6 +219,7 @@ export interface TagData {
projectId: string; projectId: string;
name: string; name: string;
color?: string; color?: string;
postTemplateSlug?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -588,6 +608,28 @@ export interface ElectronAPI {
getEnabledMacroSlugs: () => Promise<string[]>; getEnabledMacroSlugs: () => Promise<string[]>;
rebuildFromFiles: () => Promise<void>; rebuildFromFiles: () => Promise<void>;
}; };
templates: {
create: (data: {
title: string;
kind: TemplateKind;
content: string;
slug?: string;
enabled?: boolean;
}) => Promise<TemplateData>;
update: (id: string, data: {
title?: string;
kind?: TemplateKind;
content?: string;
slug?: string;
enabled?: boolean;
}) => Promise<TemplateData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<TemplateData | null>;
getAll: () => Promise<TemplateData[]>;
getEnabledByKind: (kind: TemplateKind) => Promise<TemplateData[]>;
validate: (content: string) => Promise<{ valid: boolean; errors: string[] }>;
rebuildFromFiles: () => Promise<void>;
};
postMedia: { postMedia: {
link: (postId: string, mediaId: string) => Promise<MediaLinkData>; link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
unlink: (postId: string, mediaId: string) => Promise<void>; unlink: (postId: string, mediaId: string) => Promise<void>;
@@ -654,7 +696,7 @@ export interface ElectronAPI {
get: (id: string) => Promise<TagData | null>; get: (id: string) => Promise<TagData | null>;
getByName: (name: string) => Promise<TagData | null>; getByName: (name: string) => Promise<TagData | null>;
create: (data: { name: string; color?: string }) => Promise<TagData>; create: (data: { name: string; color?: string }) => Promise<TagData>;
update: (id: string, data: { name?: string; color?: string | null }) => Promise<TagData | null>; update: (id: string, data: { name?: string; color?: string | null; postTemplateSlug?: string | null }) => Promise<TagData | null>;
delete: (id: string) => Promise<DeleteTagResult>; delete: (id: string) => Promise<DeleteTagResult>;
merge: (sourceTagIds: string[], targetTagId: string) => Promise<MergeTagsResult>; merge: (sourceTagIds: string[], targetTagId: string) => Promise<MergeTagsResult>;
rename: (id: string, newName: string) => Promise<RenameTagResult>; rename: (id: string, newName: string) => Promise<RenameTagResult>;

View File

@@ -36,6 +36,12 @@ const ScriptsIcon = () => (
</svg> </svg>
); );
const TemplatesIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"/>
</svg>
);
const SettingsIcon = () => ( const SettingsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/> <path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
@@ -183,6 +189,13 @@ export const ActivityBar: React.FC = () => {
> >
<ScriptsIcon /> <ScriptsIcon />
</button> </button>
<button
className={`activity-bar-item ${isActivityActive(snapshot, 'templates') ? 'active' : ''}`}
onClick={() => executeActivityClick('templates')}
title={getTitle('templates')}
>
<TemplatesIcon />
</button>
<button <button
className={`activity-bar-item ${isActivityActive(snapshot, 'tags') ? 'active' : ''}`} className={`activity-bar-item ${isActivityActive(snapshot, 'tags') ? 'active' : ''}`}
onClick={() => executeActivityClick('tags')} onClick={() => executeActivityClick('tags')}

View File

@@ -20,6 +20,7 @@ import { GitDiffView } from '../GitDiffView/GitDiffView';
import { DocumentationView } from '../DocumentationView/DocumentationView'; import { DocumentationView } from '../DocumentationView/DocumentationView';
import { SiteValidationView } from '../SiteValidationView'; import { SiteValidationView } from '../SiteValidationView';
import { ScriptsView } from '../ScriptsView/ScriptsView'; import { ScriptsView } from '../ScriptsView/ScriptsView';
import { TemplatesView } from '../TemplatesView/TemplatesView';
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils'; import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
import { InsertModal } from '../InsertModal'; import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal'; import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
@@ -71,6 +72,9 @@ const autoSaveManager = new AutoSaveManager({
if ('categories' in changes) { if ('categories' in changes) {
update.categories = changes.categories as string[]; update.categories = changes.categories as string[];
} }
if ('templateSlug' in changes) {
(update as Record<string, unknown>).templateSlug = changes.templateSlug as string || null;
}
const updated = await window.electronAPI?.posts.update(id, update); const updated = await window.electronAPI?.posts.update(id, update);
if (updated) { if (updated) {
@@ -191,6 +195,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [author, setAuthor] = useState(''); const [author, setAuthor] = useState('');
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']); const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
const [templateSlug, setTemplateSlug] = useState('');
const [availablePostTemplates, setAvailablePostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false); const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode); const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
@@ -319,10 +325,15 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setAuthor(post.author || ''); setAuthor(post.author || '');
setTags(post.tags); setTags(post.tags);
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
setTemplateSlug((post as PostData & { templateSlug?: string }).templateSlug || '');
setMetadataExpanded(post.title === ''); setMetadataExpanded(post.title === '');
markClean(postId); markClean(postId);
// Mark as initialized AFTER setting local state // Mark as initialized AFTER setting local state
setIsInitialized(true); setIsInitialized(true);
// Load available post templates for the dropdown
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
setAvailablePostTemplates((templates ?? []).map((tmpl) => ({ slug: tmpl.slug, title: tmpl.title })));
});
} }
}, [post, postId, markClean, isInitialized]); }, [post, postId, markClean, isInitialized]);
@@ -335,7 +346,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const contentChanged = content !== post.content; const contentChanged = content !== post.content;
const titleChanged = title !== post.title; const titleChanged = title !== post.title;
const authorChanged = author !== (post.author || ''); const authorChanged = author !== (post.author || '');
const hasChanges = contentChanged || titleChanged || authorChanged || const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || '');
const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged ||
JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) || JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort()); JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
@@ -349,11 +361,12 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
author, author,
tags: tags.join(', '), tags: tags.join(', '),
categories: selectedCategories, categories: selectedCategories,
templateSlug: templateSlug || undefined,
}); });
} else { } else {
markClean(postId); markClean(postId);
} }
}, [title, content, author, tags, selectedCategories, post, postId, isInitialized, isDirty, markDirty, markClean]); }, [title, content, author, tags, selectedCategories, templateSlug, post, postId, isInitialized, isDirty, markDirty, markClean]);
// Handle editor mode change and persist preference // Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => { const handleEditorModeChange = (mode: EditorMode) => {
@@ -375,7 +388,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
author: author || undefined, author: author || undefined,
tags, tags,
categories: selectedCategories.length > 0 ? selectedCategories : ['article'], categories: selectedCategories.length > 0 ? selectedCategories : ['article'],
}); templateSlug: templateSlug || null,
} as Parameters<typeof window.electronAPI.posts.update>[1]);
if (updated) { if (updated) {
updatePost(postId, updated as Partial<PostData>); updatePost(postId, updated as Partial<PostData>);
@@ -799,6 +813,20 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
/> />
</div> </div>
</div> </div>
{availablePostTemplates.length > 0 && (
<div className="editor-field">
<label>{tr('editor.field.template')}</label>
<select
value={templateSlug}
onChange={(e) => setTemplateSlug(e.target.value)}
>
<option value="">{tr('editor.field.templateDefault')}</option>
{availablePostTemplates.map((tmpl) => (
<option key={tmpl.slug} value={tmpl.slug}>{tmpl.title}</option>
))}
</select>
</div>
)}
<PostLinks <PostLinks
postId={postId} postId={postId}
@@ -1836,6 +1864,7 @@ export const Editor: React.FC = () => {
), ),
'site-validation': () => <SiteValidationView />, 'site-validation': () => <SiteValidationView />,
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />, scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
templates: () => <TemplatesView templateId={editorRoute.tabId} />,
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />), post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
media: () => (editorRoute.tabId ? <MediaEditor key={editorRoute.tabId} mediaId={editorRoute.tabId} /> : <Dashboard />), media: () => (editorRoute.tabId ? <MediaEditor key={editorRoute.tabId} mediaId={editorRoute.tabId} /> : <Dashboard />),
dashboard: () => <Dashboard />, dashboard: () => <Dashboard />,

View File

@@ -34,6 +34,8 @@ interface CategoryMetadata {
renderInLists: boolean; renderInLists: boolean;
showTitle: boolean; showTitle: boolean;
title: string; title: string;
postTemplateSlug?: string;
listTemplateSlug?: string;
} }
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = { const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
@@ -151,6 +153,10 @@ export const SettingsView: React.FC = () => {
const [categoryMetadata, setCategoryMetadata] = useState<Record<string, CategoryMetadata>>(DEFAULT_CATEGORY_METADATA); const [categoryMetadata, setCategoryMetadata] = useState<Record<string, CategoryMetadata>>(DEFAULT_CATEGORY_METADATA);
const [newCategoryInput, setNewCategoryInput] = useState(''); const [newCategoryInput, setNewCategoryInput] = useState('');
// Available templates for category dropdowns
const [postTemplates, setPostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
const [listTemplates, setListTemplates] = useState<Array<{ slug: string; title: string }>>([]);
// AI Assistant settings // AI Assistant settings
const [aiSystemPrompt, setAiSystemPrompt] = useState(''); const [aiSystemPrompt, setAiSystemPrompt] = useState('');
const [aiSystemPromptModified, setAiSystemPromptModified] = useState(false); const [aiSystemPromptModified, setAiSystemPromptModified] = useState(false);
@@ -221,6 +227,8 @@ export const SettingsView: React.FC = () => {
title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0 title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0
? (settings as any).title.trim() ? (settings as any).title.trim()
: category, : category,
postTemplateSlug: typeof (settings as any)?.postTemplateSlug === 'string' ? (settings as any).postTemplateSlug : undefined,
listTemplateSlug: typeof (settings as any)?.listTemplateSlug === 'string' ? (settings as any).listTemplateSlug : undefined,
}; };
} }
} }
@@ -230,6 +238,18 @@ export const SettingsView: React.FC = () => {
} }
}, [activeProject]); }, [activeProject]);
// Load available templates for category dropdowns
useEffect(() => {
if (activeProject) {
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
});
window.electronAPI?.templates.getEnabledByKind('list').then((templates) => {
setListTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
});
}
}, [activeProject]);
// Load saved credentials and categories // Load saved credentials and categories
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
@@ -771,6 +791,29 @@ export const SettingsView: React.FC = () => {
} }
}; };
const handleCategoryTemplateChange = async (
category: string,
field: 'postTemplateSlug' | 'listTemplateSlug',
value: string,
) => {
const nextCategoryMetadata: Record<string, CategoryMetadata> = {
...categoryMetadata,
[category]: {
...(categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category }),
[field]: value || undefined,
},
};
setCategoryMetadata(nextCategoryMetadata);
try {
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
} catch (error) {
console.error('Failed to update category settings:', error);
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
}
};
const renderContentSettings = () => ( const renderContentSettings = () => (
<SettingSection <SettingSection
id="settings-section-content" id="settings-section-content"
@@ -786,6 +829,8 @@ export const SettingsView: React.FC = () => {
<th>{t('settings.content.titleColumn')}</th> <th>{t('settings.content.titleColumn')}</th>
<th>{t('settings.content.renderInLists')}</th> <th>{t('settings.content.renderInLists')}</th>
<th>{t('settings.content.showTitles')}</th> <th>{t('settings.content.showTitles')}</th>
<th>{t('settings.content.postTemplateColumn')}</th>
<th>{t('settings.content.listTemplateColumn')}</th>
<th>{t('settings.content.actionsColumn')}</th> <th>{t('settings.content.actionsColumn')}</th>
</tr> </tr>
</thead> </thead>
@@ -823,6 +868,30 @@ export const SettingsView: React.FC = () => {
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)} onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
/> />
</td> </td>
<td>
<select
value={metadata.postTemplateSlug || ''}
onChange={(event) => handleCategoryTemplateChange(cat, 'postTemplateSlug', event.target.value)}
aria-label={t('settings.content.postTemplateAria', { category: cat })}
>
<option value="">{t('editor.field.templateDefault')}</option>
{postTemplates.map((tpl) => (
<option key={tpl.slug} value={tpl.slug}>{tpl.title}</option>
))}
</select>
</td>
<td>
<select
value={metadata.listTemplateSlug || ''}
onChange={(event) => handleCategoryTemplateChange(cat, 'listTemplateSlug', event.target.value)}
aria-label={t('settings.content.listTemplateAria', { category: cat })}
>
<option value="">{t('editor.field.templateDefault')}</option>
{listTemplates.map((tpl) => (
<option key={tpl.slug} value={tpl.slug}>{tpl.title}</option>
))}
</select>
</td>
<td className="category-actions-cell"> <td className="category-actions-cell">
{!isProtected && ( {!isProtected && (
<button <button
@@ -1213,6 +1282,29 @@ export const SettingsView: React.FC = () => {
</button> </button>
</SettingRow> </SettingRow>
<SettingRow
id="rebuild-templates"
label={t('settings.data.rebuildTemplatesLabel')}
description={t('settings.data.rebuildTemplatesDescription')}
>
<button
className="secondary"
onClick={async () => {
showToast.loading(t('settings.toast.rebuildTemplatesLoading'));
try {
await window.electronAPI?.templates.rebuildFromFiles();
showToast.dismiss();
showToast.success(t('settings.toast.rebuildTemplatesSuccess'));
} catch {
showToast.dismiss();
showToast.error(t('settings.toast.rebuildTemplatesFailed'));
}
}}
>
{t('settings.data.rebuildTemplatesAction')}
</button>
</SettingRow>
<SettingRow <SettingRow
id="rebuild-links" id="rebuild-links"
label={t('settings.data.rebuildLinksLabel')} label={t('settings.data.rebuildLinksLabel')}

View File

@@ -1,14 +1,14 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useAppStore, PostData, MediaData } from '../../store'; import { useAppStore, PostData, MediaData } from '../../store';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils'; import { BDS_EVENT_SCRIPTS_CHANGED, BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils';
import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
import { GitSidebar } from '../GitSidebar/GitSidebar'; import { GitSidebar } from '../GitSidebar/GitSidebar';
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
import { scrollToTagsSection, TagsCategory } from '../TagsView'; import { scrollToTagsSection, TagsCategory } from '../TagsView';
import { activateSidebarSection } from '../../navigation/sectionActivation'; import { activateSidebarSection } from '../../navigation/sectionActivation';
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence'; import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
import { openChatTab, openEntityTab, openImportTab, openScriptTab, openSingletonToolTab } from '../../navigation/tabPolicy'; import { openChatTab, openEntityTab, openImportTab, openScriptTab, openTemplateTab, openSingletonToolTab } from '../../navigation/tabPolicy';
import { createAndFocusPost } from '../../navigation/postCreation'; import { createAndFocusPost } from '../../navigation/postCreation';
import type { SidebarView } from '../../navigation/sidebarViewRegistry'; import type { SidebarView } from '../../navigation/sidebarViewRegistry';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
@@ -1702,6 +1702,120 @@ const ScriptsList: React.FC = () => {
); );
}; };
const TemplatesList: React.FC = () => {
const { t, language } = useI18n();
const { openTab, activeTabId, closeTab } = useAppStore();
const activeProjectId = useAppStore((state) => state.activeProject?.id);
const loadTemplates = useCallback(async (): Promise<Array<{ id: string; title: string; updatedAt: string }>> => {
const items = await window.electronAPI?.templates.getAll();
return (items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt }));
}, []);
const {
items: templates,
setItems: setTemplates,
isLoading,
reload: reloadTemplates,
} = useProjectScopedSidebarData<Array<{ id: string; title: string; updatedAt: string }>[number]>({
load: loadTemplates,
activeProjectId,
refreshEventName: BDS_EVENT_TEMPLATES_CHANGED,
});
const handleCreateTemplate = async () => {
try {
const created = await window.electronAPI?.templates.create({
title: t('sidebar.templates.newTemplate'),
kind: 'post',
content: '',
enabled: true,
});
if (!created) {
return;
}
setTemplates((prev) => [
{ id: created.id, title: created.title, updatedAt: created.updatedAt },
...prev.filter((tmpl) => tmpl.id !== created.id),
]);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
openTemplateTab(openTab, created.id, 'pin');
void reloadTemplates();
} catch (error) {
console.error('Failed to create template:', error);
showToast.error(t('sidebar.templates.createFailed'));
}
};
const handleDeleteTemplate = async (event: React.MouseEvent, templateId: string) => {
event.stopPropagation();
try {
const deleted = await window.electronAPI?.templates.delete(templateId);
if (!deleted) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
setTemplates((prev) => prev.filter((tmpl) => tmpl.id !== templateId));
closeTab(templateId);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
} catch (error) {
console.error('Failed to delete template:', error);
showToast.error(t('sidebar.templates.deleteFailed'));
}
};
return (
<SidebarEntityList
header={t('sidebar.templates.header')}
createTitle={t('sidebar.templates.newTemplate')}
onCreate={handleCreateTemplate}
isLoading={isLoading}
loadingLabel={t('sidebar.loading')}
emptyMessage={t('sidebar.templates.none')}
emptyActionLabel={t('sidebar.templates.createTemplate')}
onEmptyAction={handleCreateTemplate}
items={templates}
getItemKey={(tmpl) => tmpl.id}
renderItem={(tmpl) => (
<div
role="button"
tabIndex={0}
aria-label={tmpl.title}
className={`chat-list-item ${activeTabId === tmpl.id ? 'active' : ''}`}
onClick={() => openTemplateTab(openTab, tmpl.id, 'preview')}
onDoubleClick={() => openTemplateTab(openTab, tmpl.id, 'pin')}
onKeyDown={(event) => {
if (event.key === 'Enter') {
openTemplateTab(openTab, tmpl.id, 'pin');
return;
}
if (event.key === ' ') {
event.preventDefault();
openTemplateTab(openTab, tmpl.id, 'preview');
}
}}
>
<div className="chat-item-content">
<div className="chat-item-title">{tmpl.title}</div>
<div className="chat-item-date">
{formatSidebarRelativeDate({ dateString: tmpl.updatedAt, language, t })}
</div>
</div>
<button
className="chat-item-delete"
onClick={(event) => handleDeleteTemplate(event, tmpl.id)}
title={t('sidebar.templates.deleteTemplate')}
>
×
</button>
</div>
)}
/>
);
};
export const Sidebar: React.FC = () => { export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore(); const { activeView, sidebarVisible } = useAppStore();
@@ -1714,6 +1828,7 @@ export const Sidebar: React.FC = () => {
pages: <PostsList mode="pages" isActive={true} />, pages: <PostsList mode="pages" isActive={true} />,
media: <MediaList />, media: <MediaList />,
scripts: <ScriptsList />, scripts: <ScriptsList />,
templates: <TemplatesList />,
settings: <SettingsNav />, settings: <SettingsNav />,
tags: <TagsNav />, tags: <TagsNav />,
chat: <ChatList />, chat: <ChatList />,

View File

@@ -18,6 +18,7 @@ interface TagData {
projectId: string; projectId: string;
name: string; name: string;
color?: string; color?: string;
postTemplateSlug?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -147,6 +148,8 @@ export const TagsView: React.FC = () => {
const [editingTagId, setEditingTagId] = useState<string | null>(null); const [editingTagId, setEditingTagId] = useState<string | null>(null);
const [editTagColor, setEditTagColor] = useState<string>(''); const [editTagColor, setEditTagColor] = useState<string>('');
const [editTagName, setEditTagName] = useState(''); const [editTagName, setEditTagName] = useState('');
const [editTagTemplate, setEditTagTemplate] = useState<string>('');
const [postTemplates, setPostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
// Merge tags state // Merge tags state
const [mergeTargetName, setMergeTargetName] = useState(''); const [mergeTargetName, setMergeTargetName] = useState('');
@@ -188,6 +191,13 @@ export const TagsView: React.FC = () => {
}); });
}, [loadTags]); }, [loadTags]);
// Load post templates on mount
useEffect(() => {
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
});
}, []);
// Handle tag selection // Handle tag selection
const handleTagSelect = (name: string) => { const handleTagSelect = (name: string) => {
setSelectedTags(prev => { setSelectedTags(prev => {
@@ -247,6 +257,7 @@ export const TagsView: React.FC = () => {
setEditingTagId(tag.id); setEditingTagId(tag.id);
setEditTagColor(tag.color || ''); setEditTagColor(tag.color || '');
setEditTagName(tag.name); setEditTagName(tag.name);
setEditTagTemplate(tag.postTemplateSlug || '');
}; };
// Save tag edit // Save tag edit
@@ -254,9 +265,10 @@ export const TagsView: React.FC = () => {
if (!editingTagId) return; if (!editingTagId) return;
try { try {
// Update color // Update color and template
await window.electronAPI?.tags.update(editingTagId, { await window.electronAPI?.tags.update(editingTagId, {
color: editTagColor || null, color: editTagColor || null,
postTemplateSlug: editTagTemplate || null,
}); });
// If name changed, rename the tag // If name changed, rename the tag
@@ -455,6 +467,7 @@ export const TagsView: React.FC = () => {
<div className="tag-edit-form"> <div className="tag-edit-form">
<h4>{t('tagsView.edit.title', { name: selectedTagObjects[0].name })}</h4> <h4>{t('tagsView.edit.title', { name: selectedTagObjects[0].name })}</h4>
{editingTagId === selectedTagObjects[0].id ? ( {editingTagId === selectedTagObjects[0].id ? (
<>
<div className="tag-form-row"> <div className="tag-form-row">
<input <input
type="text" type="text"
@@ -469,8 +482,8 @@ export const TagsView: React.FC = () => {
onChange={(e) => setEditTagColor(e.target.value)} onChange={(e) => setEditTagColor(e.target.value)}
/> />
{editTagColor && ( {editTagColor && (
<button <button
className="clear-color" className="clear-color"
onClick={() => setEditTagColor('')} onClick={() => setEditTagColor('')}
title={t('tagsView.removeColor')} title={t('tagsView.removeColor')}
> >
@@ -481,6 +494,14 @@ export const TagsView: React.FC = () => {
<button onClick={handleSaveEdit} className="primary">{t('common.save')}</button> <button onClick={handleSaveEdit} className="primary">{t('common.save')}</button>
<button onClick={() => setEditingTagId(null)}>{t('common.cancel')}</button> <button onClick={() => setEditingTagId(null)}>{t('common.cancel')}</button>
</div> </div>
<div className="tagsview-field">
<label>{t('tagsView.edit.postTemplate')}</label>
<select value={editTagTemplate} onChange={(e) => setEditTagTemplate(e.target.value)}>
<option value="">{t('editor.field.templateDefault')}</option>
{postTemplates.map(tmpl => <option key={tmpl.slug} value={tmpl.slug}>{tmpl.title}</option>)}
</select>
</div>
</>
) : ( ) : (
<div className="tag-form-row"> <div className="tag-form-row">
<span className="tag-preview" style={ <span className="tag-preview" style={

View File

@@ -0,0 +1,54 @@
.templates-view-shell {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.templates-view {
flex: 1;
min-height: 0;
}
.templates-meta-row {
margin-bottom: 8px;
}
.templates-enabled-field {
align-self: flex-end;
}
.templates-enabled-field label {
display: flex;
align-items: center;
gap: 8px;
}
.templates-editor {
flex: 1;
min-height: 0;
}
.templates-toolbar {
margin-bottom: 8px;
}
.templates-monaco {
flex: 1;
min-height: 0;
border-radius: 4px;
overflow: hidden;
background-color: var(--vscode-input-background);
}
.templates-save-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}
.templates-validate-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}

View File

@@ -0,0 +1,360 @@
import React, { useEffect, useState } from 'react';
import MonacoEditor from '@monaco-editor/react';
import type { TemplateData, TemplateKind } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store';
import { BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent } from '../../utils';
import { useI18n } from '../../i18n';
import { showToast } from '../Toast';
import './TemplatesView.css';
const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
it: 'it-IT',
es: 'es-ES',
};
const toTemplateSlug = (value: string) => {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'template';
};
interface TemplatesViewProps {
templateId: string | null;
}
export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
const { t, language } = useI18n();
const closeTab = useAppStore((state) => state.closeTab);
const [template, setTemplate] = useState<TemplateData | null>(null);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [kind, setKind] = useState<TemplateKind>('post');
const [enabled, setEnabled] = useState(true);
const [templateContent, setTemplateContent] = useState('');
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [monacoResetToken, setMonacoResetToken] = useState(0);
useEffect(() => {
let cancelled = false;
const loadTemplate = async () => {
if (!templateId) {
setTemplate(null);
setTitle('');
setSlug('');
setKind('post');
setEnabled(true);
setTemplateContent('');
setMonacoResetToken((prev) => prev + 1);
setIsSlugManuallyEdited(false);
return;
}
const item = await window.electronAPI?.templates.get(templateId);
if (cancelled || !item) {
setTemplate(null);
setTitle('');
setSlug('');
setKind('post');
setEnabled(true);
setTemplateContent('');
setMonacoResetToken((prev) => prev + 1);
setIsSlugManuallyEdited(false);
return;
}
setTemplate(item);
setTitle(item.title || '');
setSlug(item.slug || toTemplateSlug(item.title || ''));
setKind(item.kind || 'post');
setEnabled(item.enabled ?? true);
setTemplateContent(item.content || '');
setMonacoResetToken((prev) => prev + 1);
const normalizedExisting = toTemplateSlug(item.slug || item.title || '');
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(item.title || ''));
};
void loadTemplate();
return () => {
cancelled = true;
};
}, [templateId]);
const hasChanges =
!!template &&
(title !== template.title ||
slug !== template.slug ||
kind !== template.kind ||
enabled !== template.enabled ||
templateContent !== template.content);
const handleTitleChange = (nextTitle: string) => {
setTitle(nextTitle);
if (!isSlugManuallyEdited) {
setSlug(toTemplateSlug(nextTitle));
}
};
const handleSlugChange = (nextSlug: string) => {
setIsSlugManuallyEdited(true);
setSlug(toTemplateSlug(nextSlug));
};
const handleValidate = async (options: { notify: boolean } = { notify: true }): Promise<boolean> => {
if (!template || isValidating) {
return false;
}
setIsValidating(true);
try {
const result = await window.electronAPI?.templates.validate(templateContent);
if (!result) {
return false;
}
if (!result.valid) {
if (options.notify) {
showToast.error(t('templates.validate.invalid', { count: result.errors.length }));
}
return false;
}
if (options.notify) {
showToast.success(t('templates.validate.valid'));
}
return true;
} catch {
return false;
} finally {
setIsValidating(false);
}
};
const handleSaveTemplate = async () => {
if (!template || isSaving || !hasChanges) {
return;
}
setIsSaving(true);
try {
const isValid = await handleValidate({ notify: true });
if (!isValid) {
return;
}
const updated = await window.electronAPI?.templates.update(template.id, {
title,
slug,
kind,
enabled,
content: templateContent,
});
if (!updated) {
return;
}
setTemplate(updated);
setTitle(updated.title || '');
setSlug(updated.slug || toTemplateSlug(updated.title || ''));
setKind(updated.kind || 'post');
setEnabled(updated.enabled ?? true);
setTemplateContent(updated.content || '');
const normalizedExisting = toTemplateSlug(updated.slug || updated.title || '');
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(updated.title || ''));
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
} finally {
setIsSaving(false);
}
};
const handleDeleteTemplate = async () => {
if (!template) {
return;
}
try {
const deleted = await window.electronAPI?.templates.delete(template.id);
if (!deleted) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
closeTab(template.id);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
} catch (error) {
console.error('Failed to delete template:', error);
showToast.error(t('sidebar.templates.deleteFailed'));
}
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
void handleSaveTemplate();
}
};
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
return;
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSaveTemplate]);
return (
<div className="templates-view-shell">
<div className="editor-header templates-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{title || t('editor.untitled')}</span>
</div>
</div>
<div className="editor-actions">
<button
type="button"
className="templates-save-button"
onClick={handleSaveTemplate}
disabled={!template || !hasChanges || isSaving}
>
{isSaving ? t('editor.saving') : t('templates.save')}
</button>
<button
type="button"
className="templates-validate-button"
onClick={() => {
void handleValidate({ notify: true });
}}
disabled={!template || isValidating || isSaving}
>
{isValidating ? t('templates.validate.checking') : t('templates.validate')}
</button>
<button
type="button"
className="secondary danger"
onClick={handleDeleteTemplate}
disabled={!template}
title={t('templates.delete')}
>
{t('templates.delete')}
</button>
</div>
</div>
<div className="editor-content templates-view">
<div className="editor-header-row templates-meta-row">
<div className="editor-meta">
<div className="editor-field-row">
<div className="editor-field">
<label htmlFor="template-title">{t('editor.field.title')}</label>
<input
id="template-title"
type="text"
value={title}
onChange={(event) => handleTitleChange(event.target.value)}
disabled={!template}
/>
</div>
<div className="editor-field">
<label htmlFor="template-slug">{t('editor.field.slug')}</label>
<input
id="template-slug"
type="text"
value={slug}
onChange={(event) => handleSlugChange(event.target.value)}
disabled={!template}
/>
</div>
</div>
<div className="editor-field-row">
<div className="editor-field">
<label htmlFor="template-kind">{t('templates.field.kind')}</label>
<select
id="template-kind"
value={kind}
onChange={(event) => setKind(event.target.value as TemplateKind)}
disabled={!template}
>
<option value="post">{t('templates.kind.post')}</option>
<option value="list">{t('templates.kind.list')}</option>
<option value="not-found">{t('templates.kind.not_found')}</option>
<option value="partial">{t('templates.kind.partial')}</option>
</select>
</div>
<div className="editor-field templates-enabled-field">
<label htmlFor="template-enabled">
<input
id="template-enabled"
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
disabled={!template}
/>
{t('templates.field.enabled')}
</label>
</div>
</div>
</div>
</div>
<div className="editor-body templates-editor">
<div className="editor-toolbar templates-toolbar">
<div className="editor-toolbar-left">
<label>{t('templates.content')}</label>
</div>
<div className="editor-toolbar-center" />
<div className="editor-toolbar-right" />
</div>
<div className="templates-monaco">
<MonacoEditor
key={monacoResetToken}
height="100%"
language="html"
theme="vs-dark"
defaultValue={templateContent}
onChange={(value) => setTemplateContent(value || '')}
options={{
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
automaticLayout: true,
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
formatOnPaste: true,
cursorStyle: 'line',
cursorBlinking: 'smooth',
readOnly: !template,
}}
/>
</div>
</div>
{template && (
<div className="editor-footer">
<span className="text-muted text-small">
{t('editor.footer.created')}:{' '}
{new Date(template.createdAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
</span>
<span className="text-muted text-small">
{t('editor.footer.updated')}:{' '}
{new Date(template.updatedAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -11,6 +11,7 @@
"activity.media": "Medien", "activity.media": "Medien",
"activity.scripts": "Skripte", "activity.scripts": "Skripte",
"activity.tags": "Schlagwörter", "activity.tags": "Schlagwörter",
"activity.templates": "Vorlagen",
"activity.aiAssistant": "KI-Assistent", "activity.aiAssistant": "KI-Assistent",
"activity.import": "Importieren", "activity.import": "Importieren",
"activity.sourceControl": "Versionskontrolle", "activity.sourceControl": "Versionskontrolle",
@@ -179,6 +180,9 @@
"settings.toast.rebuildScriptsLoading": "Skriptdatenbank wird neu aufgebaut...", "settings.toast.rebuildScriptsLoading": "Skriptdatenbank wird neu aufgebaut...",
"settings.toast.rebuildScriptsSuccess": "Skriptdatenbank neu aufgebaut", "settings.toast.rebuildScriptsSuccess": "Skriptdatenbank neu aufgebaut",
"settings.toast.rebuildScriptsFailed": "Skriptdatenbank konnte nicht neu aufgebaut werden", "settings.toast.rebuildScriptsFailed": "Skriptdatenbank konnte nicht neu aufgebaut werden",
"settings.toast.rebuildTemplatesLoading": "Vorlagen-Datenbank wird neu aufgebaut...",
"settings.toast.rebuildTemplatesSuccess": "Vorlagen-Datenbank wurde neu aufgebaut",
"settings.toast.rebuildTemplatesFailed": "Fehler beim Neuaufbau der Vorlagen-Datenbank",
"settings.toast.rebuildLinksLoading": "Beitragslinks werden neu aufgebaut...", "settings.toast.rebuildLinksLoading": "Beitragslinks werden neu aufgebaut...",
"settings.toast.rebuildLinksSuccess": "Beitragslinks neu aufgebaut", "settings.toast.rebuildLinksSuccess": "Beitragslinks neu aufgebaut",
"settings.toast.rebuildLinksFailed": "Beitragslinks konnten nicht neu aufgebaut werden", "settings.toast.rebuildLinksFailed": "Beitragslinks konnten nicht neu aufgebaut werden",
@@ -451,6 +455,19 @@
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",
"templates.save": "Vorlage speichern",
"templates.delete": "Vorlage löschen",
"templates.content": "Vorlageninhalt",
"templates.field.kind": "Art",
"templates.field.enabled": "Aktiviert",
"templates.validate": "Validieren",
"templates.validate.valid": "Vorlagensyntax ist gültig",
"templates.validate.invalid": "Vorlagensyntaxfehler: {count}",
"templates.validate.checking": "Wird validiert...",
"templates.kind.post": "Beitrag",
"templates.kind.list": "Liste",
"templates.kind.not_found": "Nicht gefunden",
"templates.kind.partial": "Partial",
"sidebar.tagCloud": "Tag-Wolke", "sidebar.tagCloud": "Tag-Wolke",
"sidebar.createEdit": "Erstellen & Bearbeiten", "sidebar.createEdit": "Erstellen & Bearbeiten",
"sidebar.mergeTags": "Tags zusammenführen", "sidebar.mergeTags": "Tags zusammenführen",
@@ -497,6 +514,8 @@
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.categories": "Kategorien", "editor.field.categories": "Kategorien",
"editor.field.content": "Inhalt", "editor.field.content": "Inhalt",
"editor.field.template": "Vorlage",
"editor.field.templateDefault": "Standard",
"editor.placeholder.tags": "Tags hinzufügen...", "editor.placeholder.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname", "editor.placeholder.author": "Autorenname",
"editor.placeholder.categories": "Kategorien hinzufügen...", "editor.placeholder.categories": "Kategorien hinzufügen...",
@@ -587,6 +606,7 @@
"tagsView.removeColor": "Farbe entfernen", "tagsView.removeColor": "Farbe entfernen",
"tagsView.edit.title": "Tag bearbeiten: {name}", "tagsView.edit.title": "Tag bearbeiten: {name}",
"tagsView.edit.action": "Bearbeiten", "tagsView.edit.action": "Bearbeiten",
"tagsView.edit.postTemplate": "Beitragsvorlage",
"tagsView.deleteAction": "Löschen", "tagsView.deleteAction": "Löschen",
"tagsView.merge.title": "Tags zusammenführen", "tagsView.merge.title": "Tags zusammenführen",
"tagsView.merge.description": "Wähle oben mehrere Tags aus und führe sie zu einem einzigen zusammen. Alle Beiträge werden aktualisiert.", "tagsView.merge.description": "Wähle oben mehrere Tags aus und führe sie zu einem einzigen zusammen. Alle Beiträge werden aktualisiert.",
@@ -683,6 +703,10 @@
"settings.content.categoryColumn": "Kategorie", "settings.content.categoryColumn": "Kategorie",
"settings.content.titleColumn": "Titel", "settings.content.titleColumn": "Titel",
"settings.content.actionsColumn": "Aktionen", "settings.content.actionsColumn": "Aktionen",
"settings.content.postTemplateColumn": "Beitragsvorlage",
"settings.content.listTemplateColumn": "Listenvorlage",
"settings.content.postTemplateAria": "{category} Beitragsvorlage",
"settings.content.listTemplateAria": "{category} Listenvorlage",
"settings.content.renderInListsAria": "{category} in Listen anzeigen", "settings.content.renderInListsAria": "{category} in Listen anzeigen",
"settings.content.showTitlesAria": "{category} Titel anzeigen", "settings.content.showTitlesAria": "{category} Titel anzeigen",
"settings.content.categoryTitleAria": "{category} Anzeigename", "settings.content.categoryTitleAria": "{category} Anzeigename",
@@ -718,6 +742,9 @@
"settings.data.rebuildScriptsLabel": "Skriptdatenbank neu aufbauen", "settings.data.rebuildScriptsLabel": "Skriptdatenbank neu aufbauen",
"settings.data.rebuildScriptsDescription": "Alle Python-Skripte neu scannen und den Skript-Metadatenindex neu aufbauen.", "settings.data.rebuildScriptsDescription": "Alle Python-Skripte neu scannen und den Skript-Metadatenindex neu aufbauen.",
"settings.data.rebuildScriptsAction": "Skripte neu aufbauen", "settings.data.rebuildScriptsAction": "Skripte neu aufbauen",
"settings.data.rebuildTemplatesLabel": "Vorlagen-Datenbank neu aufbauen",
"settings.data.rebuildTemplatesDescription": "Alle Liquid-Vorlagen neu scannen und den Vorlagen-Metadaten-Index neu aufbauen.",
"settings.data.rebuildTemplatesAction": "Vorlagen neu aufbauen",
"settings.data.rebuildLinksLabel": "Beitragslinks neu aufbauen", "settings.data.rebuildLinksLabel": "Beitragslinks neu aufbauen",
"settings.data.rebuildLinksDescription": "Alle Beiträge neu scannen und den internen Linkgraphen zwischen Beiträgen neu aufbauen.", "settings.data.rebuildLinksDescription": "Alle Beiträge neu scannen und den internen Linkgraphen zwischen Beiträgen neu aufbauen.",
"settings.data.rebuildLinksAction": "Links neu aufbauen", "settings.data.rebuildLinksAction": "Links neu aufbauen",
@@ -746,6 +773,13 @@
"sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden", "sidebar.scripts.createFailed": "Skript konnte nicht erstellt werden",
"sidebar.scripts.deleteScript": "Skript löschen", "sidebar.scripts.deleteScript": "Skript löschen",
"sidebar.scripts.deleteFailed": "Skript konnte nicht gelöscht werden", "sidebar.scripts.deleteFailed": "Skript konnte nicht gelöscht werden",
"sidebar.templates.header": "VORLAGEN",
"sidebar.templates.newTemplate": "Neue Vorlage",
"sidebar.templates.none": "Noch keine Vorlagen",
"sidebar.templates.createTemplate": "Vorlage erstellen",
"sidebar.templates.createFailed": "Vorlage konnte nicht erstellt werden",
"sidebar.templates.deleteTemplate": "Vorlage löschen",
"sidebar.templates.deleteFailed": "Vorlage konnte nicht gelöscht werden",
"sidebar.import.none": "Noch keine Importdefinitionen", "sidebar.import.none": "Noch keine Importdefinitionen",
"sidebar.import.createDefinition": "Eine Importdefinition erstellen", "sidebar.import.createDefinition": "Eine Importdefinition erstellen",
"sidebar.import.deleteDefinition": "Importdefinition löschen", "sidebar.import.deleteDefinition": "Importdefinition löschen",

View File

@@ -11,6 +11,7 @@
"activity.media": "Media", "activity.media": "Media",
"activity.scripts": "Scripts", "activity.scripts": "Scripts",
"activity.tags": "Tags", "activity.tags": "Tags",
"activity.templates": "Templates",
"activity.aiAssistant": "AI Assistant", "activity.aiAssistant": "AI Assistant",
"activity.import": "Import", "activity.import": "Import",
"activity.sourceControl": "Source Control", "activity.sourceControl": "Source Control",
@@ -179,6 +180,9 @@
"settings.toast.rebuildScriptsLoading": "Rebuilding scripts database...", "settings.toast.rebuildScriptsLoading": "Rebuilding scripts database...",
"settings.toast.rebuildScriptsSuccess": "Scripts database rebuilt", "settings.toast.rebuildScriptsSuccess": "Scripts database rebuilt",
"settings.toast.rebuildScriptsFailed": "Failed to rebuild scripts database", "settings.toast.rebuildScriptsFailed": "Failed to rebuild scripts database",
"settings.toast.rebuildTemplatesLoading": "Rebuilding templates database...",
"settings.toast.rebuildTemplatesSuccess": "Templates database rebuilt",
"settings.toast.rebuildTemplatesFailed": "Failed to rebuild templates database",
"settings.toast.rebuildLinksLoading": "Rebuilding post links...", "settings.toast.rebuildLinksLoading": "Rebuilding post links...",
"settings.toast.rebuildLinksSuccess": "Post links rebuilt", "settings.toast.rebuildLinksSuccess": "Post links rebuilt",
"settings.toast.rebuildLinksFailed": "Failed to rebuild post links", "settings.toast.rebuildLinksFailed": "Failed to rebuild post links",
@@ -451,6 +455,19 @@
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",
"templates.save": "Save Template",
"templates.delete": "Delete Template",
"templates.content": "Template Content",
"templates.field.kind": "Kind",
"templates.field.enabled": "Enabled",
"templates.validate": "Validate",
"templates.validate.valid": "Template syntax is valid",
"templates.validate.invalid": "Template syntax errors: {count}",
"templates.validate.checking": "Validating...",
"templates.kind.post": "post",
"templates.kind.list": "list",
"templates.kind.not_found": "not found",
"templates.kind.partial": "partial",
"sidebar.tagCloud": "Tag Cloud", "sidebar.tagCloud": "Tag Cloud",
"sidebar.createEdit": "Create & Edit", "sidebar.createEdit": "Create & Edit",
"sidebar.mergeTags": "Merge Tags", "sidebar.mergeTags": "Merge Tags",
@@ -497,6 +514,8 @@
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.categories": "Categories", "editor.field.categories": "Categories",
"editor.field.content": "Content", "editor.field.content": "Content",
"editor.field.template": "Template",
"editor.field.templateDefault": "Default",
"editor.placeholder.tags": "Add tags...", "editor.placeholder.tags": "Add tags...",
"editor.placeholder.author": "Author name", "editor.placeholder.author": "Author name",
"editor.placeholder.categories": "Add categories...", "editor.placeholder.categories": "Add categories...",
@@ -587,6 +606,7 @@
"tagsView.removeColor": "Remove color", "tagsView.removeColor": "Remove color",
"tagsView.edit.title": "Edit Tag: {name}", "tagsView.edit.title": "Edit Tag: {name}",
"tagsView.edit.action": "Edit", "tagsView.edit.action": "Edit",
"tagsView.edit.postTemplate": "Post Template",
"tagsView.deleteAction": "Delete", "tagsView.deleteAction": "Delete",
"tagsView.merge.title": "Merge Tags", "tagsView.merge.title": "Merge Tags",
"tagsView.merge.description": "Select multiple tags above, then merge them into a single tag. All posts will be updated.", "tagsView.merge.description": "Select multiple tags above, then merge them into a single tag. All posts will be updated.",
@@ -683,6 +703,10 @@
"settings.content.categoryColumn": "Category", "settings.content.categoryColumn": "Category",
"settings.content.titleColumn": "Title", "settings.content.titleColumn": "Title",
"settings.content.actionsColumn": "Actions", "settings.content.actionsColumn": "Actions",
"settings.content.postTemplateColumn": "Post Template",
"settings.content.listTemplateColumn": "List Template",
"settings.content.postTemplateAria": "{category} post template",
"settings.content.listTemplateAria": "{category} list template",
"settings.content.renderInListsAria": "{category} render in lists", "settings.content.renderInListsAria": "{category} render in lists",
"settings.content.showTitlesAria": "{category} show titles", "settings.content.showTitlesAria": "{category} show titles",
"settings.content.categoryTitleAria": "{category} display title", "settings.content.categoryTitleAria": "{category} display title",
@@ -718,6 +742,9 @@
"settings.data.rebuildScriptsLabel": "Rebuild Scripts Database", "settings.data.rebuildScriptsLabel": "Rebuild Scripts Database",
"settings.data.rebuildScriptsDescription": "Re-scan all Python scripts and rebuild the scripts metadata index.", "settings.data.rebuildScriptsDescription": "Re-scan all Python scripts and rebuild the scripts metadata index.",
"settings.data.rebuildScriptsAction": "Rebuild Scripts", "settings.data.rebuildScriptsAction": "Rebuild Scripts",
"settings.data.rebuildTemplatesLabel": "Rebuild Templates Database",
"settings.data.rebuildTemplatesDescription": "Re-scan all Liquid templates and rebuild the templates metadata index.",
"settings.data.rebuildTemplatesAction": "Rebuild Templates",
"settings.data.rebuildLinksLabel": "Rebuild Post Links", "settings.data.rebuildLinksLabel": "Rebuild Post Links",
"settings.data.rebuildLinksDescription": "Re-scan all posts and rebuild the internal link graph between posts.", "settings.data.rebuildLinksDescription": "Re-scan all posts and rebuild the internal link graph between posts.",
"settings.data.rebuildLinksAction": "Rebuild Links", "settings.data.rebuildLinksAction": "Rebuild Links",
@@ -746,6 +773,13 @@
"sidebar.scripts.createFailed": "Failed to create script", "sidebar.scripts.createFailed": "Failed to create script",
"sidebar.scripts.deleteScript": "Delete script", "sidebar.scripts.deleteScript": "Delete script",
"sidebar.scripts.deleteFailed": "Failed to delete script", "sidebar.scripts.deleteFailed": "Failed to delete script",
"sidebar.templates.header": "TEMPLATES",
"sidebar.templates.newTemplate": "New Template",
"sidebar.templates.none": "No templates yet",
"sidebar.templates.createTemplate": "Create a template",
"sidebar.templates.createFailed": "Failed to create template",
"sidebar.templates.deleteTemplate": "Delete template",
"sidebar.templates.deleteFailed": "Failed to delete template",
"sidebar.import.none": "No import definitions yet", "sidebar.import.none": "No import definitions yet",
"sidebar.import.createDefinition": "Create an import definition", "sidebar.import.createDefinition": "Create an import definition",
"sidebar.import.deleteDefinition": "Delete import definition", "sidebar.import.deleteDefinition": "Delete import definition",

View File

@@ -11,6 +11,7 @@
"activity.media": "Medios", "activity.media": "Medios",
"activity.scripts": "Scripts", "activity.scripts": "Scripts",
"activity.tags": "Etiquetas", "activity.tags": "Etiquetas",
"activity.templates": "Plantillas",
"activity.aiAssistant": "Asistente IA", "activity.aiAssistant": "Asistente IA",
"activity.import": "Importar", "activity.import": "Importar",
"activity.sourceControl": "Control de código fuente", "activity.sourceControl": "Control de código fuente",
@@ -179,6 +180,9 @@
"settings.toast.rebuildScriptsLoading": "Reconstruyendo base de datos de scripts...", "settings.toast.rebuildScriptsLoading": "Reconstruyendo base de datos de scripts...",
"settings.toast.rebuildScriptsSuccess": "Base de datos de scripts reconstruida", "settings.toast.rebuildScriptsSuccess": "Base de datos de scripts reconstruida",
"settings.toast.rebuildScriptsFailed": "No se pudo reconstruir la base de datos de scripts", "settings.toast.rebuildScriptsFailed": "No se pudo reconstruir la base de datos de scripts",
"settings.toast.rebuildTemplatesLoading": "Reconstruyendo la base de datos de plantillas...",
"settings.toast.rebuildTemplatesSuccess": "Base de datos de plantillas reconstruida",
"settings.toast.rebuildTemplatesFailed": "Error al reconstruir la base de datos de plantillas",
"settings.toast.rebuildLinksLoading": "Reconstruyendo enlaces de entradas...", "settings.toast.rebuildLinksLoading": "Reconstruyendo enlaces de entradas...",
"settings.toast.rebuildLinksSuccess": "Enlaces de publicaciones reconstruidos", "settings.toast.rebuildLinksSuccess": "Enlaces de publicaciones reconstruidos",
"settings.toast.rebuildLinksFailed": "No se pudieron reconstruir los enlaces de entradas", "settings.toast.rebuildLinksFailed": "No se pudieron reconstruir los enlaces de entradas",
@@ -451,6 +455,19 @@
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",
"templates.save": "Guardar plantilla",
"templates.delete": "Eliminar plantilla",
"templates.content": "Contenido de la plantilla",
"templates.field.kind": "Tipo",
"templates.field.enabled": "Habilitado",
"templates.validate": "Validar",
"templates.validate.valid": "La sintaxis de la plantilla es válida",
"templates.validate.invalid": "Errores de sintaxis de la plantilla: {count}",
"templates.validate.checking": "Validando...",
"templates.kind.post": "entrada",
"templates.kind.list": "lista",
"templates.kind.not_found": "no encontrado",
"templates.kind.partial": "parcial",
"sidebar.tagCloud": "Nube de etiquetas", "sidebar.tagCloud": "Nube de etiquetas",
"sidebar.createEdit": "Crear y editar", "sidebar.createEdit": "Crear y editar",
"sidebar.mergeTags": "Combinar etiquetas", "sidebar.mergeTags": "Combinar etiquetas",
@@ -497,6 +514,8 @@
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.categories": "Categorías", "editor.field.categories": "Categorías",
"editor.field.content": "Contenido", "editor.field.content": "Contenido",
"editor.field.template": "Plantilla",
"editor.field.templateDefault": "Predeterminada",
"editor.placeholder.tags": "Agregar etiquetas...", "editor.placeholder.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor", "editor.placeholder.author": "Nombre del autor",
"editor.placeholder.categories": "Agregar categorías...", "editor.placeholder.categories": "Agregar categorías...",
@@ -587,6 +606,7 @@
"tagsView.removeColor": "Quitar color", "tagsView.removeColor": "Quitar color",
"tagsView.edit.title": "Editar etiqueta: {name}", "tagsView.edit.title": "Editar etiqueta: {name}",
"tagsView.edit.action": "Editar", "tagsView.edit.action": "Editar",
"tagsView.edit.postTemplate": "Plantilla de entrada",
"tagsView.deleteAction": "Eliminar", "tagsView.deleteAction": "Eliminar",
"tagsView.merge.title": "Combinar etiquetas", "tagsView.merge.title": "Combinar etiquetas",
"tagsView.merge.description": "Selecciona varias etiquetas arriba y combínalas en una sola. Se actualizarán todas las entradas.", "tagsView.merge.description": "Selecciona varias etiquetas arriba y combínalas en una sola. Se actualizarán todas las entradas.",
@@ -683,6 +703,10 @@
"settings.content.categoryColumn": "Categoría", "settings.content.categoryColumn": "Categoría",
"settings.content.titleColumn": "Título", "settings.content.titleColumn": "Título",
"settings.content.actionsColumn": "Acciones", "settings.content.actionsColumn": "Acciones",
"settings.content.postTemplateColumn": "Plantilla de entrada",
"settings.content.listTemplateColumn": "Plantilla de lista",
"settings.content.postTemplateAria": "{category} plantilla de entrada",
"settings.content.listTemplateAria": "{category} plantilla de lista",
"settings.content.renderInListsAria": "{category} mostrar en listas", "settings.content.renderInListsAria": "{category} mostrar en listas",
"settings.content.showTitlesAria": "{category} mostrar títulos", "settings.content.showTitlesAria": "{category} mostrar títulos",
"settings.content.categoryTitleAria": "Título visible para {category}", "settings.content.categoryTitleAria": "Título visible para {category}",
@@ -718,6 +742,9 @@
"settings.data.rebuildScriptsLabel": "Reconstruir base de datos de scripts", "settings.data.rebuildScriptsLabel": "Reconstruir base de datos de scripts",
"settings.data.rebuildScriptsDescription": "Reescanea todos los scripts de Python y reconstruye el índice de metadatos de scripts.", "settings.data.rebuildScriptsDescription": "Reescanea todos los scripts de Python y reconstruye el índice de metadatos de scripts.",
"settings.data.rebuildScriptsAction": "Reconstruir scripts", "settings.data.rebuildScriptsAction": "Reconstruir scripts",
"settings.data.rebuildTemplatesLabel": "Reconstruir base de datos de plantillas",
"settings.data.rebuildTemplatesDescription": "Re-escanear todas las plantillas Liquid y reconstruir el índice de metadatos.",
"settings.data.rebuildTemplatesAction": "Reconstruir plantillas",
"settings.data.rebuildLinksLabel": "Reconstruir enlaces de publicaciones", "settings.data.rebuildLinksLabel": "Reconstruir enlaces de publicaciones",
"settings.data.rebuildLinksDescription": "Reescanea todas las publicaciones y reconstruye el grafo interno de enlaces entre publicaciones.", "settings.data.rebuildLinksDescription": "Reescanea todas las publicaciones y reconstruye el grafo interno de enlaces entre publicaciones.",
"settings.data.rebuildLinksAction": "Reconstruir enlaces", "settings.data.rebuildLinksAction": "Reconstruir enlaces",
@@ -746,6 +773,13 @@
"sidebar.scripts.createFailed": "No se pudo crear el script", "sidebar.scripts.createFailed": "No se pudo crear el script",
"sidebar.scripts.deleteScript": "Eliminar script", "sidebar.scripts.deleteScript": "Eliminar script",
"sidebar.scripts.deleteFailed": "No se pudo eliminar el script", "sidebar.scripts.deleteFailed": "No se pudo eliminar el script",
"sidebar.templates.header": "PLANTILLAS",
"sidebar.templates.newTemplate": "Nueva plantilla",
"sidebar.templates.none": "Aún no hay plantillas",
"sidebar.templates.createTemplate": "Crear una plantilla",
"sidebar.templates.createFailed": "No se pudo crear la plantilla",
"sidebar.templates.deleteTemplate": "Eliminar plantilla",
"sidebar.templates.deleteFailed": "No se pudo eliminar la plantilla",
"sidebar.import.none": "Sin definiciones de importación", "sidebar.import.none": "Sin definiciones de importación",
"sidebar.import.createDefinition": "Crear definición", "sidebar.import.createDefinition": "Crear definición",
"sidebar.import.deleteDefinition": "Eliminar definición", "sidebar.import.deleteDefinition": "Eliminar definición",

View File

@@ -11,6 +11,7 @@
"activity.media": "Médias", "activity.media": "Médias",
"activity.scripts": "Scripts", "activity.scripts": "Scripts",
"activity.tags": "Étiquettes", "activity.tags": "Étiquettes",
"activity.templates": "Modèles",
"activity.aiAssistant": "Assistant IA", "activity.aiAssistant": "Assistant IA",
"activity.import": "Importation", "activity.import": "Importation",
"activity.sourceControl": "Contrôle de source", "activity.sourceControl": "Contrôle de source",
@@ -177,6 +178,9 @@
"settings.toast.rebuildScriptsLoading": "Reconstruction de la base des scripts...", "settings.toast.rebuildScriptsLoading": "Reconstruction de la base des scripts...",
"settings.toast.rebuildScriptsSuccess": "Base des scripts reconstruite", "settings.toast.rebuildScriptsSuccess": "Base des scripts reconstruite",
"settings.toast.rebuildScriptsFailed": "Impossible de reconstruire la base des scripts", "settings.toast.rebuildScriptsFailed": "Impossible de reconstruire la base des scripts",
"settings.toast.rebuildTemplatesLoading": "Reconstruction de la base de données des modèles...",
"settings.toast.rebuildTemplatesSuccess": "Base de données des modèles reconstruite",
"settings.toast.rebuildTemplatesFailed": "Échec de la reconstruction de la base de données des modèles",
"settings.toast.rebuildLinksLoading": "Reconstruction des liens darticles...", "settings.toast.rebuildLinksLoading": "Reconstruction des liens darticles...",
"settings.toast.rebuildLinksSuccess": "Liens darticles reconstruits", "settings.toast.rebuildLinksSuccess": "Liens darticles reconstruits",
"settings.toast.rebuildLinksFailed": "Impossible de reconstruire les liens darticles", "settings.toast.rebuildLinksFailed": "Impossible de reconstruire les liens darticles",
@@ -449,6 +453,19 @@
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",
"templates.save": "Enregistrer le modèle",
"templates.delete": "Supprimer le modèle",
"templates.content": "Contenu du modèle",
"templates.field.kind": "Type",
"templates.field.enabled": "Activé",
"templates.validate": "Valider",
"templates.validate.valid": "La syntaxe du modèle est valide",
"templates.validate.invalid": "Erreurs de syntaxe du modèle : {count}",
"templates.validate.checking": "Validation en cours...",
"templates.kind.post": "article",
"templates.kind.list": "liste",
"templates.kind.not_found": "non trouvé",
"templates.kind.partial": "partiel",
"sidebar.tagCloud": "Nuage détiquettes", "sidebar.tagCloud": "Nuage détiquettes",
"sidebar.createEdit": "Créer & modifier", "sidebar.createEdit": "Créer & modifier",
"sidebar.mergeTags": "Fusionner les étiquettes", "sidebar.mergeTags": "Fusionner les étiquettes",
@@ -495,6 +512,8 @@
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.categories": "Catégories", "editor.field.categories": "Catégories",
"editor.field.content": "Contenu", "editor.field.content": "Contenu",
"editor.field.template": "Modèle",
"editor.field.templateDefault": "Par défaut",
"editor.placeholder.tags": "Ajouter des étiquettes...", "editor.placeholder.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de lauteur", "editor.placeholder.author": "Nom de lauteur",
"editor.placeholder.categories": "Ajouter des catégories...", "editor.placeholder.categories": "Ajouter des catégories...",
@@ -585,6 +604,7 @@
"tagsView.removeColor": "Supprimer la couleur", "tagsView.removeColor": "Supprimer la couleur",
"tagsView.edit.title": "Modifier le tag : {name}", "tagsView.edit.title": "Modifier le tag : {name}",
"tagsView.edit.action": "Modifier", "tagsView.edit.action": "Modifier",
"tagsView.edit.postTemplate": "Modèle d'article",
"tagsView.deleteAction": "Supprimer", "tagsView.deleteAction": "Supprimer",
"tagsView.merge.title": "Fusionner des tags", "tagsView.merge.title": "Fusionner des tags",
"tagsView.merge.description": "Sélectionnez plusieurs tags ci-dessus puis fusionnez-les en un seul. Tous les articles seront mis à jour.", "tagsView.merge.description": "Sélectionnez plusieurs tags ci-dessus puis fusionnez-les en un seul. Tous les articles seront mis à jour.",
@@ -681,6 +701,10 @@
"settings.content.categoryColumn": "Catégorie", "settings.content.categoryColumn": "Catégorie",
"settings.content.titleColumn": "Titre", "settings.content.titleColumn": "Titre",
"settings.content.actionsColumn": "Actions", "settings.content.actionsColumn": "Actions",
"settings.content.postTemplateColumn": "Modèle d'article",
"settings.content.listTemplateColumn": "Modèle de liste",
"settings.content.postTemplateAria": "{category} modèle d'article",
"settings.content.listTemplateAria": "{category} modèle de liste",
"settings.content.renderInListsAria": "{category} afficher dans les listes", "settings.content.renderInListsAria": "{category} afficher dans les listes",
"settings.content.showTitlesAria": "{category} afficher les titres", "settings.content.showTitlesAria": "{category} afficher les titres",
"settings.content.categoryTitleAria": "Titre affiché pour {category}", "settings.content.categoryTitleAria": "Titre affiché pour {category}",
@@ -716,6 +740,9 @@
"settings.data.rebuildScriptsLabel": "Reconstruire la base des scripts", "settings.data.rebuildScriptsLabel": "Reconstruire la base des scripts",
"settings.data.rebuildScriptsDescription": "Réanalyse tous les scripts Python et reconstruit lindex des métadonnées de scripts.", "settings.data.rebuildScriptsDescription": "Réanalyse tous les scripts Python et reconstruit lindex des métadonnées de scripts.",
"settings.data.rebuildScriptsAction": "Reconstruire les scripts", "settings.data.rebuildScriptsAction": "Reconstruire les scripts",
"settings.data.rebuildTemplatesLabel": "Reconstruire la base de données des modèles",
"settings.data.rebuildTemplatesDescription": "Re-scanner tous les modèles Liquid et reconstruire l'index des métadonnées.",
"settings.data.rebuildTemplatesAction": "Reconstruire les modèles",
"settings.data.rebuildLinksLabel": "Reconstruire les liens darticles", "settings.data.rebuildLinksLabel": "Reconstruire les liens darticles",
"settings.data.rebuildLinksDescription": "Réanalyse tous les articles et reconstruit le graphe interne des liens entre articles.", "settings.data.rebuildLinksDescription": "Réanalyse tous les articles et reconstruit le graphe interne des liens entre articles.",
"settings.data.rebuildLinksAction": "Reconstruire les liens", "settings.data.rebuildLinksAction": "Reconstruire les liens",
@@ -744,6 +771,13 @@
"sidebar.scripts.createFailed": "Impossible de créer le script", "sidebar.scripts.createFailed": "Impossible de créer le script",
"sidebar.scripts.deleteScript": "Supprimer le script", "sidebar.scripts.deleteScript": "Supprimer le script",
"sidebar.scripts.deleteFailed": "Impossible de supprimer le script", "sidebar.scripts.deleteFailed": "Impossible de supprimer le script",
"sidebar.templates.header": "MODÈLES",
"sidebar.templates.newTemplate": "Nouveau modèle",
"sidebar.templates.none": "Aucun modèle",
"sidebar.templates.createTemplate": "Créer un modèle",
"sidebar.templates.createFailed": "Impossible de créer le modèle",
"sidebar.templates.deleteTemplate": "Supprimer le modèle",
"sidebar.templates.deleteFailed": "Impossible de supprimer le modèle",
"sidebar.import.none": "Aucune définition dimport", "sidebar.import.none": "Aucune définition dimport",
"sidebar.import.createDefinition": "Créer une définition", "sidebar.import.createDefinition": "Créer une définition",
"sidebar.import.deleteDefinition": "Supprimer la définition", "sidebar.import.deleteDefinition": "Supprimer la définition",

View File

@@ -11,6 +11,7 @@
"activity.media": "Contenuti media", "activity.media": "Contenuti media",
"activity.scripts": "Script", "activity.scripts": "Script",
"activity.tags": "Tag", "activity.tags": "Tag",
"activity.templates": "Modelli",
"activity.aiAssistant": "Assistente IA", "activity.aiAssistant": "Assistente IA",
"activity.import": "Importa", "activity.import": "Importa",
"activity.sourceControl": "Controllo sorgente", "activity.sourceControl": "Controllo sorgente",
@@ -177,6 +178,9 @@
"settings.toast.rebuildScriptsLoading": "Ricostruzione database script...", "settings.toast.rebuildScriptsLoading": "Ricostruzione database script...",
"settings.toast.rebuildScriptsSuccess": "Database script ricostruito", "settings.toast.rebuildScriptsSuccess": "Database script ricostruito",
"settings.toast.rebuildScriptsFailed": "Impossibile ricostruire il database degli script", "settings.toast.rebuildScriptsFailed": "Impossibile ricostruire il database degli script",
"settings.toast.rebuildTemplatesLoading": "Ricostruzione del database dei modelli...",
"settings.toast.rebuildTemplatesSuccess": "Database dei modelli ricostruito",
"settings.toast.rebuildTemplatesFailed": "Impossibile ricostruire il database dei modelli",
"settings.toast.rebuildLinksLoading": "Ricostruzione dei link dei post...", "settings.toast.rebuildLinksLoading": "Ricostruzione dei link dei post...",
"settings.toast.rebuildLinksSuccess": "Link dei post ricostruiti", "settings.toast.rebuildLinksSuccess": "Link dei post ricostruiti",
"settings.toast.rebuildLinksFailed": "Impossibile ricostruire i link dei post", "settings.toast.rebuildLinksFailed": "Impossibile ricostruire i link dei post",
@@ -449,6 +453,19 @@
"scripts.kind.utility": "utility", "scripts.kind.utility": "utility",
"scripts.kind.macro": "macro", "scripts.kind.macro": "macro",
"scripts.kind.transform": "transform", "scripts.kind.transform": "transform",
"templates.save": "Salva modello",
"templates.delete": "Elimina modello",
"templates.content": "Contenuto del modello",
"templates.field.kind": "Tipo",
"templates.field.enabled": "Abilitato",
"templates.validate": "Valida",
"templates.validate.valid": "La sintassi del modello è valida",
"templates.validate.invalid": "Errori di sintassi del modello: {count}",
"templates.validate.checking": "Validazione in corso...",
"templates.kind.post": "articolo",
"templates.kind.list": "elenco",
"templates.kind.not_found": "non trovato",
"templates.kind.partial": "parziale",
"sidebar.tagCloud": "Nuvola tag", "sidebar.tagCloud": "Nuvola tag",
"sidebar.createEdit": "Crea e modifica", "sidebar.createEdit": "Crea e modifica",
"sidebar.mergeTags": "Unisci tag", "sidebar.mergeTags": "Unisci tag",
@@ -495,6 +512,8 @@
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.categories": "Categorie", "editor.field.categories": "Categorie",
"editor.field.content": "Contenuto", "editor.field.content": "Contenuto",
"editor.field.template": "Modello",
"editor.field.templateDefault": "Predefinito",
"editor.placeholder.tags": "Aggiungi tag...", "editor.placeholder.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore", "editor.placeholder.author": "Nome autore",
"editor.placeholder.categories": "Aggiungi categorie...", "editor.placeholder.categories": "Aggiungi categorie...",
@@ -585,6 +604,7 @@
"tagsView.removeColor": "Rimuovi colore", "tagsView.removeColor": "Rimuovi colore",
"tagsView.edit.title": "Modifica tag: {name}", "tagsView.edit.title": "Modifica tag: {name}",
"tagsView.edit.action": "Modifica", "tagsView.edit.action": "Modifica",
"tagsView.edit.postTemplate": "Modello articolo",
"tagsView.deleteAction": "Elimina", "tagsView.deleteAction": "Elimina",
"tagsView.merge.title": "Unisci tag", "tagsView.merge.title": "Unisci tag",
"tagsView.merge.description": "Seleziona più tag sopra, quindi uniscili in un unico tag. Tutti i post verranno aggiornati.", "tagsView.merge.description": "Seleziona più tag sopra, quindi uniscili in un unico tag. Tutti i post verranno aggiornati.",
@@ -681,6 +701,10 @@
"settings.content.categoryColumn": "Categoria", "settings.content.categoryColumn": "Categoria",
"settings.content.titleColumn": "Titolo", "settings.content.titleColumn": "Titolo",
"settings.content.actionsColumn": "Azioni", "settings.content.actionsColumn": "Azioni",
"settings.content.postTemplateColumn": "Modello articolo",
"settings.content.listTemplateColumn": "Modello elenco",
"settings.content.postTemplateAria": "{category} modello articolo",
"settings.content.listTemplateAria": "{category} modello elenco",
"settings.content.renderInListsAria": "{category} mostra negli elenchi", "settings.content.renderInListsAria": "{category} mostra negli elenchi",
"settings.content.showTitlesAria": "{category} mostra i titoli", "settings.content.showTitlesAria": "{category} mostra i titoli",
"settings.content.categoryTitleAria": "Titolo visualizzato per {category}", "settings.content.categoryTitleAria": "Titolo visualizzato per {category}",
@@ -716,6 +740,9 @@
"settings.data.rebuildScriptsLabel": "Ricostruisci database script", "settings.data.rebuildScriptsLabel": "Ricostruisci database script",
"settings.data.rebuildScriptsDescription": "Rianalizza tutti gli script Python e ricostruisce lindice dei metadati degli script.", "settings.data.rebuildScriptsDescription": "Rianalizza tutti gli script Python e ricostruisce lindice dei metadati degli script.",
"settings.data.rebuildScriptsAction": "Ricostruisci script", "settings.data.rebuildScriptsAction": "Ricostruisci script",
"settings.data.rebuildTemplatesLabel": "Ricostruisci database modelli",
"settings.data.rebuildTemplatesDescription": "Scansiona tutti i modelli Liquid e ricostruisci l'indice dei metadati.",
"settings.data.rebuildTemplatesAction": "Ricostruisci modelli",
"settings.data.rebuildLinksLabel": "Ricostruisci collegamenti post", "settings.data.rebuildLinksLabel": "Ricostruisci collegamenti post",
"settings.data.rebuildLinksDescription": "Rianalizza tutti i post e ricostruisce il grafo interno dei collegamenti tra post.", "settings.data.rebuildLinksDescription": "Rianalizza tutti i post e ricostruisce il grafo interno dei collegamenti tra post.",
"settings.data.rebuildLinksAction": "Ricostruisci collegamenti", "settings.data.rebuildLinksAction": "Ricostruisci collegamenti",
@@ -744,6 +771,13 @@
"sidebar.scripts.createFailed": "Impossibile creare lo script", "sidebar.scripts.createFailed": "Impossibile creare lo script",
"sidebar.scripts.deleteScript": "Elimina script", "sidebar.scripts.deleteScript": "Elimina script",
"sidebar.scripts.deleteFailed": "Impossibile eliminare lo script", "sidebar.scripts.deleteFailed": "Impossibile eliminare lo script",
"sidebar.templates.header": "MODELLI",
"sidebar.templates.newTemplate": "Nuovo modello",
"sidebar.templates.none": "Nessun modello",
"sidebar.templates.createTemplate": "Crea un modello",
"sidebar.templates.createFailed": "Impossibile creare il modello",
"sidebar.templates.deleteTemplate": "Elimina modello",
"sidebar.templates.deleteFailed": "Impossibile eliminare il modello",
"sidebar.import.none": "Nessuna definizione di importazione", "sidebar.import.none": "Nessuna definizione di importazione",
"sidebar.import.createDefinition": "Crea definizione", "sidebar.import.createDefinition": "Crea definizione",
"sidebar.import.deleteDefinition": "Elimina definizione", "sidebar.import.deleteDefinition": "Elimina definizione",

View File

@@ -1,7 +1,7 @@
import type { Tab } from '../store/appStore'; import type { Tab } from '../store/appStore';
import type { SidebarView } from './sidebarViewRegistry'; import type { SidebarView } from './sidebarViewRegistry';
export type ActivityId = 'posts' | 'pages' | 'media' | 'scripts' | 'tags' | 'chat' | 'import' | 'git' | 'settings'; export type ActivityId = 'posts' | 'pages' | 'media' | 'scripts' | 'templates' | 'tags' | 'chat' | 'import' | 'git' | 'settings';
export interface ActivitySnapshot { export interface ActivitySnapshot {
activeView: SidebarView; activeView: SidebarView;
@@ -50,6 +50,13 @@ const ACTIVITY_CONFIG: Record<ActivityId, ActivityConfig> = {
activeStrategy: 'sidebar-owner', activeStrategy: 'sidebar-owner',
clickStrategy: 'sidebar-toggle', clickStrategy: 'sidebar-toggle',
}, },
templates: {
id: 'templates',
view: 'templates',
labelKey: 'activity.templates',
activeStrategy: 'sidebar-owner',
clickStrategy: 'sidebar-toggle',
},
tags: { tags: {
id: 'tags', id: 'tags',
view: 'tags', view: 'tags',

View File

@@ -16,7 +16,8 @@ export type EditorRoute =
| 'documentation' | 'documentation'
| 'api-documentation' | 'api-documentation'
| 'site-validation' | 'site-validation'
| 'scripts'; | 'scripts'
| 'templates';
export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = { export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = {
post: 'post', post: 'post',
@@ -33,6 +34,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
'api-documentation': 'api-documentation', 'api-documentation': 'api-documentation',
'site-validation': 'site-validation', 'site-validation': 'site-validation',
scripts: 'scripts', scripts: 'scripts',
templates: 'templates',
}; };
export interface EditorRouteResolution { export interface EditorRouteResolution {

View File

@@ -3,6 +3,7 @@ export const SIDEBAR_VIEW_REGISTRY = [
'pages', 'pages',
'media', 'media',
'scripts', 'scripts',
'templates',
'settings', 'settings',
'tags', 'tags',
'chat', 'chat',

View File

@@ -20,6 +20,7 @@ export interface CanonicalTabSpec {
export type EntityTabType = 'post' | 'media'; export type EntityTabType = 'post' | 'media';
export type EntityTabOpenIntent = 'preview' | 'pin'; export type EntityTabOpenIntent = 'preview' | 'pin';
export type ScriptTabOpenIntent = 'preview' | 'pin'; export type ScriptTabOpenIntent = 'preview' | 'pin';
export type TemplateTabOpenIntent = 'preview' | 'pin';
export type GitDiffResourceOpenIntent = 'preview' | 'pin'; export type GitDiffResourceOpenIntent = 'preview' | 'pin';
const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = { const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = {
@@ -112,6 +113,22 @@ export function openScriptTab(
openTab(getScriptTabSpec(scriptId, intent)); openTab(getScriptTabSpec(scriptId, intent));
} }
export function getTemplateTabSpec(templateId: string, intent: TemplateTabOpenIntent): CanonicalTabSpec {
return {
type: 'templates',
id: templateId,
isTransient: intent === 'preview',
};
}
export function openTemplateTab(
openTab: (tab: CanonicalTabSpec) => void,
templateId: string,
intent: TemplateTabOpenIntent,
): void {
openTab(getTemplateTabSpec(templateId, intent));
}
export function getGitDiffFileTabId(filePath: string): string { export function getGitDiffFileTabId(filePath: string): string {
return `git-diff:${filePath}`; return `git-diff:${filePath}`;
} }

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state'; const STORAGE_KEY = 'bds-app-state';
// Tab types // Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts'; export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates';
export interface Tab { export interface Tab {
type: TabType; type: TabType;

View File

@@ -4,4 +4,4 @@ export { unescapeMacroSyntax } from './markdownEscape';
export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping'; export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping';
export { loadTabsForProject, saveTabsForProject } from './tabPersistence'; export { loadTabsForProject, saveTabsForProject } from './tabPersistence';
export { buildTagColorMap, loadTagColorMap } from './tagColors'; export { buildTagColorMap, loadTagColorMap } from './tagColors';
export { BDS_EVENT_SCRIPTS_CHANGED, addWindowEventListener, dispatchWindowEvent, type BdsWindowEventName } from './windowEvents'; export { BDS_EVENT_SCRIPTS_CHANGED, BDS_EVENT_TEMPLATES_CHANGED, addWindowEventListener, dispatchWindowEvent, type BdsWindowEventName } from './windowEvents';

View File

@@ -1,7 +1,9 @@
export const BDS_EVENT_SCRIPTS_CHANGED = 'bds:scripts-changed' as const; export const BDS_EVENT_SCRIPTS_CHANGED = 'bds:scripts-changed' as const;
export const BDS_EVENT_TEMPLATES_CHANGED = 'bds:templates-changed' as const;
export type BdsWindowEventName = export type BdsWindowEventName =
| typeof BDS_EVENT_SCRIPTS_CHANGED | typeof BDS_EVENT_SCRIPTS_CHANGED
| typeof BDS_EVENT_TEMPLATES_CHANGED
| 'bds:site-validation-updated'; | 'bds:site-validation-updated';
export function addWindowEventListener<TDetail = unknown>( export function addWindowEventListener<TDetail = unknown>(

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import { resolvePostTemplateName, resolveListTemplateName, resolvePageRendererTemplateRoots } from '../../src/main/engine/PageRenderer';
describe('resolvePostTemplateName', () => {
it('returns default single-post when no overrides exist', () => {
const result = resolvePostTemplateName({});
expect(result).toBe('single-post');
});
it('returns post-level templateSlug when set', () => {
const result = resolvePostTemplateName({ templateSlug: 'photo-post' });
expect(result).toBe('photo-post');
});
it('returns tag-level override when post has no template and tag does', () => {
const result = resolvePostTemplateName(
{ tags: ['photography', 'travel'] },
{ photography: { postTemplateSlug: 'photo-layout' } },
);
expect(result).toBe('photo-layout');
});
it('prioritizes post-level over tag-level', () => {
const result = resolvePostTemplateName(
{ templateSlug: 'custom-post', tags: ['photography'] },
{ photography: { postTemplateSlug: 'photo-layout' } },
);
expect(result).toBe('custom-post');
});
it('returns category-level override when no post or tag override', () => {
const result = resolvePostTemplateName(
{ categories: ['article'] },
undefined,
{ article: { postTemplateSlug: 'article-layout' } },
);
expect(result).toBe('article-layout');
});
it('prioritizes tag-level over category-level', () => {
const result = resolvePostTemplateName(
{ tags: ['featured'], categories: ['article'] },
{ featured: { postTemplateSlug: 'featured-layout' } },
{ article: { postTemplateSlug: 'article-layout' } },
);
expect(result).toBe('featured-layout');
});
it('skips tags/categories with null postTemplateSlug', () => {
const result = resolvePostTemplateName(
{ tags: ['empty'], categories: ['article'] },
{ empty: { postTemplateSlug: null } },
{ article: { postTemplateSlug: 'article-layout' } },
);
expect(result).toBe('article-layout');
});
it('returns default when all overrides are null/undefined', () => {
const result = resolvePostTemplateName(
{ templateSlug: null, tags: ['empty'], categories: ['plain'] },
{ empty: { postTemplateSlug: null } },
{ plain: { postTemplateSlug: undefined } },
);
expect(result).toBe('single-post');
});
it('uses first matching tag with a template slug', () => {
const result = resolvePostTemplateName(
{ tags: ['no-template', 'has-template', 'also-has'] },
{
'has-template': { postTemplateSlug: 'first-match' },
'also-has': { postTemplateSlug: 'second-match' },
},
);
expect(result).toBe('first-match');
});
});
describe('resolveListTemplateName', () => {
it('returns default post-list when no overrides exist', () => {
const result = resolveListTemplateName();
expect(result).toBe('post-list');
});
it('returns default when no route category', () => {
const result = resolveListTemplateName(undefined, {
article: { listTemplateSlug: 'article-list' },
});
expect(result).toBe('post-list');
});
it('returns category-level listTemplateSlug', () => {
const result = resolveListTemplateName('article', {
article: { listTemplateSlug: 'article-list' },
});
expect(result).toBe('article-list');
});
it('returns default when category has no listTemplateSlug', () => {
const result = resolveListTemplateName('article', {
article: { listTemplateSlug: undefined },
});
expect(result).toBe('post-list');
});
it('returns default when category not in settings', () => {
const result = resolveListTemplateName('unknown', {
article: { listTemplateSlug: 'article-list' },
});
expect(result).toBe('post-list');
});
});
describe('resolvePageRendererTemplateRoots with userTemplatesDir', () => {
it('adds user templates directory first when provided', () => {
const roots = resolvePageRendererTemplateRoots({
moduleDir: '/app/dist/main/engine',
cwd: '/app',
userTemplatesDir: '/data/project/templates',
});
expect(roots[0]).toBe('/data/project/templates');
});
it('does not add user templates when not provided', () => {
const roots = resolvePageRendererTemplateRoots({
moduleDir: '/app/dist/main/engine',
cwd: '/app',
});
expect(roots).not.toContain(undefined);
expect(roots.every(r => r.length > 0)).toBe(true);
});
});

View File

@@ -0,0 +1,346 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TemplateEngine } from '../../src/main/engine/TemplateEngine';
const mockTemplates = new Map<string, any>();
const mockFiles = new Map<string, string>();
function createSelectChain() {
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockTemplates.values()))),
get: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
};
}
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain()),
insert: vi.fn(() => ({
values: vi.fn((data: any) => {
mockTemplates.set(data.id, data);
return Promise.resolve();
}),
})),
update: vi.fn(() => ({
set: vi.fn((updates: any) => ({
where: vi.fn(async () => {
for (const [templateId, existing] of mockTemplates.entries()) {
mockTemplates.set(templateId, { ...existing, ...updates });
}
}),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(async () => {
mockTemplates.clear();
return Promise.resolve();
}),
})),
};
}
const mockLocalDb = createDrizzleMock();
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => mockLocalDb),
})),
}));
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-template-id'),
}));
vi.mock('fs/promises', () => ({
readdir: vi.fn(async (dirPath: string, options?: { withFileTypes?: boolean }) => {
if (options?.withFileTypes) {
const files = Array.from((globalThis as any).__mockTemplateFiles.keys()) as string[];
const names = files
.filter((filePath) => filePath.startsWith(`${dirPath}/`))
.map((filePath) => filePath.slice(dirPath.length + 1))
.filter((name) => !name.includes('/'));
return names.map((name) => ({
name,
isDirectory: () => false,
isFile: () => true,
}));
}
return [];
}),
readFile: vi.fn(async (filePath: string) => {
const value = (globalThis as any).__mockTemplateFiles.get(filePath);
if (typeof value !== 'string') {
const error = new Error('ENOENT');
(error as any).code = 'ENOENT';
throw error;
}
return value;
}),
writeFile: vi.fn(async (filePath: string, content: string) => {
(globalThis as any).__mockTemplateFiles.set(filePath, content);
}),
unlink: vi.fn(async (filePath: string) => {
(globalThis as any).__mockTemplateFiles.delete(filePath);
}),
rename: vi.fn(async (fromPath: string, toPath: string) => {
const files = (globalThis as any).__mockTemplateFiles;
const content = files.get(fromPath);
files.delete(fromPath);
files.set(toPath, content);
}),
mkdir: vi.fn(async () => {}),
}));
describe('TemplateEngine', () => {
let templateEngine: TemplateEngine;
beforeEach(() => {
vi.clearAllMocks();
mockTemplates.clear();
mockFiles.clear();
(globalThis as any).__mockTemplateFiles = mockFiles;
vi.mocked(mockLocalDb.select).mockImplementation(() => createSelectChain());
templateEngine = new TemplateEngine();
templateEngine.setProjectContext('default', '/mock/userData/projects/default');
});
it('creates template metadata and liquid file', async () => {
const created = await templateEngine.createTemplate({
title: 'Custom Post Layout',
kind: 'post',
content: '<main>\n <article>{{ post.content | markdown }}</article>\n</main>',
});
expect(created.slug).toBe('custom_post_layout');
expect(mockTemplates.has(created.id)).toBe(true);
const persistedFile = mockFiles.get('/mock/userData/projects/default/templates/custom_post_layout.liquid') || '';
expect(persistedFile).toContain('---');
expect(persistedFile).toContain('title: "Custom Post Layout"');
expect(persistedFile).toContain('kind: "post"');
expect(persistedFile).toContain('<article>');
expect(created.content).toBe('<main>\n <article>{{ post.content | markdown }}</article>\n</main>');
});
it('updates template metadata and file content', async () => {
const created = await templateEngine.createTemplate({
title: 'Custom Post Layout',
kind: 'post',
content: '<main><article>Original</article></main>',
});
const updated = await templateEngine.updateTemplate(created.id, {
title: 'Updated Post Layout',
content: '<main><article>Updated</article></main>',
});
expect(updated?.slug).toBe('updated_post_layout');
expect(mockFiles.get('/mock/userData/projects/default/templates/updated_post_layout.liquid')).toContain('Updated');
});
it('appends underscore numeric suffix for duplicate slugs', async () => {
const first = await templateEngine.createTemplate({
title: 'Custom Post',
kind: 'post',
content: '<main>First</main>',
});
vi.mocked((await import('uuid')).v4)
.mockReturnValueOnce('mock-template-id-2');
const second = await templateEngine.createTemplate({
title: 'Custom Post',
kind: 'post',
content: '<main>Second</main>',
});
expect(first.slug).toBe('custom_post');
expect(second.slug).toBe('custom_post_2');
expect(mockFiles.get('/mock/userData/projects/default/templates/custom_post_2.liquid')).toContain('Second');
});
it('deletes template metadata and liquid file', async () => {
const created = await templateEngine.createTemplate({
title: 'Delete Me',
kind: 'partial',
content: '<footer>Footer</footer>',
});
const deleted = await templateEngine.deleteTemplate(created.id);
expect(deleted).toBe(true);
expect(mockTemplates.has(created.id)).toBe(false);
expect(mockFiles.has('/mock/userData/projects/default/templates/delete_me.liquid')).toBe(false);
});
it('keeps template content clean when file contains YAML frontmatter', async () => {
const created = await templateEngine.createTemplate({
title: 'Metadata Test',
kind: 'list',
content: '<main>{{ day_blocks }}</main>',
});
const loaded = await templateEngine.getTemplate(created.id);
expect(loaded?.content).toBe('<main>{{ day_blocks }}</main>');
expect(loaded?.title).toBe('Metadata Test');
expect(loaded?.kind).toBe('list');
});
it('rebuilds templates from filesystem and applies external file metadata', async () => {
const templatePath = '/mock/userData/projects/default/templates/external_post.liquid';
mockFiles.set(templatePath, [
'---',
'id: "external-template-id"',
'projectId: "default"',
'slug: "external_post"',
'title: "External Post Layout"',
'kind: "post"',
'enabled: false',
'version: 3',
'createdAt: "2026-02-20T10:00:00.000Z"',
'updatedAt: "2026-02-21T11:00:00.000Z"',
'---',
'<main><article>{{ post.content | markdown }}</article></main>',
].join('\n'));
await templateEngine.rebuildDatabaseFromFiles();
const all = await templateEngine.getAllTemplates();
expect(all).toHaveLength(1);
expect(all[0].id).toBe('external-template-id');
expect(all[0].slug).toBe('external_post');
expect(all[0].kind).toBe('post');
expect(all[0].enabled).toBe(false);
expect(all[0].version).toBe(3);
expect(all[0].title).toBe('External Post Layout');
expect(all[0].content).toContain('<article>');
});
it('reconciles git changes for templates (modify/add/delete)', async () => {
const created = await templateEngine.createTemplate({
title: 'Custom Post',
kind: 'post',
content: '<main>Original</main>',
});
const existingPath = '/repo/templates/custom_post.liquid';
mockFiles.set(existingPath, [
'---',
`id: "${created.id}"`,
'projectId: "default"',
'slug: "custom_post"',
'title: "Custom Post Updated Outside"',
'kind: "post"',
'enabled: true',
'version: 8',
'createdAt: "2026-02-20T10:00:00.000Z"',
'updatedAt: "2026-02-21T11:00:00.000Z"',
'---',
'<main>Updated Outside</main>',
].join('\n'));
const addedPath = '/repo/templates/new_list.liquid';
mockFiles.set(addedPath, [
'---',
'id: "added-template-id"',
'projectId: "default"',
'slug: "new_list"',
'title: "New List Layout"',
'kind: "list"',
'enabled: true',
'version: 1',
'createdAt: "2026-02-22T10:00:00.000Z"',
'updatedAt: "2026-02-22T11:00:00.000Z"',
'---',
'<main>{{ day_blocks }}</main>',
].join('\n'));
const result = await templateEngine.reconcileTemplatesFromGitChanges('/repo', [
{ status: 'modified', path: 'templates/custom_post.liquid' },
{ status: 'added', path: 'templates/new_list.liquid' },
{ status: 'deleted', path: 'templates/custom_post.liquid' },
]);
expect(result.updated).toBe(1);
expect(result.created).toBe(1);
expect(result.deleted).toBe(1);
expect(result.processedFiles).toBe(3);
});
describe('template kind queries', () => {
it('getEnabledTemplatesByKind returns only enabled templates of specified kind', async () => {
await templateEngine.createTemplate({
title: 'Post Template',
kind: 'post',
content: '<main>Post</main>',
enabled: true,
});
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-template-id-2');
await templateEngine.createTemplate({
title: 'List Template',
kind: 'list',
content: '<main>List</main>',
enabled: true,
});
vi.mocked((await import('uuid')).v4).mockReturnValueOnce('mock-template-id-3');
await templateEngine.createTemplate({
title: 'Disabled Post',
kind: 'post',
content: '<main>Disabled</main>',
enabled: false,
});
const postTemplates = await templateEngine.getEnabledTemplatesByKind('post');
expect(postTemplates).toHaveLength(1);
expect(postTemplates[0].kind).toBe('post');
expect(postTemplates[0].enabled).toBe(true);
expect(postTemplates[0].title).toBe('Post Template');
});
});
describe('template validation', () => {
it('validates correct liquid syntax', async () => {
const result = await templateEngine.validateTemplate('<main>{{ post.title }}</main>');
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('reports invalid liquid syntax', async () => {
const result = await templateEngine.validateTemplate('<main>{% if unclosed %}</main>');
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('template slug normalization', () => {
it('normalizes slugs to lowercase with underscores', async () => {
const created = await templateEngine.createTemplate({
title: 'My Custom Layout!',
kind: 'post',
content: '<main>test</main>',
});
expect(created.slug).toBe('my_custom_layout');
});
it('uses slug from input when provided', async () => {
const created = await templateEngine.createTemplate({
title: 'Custom Post Layout',
kind: 'post',
content: '<main>test</main>',
slug: 'my-custom-slug',
});
expect(created.slug).toBe('my_custom_slug');
});
});
});

View File

@@ -170,11 +170,28 @@ const mockScriptEngine = {
reconcileScriptsFromGitChanges: vi.fn(), reconcileScriptsFromGitChanges: vi.fn(),
}; };
const mockTemplateEngine = {
on: vi.fn(),
createTemplate: vi.fn(),
updateTemplate: vi.fn(),
deleteTemplate: vi.fn(),
getTemplate: vi.fn(),
getAllTemplates: vi.fn(),
getEnabledTemplatesByKind: vi.fn(),
getTemplateBySlug: vi.fn(),
validateTemplate: vi.fn(),
rebuildDatabaseFromFiles: vi.fn(),
reconcileTemplatesFromGitChanges: vi.fn(),
setProjectContext: vi.fn(),
getTemplatesDirectory: vi.fn().mockReturnValue('/tmp/templates'),
};
const mockGitEngine = { const mockGitEngine = {
checkAvailability: vi.fn(), checkAvailability: vi.fn(),
getHeadCommit: vi.fn(), getHeadCommit: vi.fn(),
getChangedPostFilesBetween: vi.fn(), getChangedPostFilesBetween: vi.fn(),
getChangedScriptFilesBetween: vi.fn(), getChangedScriptFilesBetween: vi.fn(),
getChangedTemplateFilesBetween: vi.fn(),
getRepoState: vi.fn(), getRepoState: vi.fn(),
getStatus: vi.fn(), getStatus: vi.fn(),
getDiff: vi.fn(), getDiff: vi.fn(),
@@ -280,6 +297,10 @@ vi.mock('../../src/main/engine/ScriptEngine', () => ({
getScriptEngine: vi.fn(() => mockScriptEngine), getScriptEngine: vi.fn(() => mockScriptEngine),
})); }));
vi.mock('../../src/main/engine/TemplateEngine', () => ({
getTemplateEngine: vi.fn(() => mockTemplateEngine),
}));
vi.mock('../../src/main/engine/GitEngine', () => ({ vi.mock('../../src/main/engine/GitEngine', () => ({
getGitEngine: vi.fn(() => mockGitEngine), getGitEngine: vi.fn(() => mockGitEngine),
})); }));
@@ -581,6 +602,7 @@ describe('IPC Handlers', () => {
mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([ mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([
{ status: 'modified', path: 'scripts/transform.py' }, { status: 'modified', path: 'scripts/transform.py' },
]); ]);
mockGitEngine.getChangedTemplateFilesBetween.mockResolvedValue([]);
mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({ mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({
created: 1, created: 1,
updated: 1, updated: 1,

View File

@@ -46,6 +46,10 @@ describe('SettingsView i18n', () => {
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
}, },
templates: {
...(window as Window & { electronAPI: any }).electronAPI?.templates,
getEnabledByKind: vi.fn().mockResolvedValue([]),
},
}; };
}); });

View File

@@ -61,6 +61,10 @@ describe('SettingsView Diff Preferences', () => {
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
}, },
templates: {
...(window as any).electronAPI?.templates,
getEnabledByKind: vi.fn().mockResolvedValue([]),
},
projects: { projects: {
...(window as any).electronAPI?.projects, ...(window as any).electronAPI?.projects,
update: updateProjectMock, update: updateProjectMock,

View File

@@ -19,6 +19,9 @@ describe('TagsView subscriptions', () => {
merge: vi.fn(), merge: vi.fn(),
syncFromPosts: vi.fn(), syncFromPosts: vi.fn(),
}, },
templates: {
getEnabledByKind: vi.fn().mockResolvedValue([]),
},
on: onMock, on: onMock,
}; };
}); });

View File

@@ -22,6 +22,7 @@ describe('editorRouting', () => {
'api-documentation': 'api-documentation', 'api-documentation': 'api-documentation',
'site-validation': 'site-validation', 'site-validation': 'site-validation',
scripts: 'scripts', scripts: 'scripts',
templates: 'templates',
}); });
}); });

View File

@@ -12,6 +12,7 @@ describe('sidebarViewRegistry', () => {
'pages', 'pages',
'media', 'media',
'scripts', 'scripts',
'templates',
'settings', 'settings',
'tags', 'tags',
'chat', 'chat',

View File

@@ -146,6 +146,16 @@ Object.defineProperty(globalThis, 'window', {
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
}, },
templates: {
getEnabledByKind: vi.fn().mockResolvedValue([]),
getAll: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(null),
delete: vi.fn().mockResolvedValue(false),
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
rebuildFromFiles: vi.fn().mockResolvedValue(undefined),
},
on: vi.fn(() => () => {}), on: vi.fn(() => () => {}),
}, },
}, },