feat: user-managed templates
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import path from 'node:path';
|
||||
import type { CategoryRenderSettings } from './PageRenderer';
|
||||
import { buildCanonicalPostPath } from './PageRenderer';
|
||||
import type { MenuDocument } from './MenuEngine';
|
||||
@@ -210,6 +211,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
getMenu: async () => menu,
|
||||
},
|
||||
getActiveProjectContext: async () => projectContext,
|
||||
userTemplatesDir: path.join(params.options.dataDir, 'templates'),
|
||||
});
|
||||
|
||||
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fsPromises from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import type { GitScriptFileChange, GitScriptFileChangeStatus } from './ScriptEngine';
|
||||
import type { GitTemplateFileChange, GitTemplateFileChangeStatus } from './TemplateEngine';
|
||||
|
||||
export interface GitAvailability {
|
||||
gitFound: boolean;
|
||||
@@ -142,6 +143,7 @@ export interface GitPostFileChange {
|
||||
}
|
||||
|
||||
export type { GitScriptFileChange, GitScriptFileChangeStatus };
|
||||
export type { GitTemplateFileChange, GitTemplateFileChangeStatus };
|
||||
|
||||
type GitProvider = 'unknown' | 'github' | 'gitlab' | 'gitea-forgejo';
|
||||
|
||||
@@ -534,6 +536,11 @@ export class GitEngine {
|
||||
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[] {
|
||||
const tokens = raw.split('\0').filter((token) => token.length > 0);
|
||||
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> {
|
||||
const git = this.createNonInteractiveGit(projectPath);
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ProjectMetadata {
|
||||
export interface CategoryRenderSettings {
|
||||
renderInLists: boolean;
|
||||
showTitle: boolean;
|
||||
postTemplateSlug?: string;
|
||||
listTemplateSlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +169,8 @@ function normalizeCategoryMetadata(value: unknown): Record<string, CategoryMetad
|
||||
renderInLists: settings.renderInLists !== false,
|
||||
showTitle: settings.showTitle !== false,
|
||||
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(
|
||||
Object.entries(metadata).map(([category, data]) => [
|
||||
category,
|
||||
{ renderInLists: data.renderInLists, showTitle: data.showTitle },
|
||||
{
|
||||
renderInLists: data.renderInLists,
|
||||
showTitle: data.showTitle,
|
||||
postTemplateSlug: data.postTemplateSlug,
|
||||
listTemplateSlug: data.listTemplateSlug,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface TemplatePostEntry {
|
||||
export interface CategoryRenderSettings {
|
||||
renderInLists: boolean;
|
||||
showTitle: boolean;
|
||||
postTemplateSlug?: string;
|
||||
listTemplateSlug?: string;
|
||||
}
|
||||
|
||||
export interface DayBlockContext {
|
||||
@@ -1021,6 +1023,7 @@ export function resolvePageRendererTemplateRoots(options?: {
|
||||
moduleDir?: string;
|
||||
cwd?: string;
|
||||
resourcesPath?: string;
|
||||
userTemplatesDir?: string;
|
||||
}): string[] {
|
||||
const moduleDir = options?.moduleDir ?? __dirname;
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
@@ -1036,9 +1039,67 @@ export function resolvePageRendererTemplateRoots(options?: {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private readonly mediaEngine: MediaEngineContract;
|
||||
private readonly postMediaEngine: PostMediaEngineContract;
|
||||
@@ -1051,13 +1112,14 @@ export class PageRenderer {
|
||||
postMediaEngine: PostMediaEngineContract,
|
||||
postEngineForMacros?: PostEngineContract,
|
||||
pythonMacroRenderer?: PythonMacroRendererContract,
|
||||
userTemplatesDir?: string,
|
||||
) {
|
||||
this.mediaEngine = mediaEngine;
|
||||
this.postMediaEngine = postMediaEngine;
|
||||
this.postEngineForMacros = postEngineForMacros;
|
||||
this.pythonMacroRenderer = pythonMacroRenderer;
|
||||
|
||||
const templateRoots = resolvePageRendererTemplateRoots();
|
||||
const templateRoots = resolvePageRendererTemplateRoots({ userTemplatesDir });
|
||||
|
||||
this.liquid = new Liquid({
|
||||
root: templateRoots,
|
||||
@@ -1355,13 +1417,27 @@ export class PageRenderer {
|
||||
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(
|
||||
post: PostData,
|
||||
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,
|
||||
): Promise<string> {
|
||||
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> {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type PythonMacroRendererContract,
|
||||
} from './PageRenderer';
|
||||
import { getScriptEngine } from './ScriptEngine';
|
||||
import { getTemplateEngine } from './TemplateEngine';
|
||||
import { getPythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime';
|
||||
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
||||
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
||||
@@ -69,6 +70,7 @@ interface PreviewServerDependencies {
|
||||
settingsEngine: MetaEngineContract;
|
||||
menuEngine: MenuEngineContract;
|
||||
getActiveProjectContext: () => Promise<ActiveProjectContext>;
|
||||
userTemplatesDir?: string;
|
||||
}
|
||||
|
||||
interface SerializedTag {
|
||||
@@ -106,7 +108,13 @@ export class PreviewServer {
|
||||
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> {
|
||||
@@ -197,6 +205,7 @@ export class PreviewServer {
|
||||
resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings),
|
||||
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
|
||||
resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext),
|
||||
resolveTagTemplateSettings: (projectContext) => this.resolveTagTemplateSettings(projectContext),
|
||||
pageRenderer: this.pageRenderer,
|
||||
postEngineForMacros: this.postEngine,
|
||||
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> {
|
||||
const match = pathname.match(/^\/assets\/([^/]+)$/);
|
||||
if (!match) return null;
|
||||
@@ -592,6 +634,8 @@ export class PreviewServer {
|
||||
mergedSettings[category] = {
|
||||
renderInLists: value.renderInLists,
|
||||
showTitle: value.showTitle,
|
||||
postTemplateSlug: value.postTemplateSlug,
|
||||
listTemplateSlug: value.listTemplateSlug,
|
||||
};
|
||||
}
|
||||
return mergedSettings;
|
||||
|
||||
@@ -81,12 +81,73 @@ export class ProjectEngine extends EventEmitter {
|
||||
// - If custom dataPath is provided, all project data lives there (allows cloud storage backup)
|
||||
// - If no dataPath (default project), use internal userData storage
|
||||
const dataDir = this.getDataDir(projectId, dataPath);
|
||||
|
||||
|
||||
// Create all project directories in the data directory
|
||||
await fs.mkdir(path.join(dataDir, 'posts'), { 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, '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> {
|
||||
@@ -119,6 +180,9 @@ export class ProjectEngine extends EventEmitter {
|
||||
// Create directories using project ID (not slug)
|
||||
await this.ensureProjectDirectories(id, data.dataPath);
|
||||
|
||||
// Copy bundled templates as starter templates
|
||||
await this.copyStarterTemplates(id, data.dataPath);
|
||||
|
||||
// Insert into database
|
||||
const dbProject: NewProject = {
|
||||
id: project.id,
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
|
||||
resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[];
|
||||
buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>;
|
||||
resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise<Record<string, string>>;
|
||||
resolveTagTemplateSettings?: (projectContext: SharedActiveProjectContext) => Promise<Record<string, { postTemplateSlug?: string | null }>>;
|
||||
pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>;
|
||||
postEngineForMacros?: PostEngineContract;
|
||||
loadPublishedSnapshotsPage: (
|
||||
@@ -96,6 +97,7 @@ async function resolveRouteWithSharedServices(
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
categoryMetadata: Record<string, CategoryMetadata>,
|
||||
tagColorByName: Record<string, string>,
|
||||
tagTemplateSettings: Record<string, { postTemplateSlug?: string | null }>,
|
||||
listExcludedCategories: string[],
|
||||
services: SharedRouteRenderServices<CategoryMetadata>,
|
||||
allowEmptyArchiveRender: boolean,
|
||||
@@ -187,6 +189,8 @@ async function resolveRouteWithSharedServices(
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
tag_color_by_name: tagColorByName,
|
||||
tagSettings: tagTemplateSettings,
|
||||
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
||||
}, services.postEngineForMacros);
|
||||
}
|
||||
|
||||
@@ -270,6 +274,8 @@ async function resolveRouteWithSharedServices(
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
tag_color_by_name: tagColorByName,
|
||||
tagSettings: tagTemplateSettings,
|
||||
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
||||
}, services.postEngineForMacros);
|
||||
}
|
||||
|
||||
@@ -310,6 +316,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
||||
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
||||
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
|
||||
const tagColorByName = await services.resolveTagColorByName(options.projectContext);
|
||||
const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {};
|
||||
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
|
||||
|
||||
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
|
||||
@@ -318,5 +325,5 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
||||
menuItems,
|
||||
picoStylesheetHref,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface TagData {
|
||||
projectId: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
postTemplateSlug?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export interface CreateTagInput {
|
||||
export interface UpdateTagInput {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
postTemplateSlug?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +112,7 @@ function isValidHexColor(color: string): boolean {
|
||||
interface SerializedTag {
|
||||
name: 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');
|
||||
}
|
||||
|
||||
if (input.color === undefined) {
|
||||
const hasColorUpdate = input.color !== undefined;
|
||||
const hasTemplateUpdate = input.postTemplateSlug !== undefined;
|
||||
|
||||
if (!hasColorUpdate && !hasTemplateUpdate) {
|
||||
// No updates
|
||||
return this.rowToTagData(row);
|
||||
}
|
||||
|
||||
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
|
||||
.update(tags)
|
||||
.set({
|
||||
color: input.color,
|
||||
updatedAt: now,
|
||||
})
|
||||
.set(setFields)
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
@@ -422,7 +433,8 @@ export class TagEngine extends EventEmitter {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
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,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -817,6 +829,7 @@ export class TagEngine extends EventEmitter {
|
||||
projectId: row.projectId,
|
||||
name: row.name,
|
||||
color: row.color || undefined,
|
||||
postTemplateSlug: row.postTemplateSlug || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
@@ -838,6 +851,9 @@ export class TagEngine extends EventEmitter {
|
||||
if (tag.color) {
|
||||
entry.color = tag.color;
|
||||
}
|
||||
if (tag.postTemplateSlug) {
|
||||
entry.postTemplateSlug = tag.postTemplateSlug;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
@@ -867,6 +883,7 @@ export class TagEngine extends EventEmitter {
|
||||
if (!name) continue;
|
||||
|
||||
const color = tag.color || null;
|
||||
const postTemplateSlug = typeof tag.postTemplateSlug === 'string' ? tag.postTemplateSlug : null;
|
||||
|
||||
// Check if tag with this name already exists
|
||||
const existing = await db
|
||||
@@ -884,17 +901,22 @@ export class TagEngine extends EventEmitter {
|
||||
projectId: this.currentProjectId,
|
||||
name,
|
||||
color,
|
||||
postTemplateSlug,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (color) {
|
||||
// Update color if provided and tag exists
|
||||
} else if (color || postTemplateSlug) {
|
||||
// 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
|
||||
.update(tags)
|
||||
.set({
|
||||
color,
|
||||
updatedAt: now,
|
||||
})
|
||||
.set(setFields)
|
||||
.where(and(
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
sql`LOWER(${tags.name}) = LOWER(${name})`
|
||||
|
||||
762
src/main/engine/TemplateEngine.ts
Normal file
762
src/main/engine/TemplateEngine.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user