feat: categories with titles
This commit is contained in:
@@ -33,11 +33,16 @@ export interface BlogGenerationOptions {
|
|||||||
language?: string;
|
language?: string;
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
picoTheme?: PicoThemeName;
|
picoTheme?: PicoThemeName;
|
||||||
|
categoryMetadata?: Record<string, CategoryMetadata>;
|
||||||
categorySettings?: Record<string, CategoryRenderSettings>;
|
categorySettings?: Record<string, CategoryRenderSettings>;
|
||||||
menu?: MenuDocument;
|
menu?: MenuDocument;
|
||||||
sections?: BlogGenerationSection[];
|
sections?: BlogGenerationSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type BlogGenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date';
|
export type BlogGenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date';
|
||||||
|
|
||||||
export interface BlogGenerationResult {
|
export interface BlogGenerationResult {
|
||||||
@@ -105,6 +110,7 @@ function clampMaxPostsPerPage(value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveCategorySettings(
|
function resolveCategorySettings(
|
||||||
|
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||||
value: Record<string, CategoryRenderSettings> | undefined,
|
value: Record<string, CategoryRenderSettings> | undefined,
|
||||||
): Record<string, CategoryRenderSettings> {
|
): Record<string, CategoryRenderSettings> {
|
||||||
const defaults: Record<string, CategoryRenderSettings> = {
|
const defaults: Record<string, CategoryRenderSettings> = {
|
||||||
@@ -114,11 +120,20 @@ function resolveCategorySettings(
|
|||||||
page: { renderInLists: false, showTitle: true },
|
page: { renderInLists: false, showTitle: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!value) {
|
const merged = { ...defaults };
|
||||||
return defaults;
|
if (categoryMetadata) {
|
||||||
|
for (const [category, metadata] of Object.entries(categoryMetadata)) {
|
||||||
|
merged[category] = {
|
||||||
|
renderInLists: metadata?.renderInLists !== false,
|
||||||
|
showTitle: metadata?.showTitle !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = { ...defaults };
|
|
||||||
for (const [category, settings] of Object.entries(value)) {
|
for (const [category, settings] of Object.entries(value)) {
|
||||||
merged[category] = {
|
merged[category] = {
|
||||||
renderInLists: settings?.renderInLists !== false,
|
renderInLists: settings?.renderInLists !== false,
|
||||||
@@ -128,6 +143,15 @@ function resolveCategorySettings(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCategoryDisplayTitle(
|
||||||
|
category: string,
|
||||||
|
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||||
|
): string {
|
||||||
|
const title = categoryMetadata?.[category]?.title;
|
||||||
|
const trimmed = typeof title === 'string' ? title.trim() : '';
|
||||||
|
return trimmed.length > 0 ? trimmed : category;
|
||||||
|
}
|
||||||
|
|
||||||
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
|
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
|
||||||
const year = createdAt.getFullYear();
|
const year = createdAt.getFullYear();
|
||||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -332,7 +356,7 @@ export class BlogGenerationEngine {
|
|||||||
const includeTag = selectedSections.has('tag');
|
const includeTag = selectedSections.has('tag');
|
||||||
const includeDate = selectedSections.has('date');
|
const includeDate = selectedSections.has('date');
|
||||||
|
|
||||||
const categorySettings = resolveCategorySettings(options.categorySettings);
|
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
||||||
const listExcludedCategories = Object.entries(categorySettings)
|
const listExcludedCategories = Object.entries(categorySettings)
|
||||||
.filter(([, settings]) => settings.renderInLists === false)
|
.filter(([, settings]) => settings.renderInLists === false)
|
||||||
.map(([category]) => category);
|
.map(([category]) => category);
|
||||||
@@ -658,7 +682,7 @@ export class BlogGenerationEngine {
|
|||||||
const pageContext = {
|
const pageContext = {
|
||||||
page_title: pageTitle,
|
page_title: pageTitle,
|
||||||
language,
|
language,
|
||||||
menu_items: buildTemplateMenuItems(options.menu),
|
menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata),
|
||||||
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
|
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -680,7 +704,7 @@ export class BlogGenerationEngine {
|
|||||||
|
|
||||||
if (includeCategory) {
|
if (includeCategory) {
|
||||||
onProgress(50, 'Generating category pages...');
|
onProgress(50, 'Generating category pages...');
|
||||||
pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, reportUnitProgress);
|
pagesGenerated += await this.generateCategoryPages(options.projectId, publishedListPosts, allCategories, rewriteContext, maxPostsPerPage, htmlDir, pageContext, pageRenderer, categorySettings, options.categoryMetadata, reportUnitProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeTag) {
|
if (includeTag) {
|
||||||
@@ -723,7 +747,7 @@ export class BlogGenerationEngine {
|
|||||||
onProgress(0, 'Collecting sitemap URLs...');
|
onProgress(0, 'Collecting sitemap URLs...');
|
||||||
|
|
||||||
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||||
const categorySettings = resolveCategorySettings(options.categorySettings);
|
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
||||||
const listExcludedCategories = Object.entries(categorySettings)
|
const listExcludedCategories = Object.entries(categorySettings)
|
||||||
.filter(([, settings]) => settings.renderInLists === false)
|
.filter(([, settings]) => settings.renderInLists === false)
|
||||||
.map(([category]) => category);
|
.map(([category]) => category);
|
||||||
@@ -1117,7 +1141,7 @@ export class BlogGenerationEngine {
|
|||||||
renderedUrlCount += generationResult.pagesGenerated;
|
renderedUrlCount += generationResult.pagesGenerated;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const categorySettings = resolveCategorySettings(options.categorySettings);
|
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
||||||
const listExcludedCategories = Object.entries(categorySettings)
|
const listExcludedCategories = Object.entries(categorySettings)
|
||||||
.filter(([, settings]) => settings.renderInLists === false)
|
.filter(([, settings]) => settings.renderInLists === false)
|
||||||
.map(([category]) => category);
|
.map(([category]) => category);
|
||||||
@@ -1274,7 +1298,7 @@ export class BlogGenerationEngine {
|
|||||||
const pageContext = {
|
const pageContext = {
|
||||||
page_title: pageTitle,
|
page_title: pageTitle,
|
||||||
language,
|
language,
|
||||||
menu_items: buildTemplateMenuItems(options.menu),
|
menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata),
|
||||||
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
|
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
|
||||||
};
|
};
|
||||||
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine);
|
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine);
|
||||||
@@ -1367,6 +1391,7 @@ export class BlogGenerationEngine {
|
|||||||
pageContext,
|
pageContext,
|
||||||
pageRenderer,
|
pageRenderer,
|
||||||
categorySettings,
|
categorySettings,
|
||||||
|
options.categoryMetadata,
|
||||||
onPageGenerated,
|
onPageGenerated,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1560,6 +1585,7 @@ export class BlogGenerationEngine {
|
|||||||
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
|
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
|
||||||
pageRenderer: PageRenderer,
|
pageRenderer: PageRenderer,
|
||||||
categorySettings: Record<string, CategoryRenderSettings>,
|
categorySettings: Record<string, CategoryRenderSettings>,
|
||||||
|
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||||
onPageGenerated: (message: string) => void,
|
onPageGenerated: (message: string) => void,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -1568,6 +1594,8 @@ export class BlogGenerationEngine {
|
|||||||
const categoryPosts = posts.filter((post) => (post.categories || []).includes(category));
|
const categoryPosts = posts.filter((post) => (post.categories || []).includes(category));
|
||||||
if (categoryPosts.length === 0) continue;
|
if (categoryPosts.length === 0) continue;
|
||||||
|
|
||||||
|
const categoryDisplayTitle = resolveCategoryDisplayTitle(category, categoryMetadata);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage));
|
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage));
|
||||||
const encodedCategory = encodeURIComponent(category);
|
const encodedCategory = encodeURIComponent(category);
|
||||||
const basePathname = `/category/${encodedCategory}`;
|
const basePathname = `/category/${encodedCategory}`;
|
||||||
@@ -1580,7 +1608,7 @@ export class BlogGenerationEngine {
|
|||||||
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
|
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
|
||||||
archiveGrouping: true,
|
archiveGrouping: true,
|
||||||
routeKind: 'non-date',
|
routeKind: 'non-date',
|
||||||
archiveContext: { kind: 'category', name: category },
|
archiveContext: { kind: 'category', name: categoryDisplayTitle },
|
||||||
basePathname,
|
basePathname,
|
||||||
pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length },
|
pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length },
|
||||||
categorySettings,
|
categorySettings,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface ProjectMetadata {
|
|||||||
defaultAuthor?: string; // Default author for new posts and media
|
defaultAuthor?: string; // Default author for new posts and media
|
||||||
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
||||||
picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering
|
picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering
|
||||||
|
categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering
|
||||||
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
|
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ export interface CategoryRenderSettings {
|
|||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||||
const MIN_MAX_POSTS_PER_PAGE = 1;
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||||
const MAX_MAX_POSTS_PER_PAGE = 500;
|
const MAX_MAX_POSTS_PER_PAGE = 500;
|
||||||
@@ -66,17 +71,25 @@ function sanitizePublicUrl(value: unknown): string | undefined {
|
|||||||
return trimmed.length > 0 ? trimmed : undefined;
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeCategoryTitle(value: unknown, fallback: string): string {
|
||||||
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return trimmed.length > 0 ? trimmed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRenderSettings>;
|
||||||
|
|
||||||
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||||
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
||||||
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
||||||
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
|
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
|
||||||
const categorySettings = normalizeCategorySettings(metadata.categorySettings);
|
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
maxPostsPerPage,
|
maxPostsPerPage,
|
||||||
picoTheme,
|
picoTheme,
|
||||||
categorySettings,
|
categoryMetadata,
|
||||||
|
categorySettings: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,37 +99,62 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
|||||||
export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
||||||
|
|
||||||
export function getDefaultCategorySettings(): Record<string, CategoryRenderSettings> {
|
export function getDefaultCategorySettings(): Record<string, CategoryRenderSettings> {
|
||||||
|
const defaults = getDefaultCategoryMetadata();
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(defaults).map(([category, value]) => [
|
||||||
|
category,
|
||||||
|
{ renderInLists: value.renderInLists, showTitle: value.showTitle },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultCategoryMetadata(): Record<string, CategoryMetadata> {
|
||||||
return {
|
return {
|
||||||
article: { renderInLists: true, showTitle: true },
|
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||||
picture: { renderInLists: true, showTitle: true },
|
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||||
aside: { renderInLists: true, showTitle: false },
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||||
page: { renderInLists: false, showTitle: true },
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCategorySettings(value: unknown): Record<string, CategoryRenderSettings> {
|
function normalizeCategoryMetadata(value: unknown): Record<string, CategoryMetadata> {
|
||||||
const defaults = getDefaultCategorySettings();
|
const defaults = getDefaultCategoryMetadata();
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized: Record<string, CategoryRenderSettings> = { ...defaults };
|
const normalized: Record<string, CategoryMetadata> = { ...defaults };
|
||||||
for (const [rawCategory, rawSettings] of Object.entries(value as Record<string, unknown>)) {
|
for (const [rawCategory, rawSettings] of Object.entries(value as RawCategoryMetadataInput)) {
|
||||||
const category = normalizeTaxonomyTerm(rawCategory);
|
const category = normalizeTaxonomyTerm(rawCategory);
|
||||||
if (!category || !rawSettings || typeof rawSettings !== 'object') {
|
if (!category || !rawSettings || typeof rawSettings !== 'object') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = rawSettings as Record<string, unknown>;
|
const settings = rawSettings as unknown as {
|
||||||
|
renderInLists?: unknown;
|
||||||
|
showTitle?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
};
|
||||||
normalized[category] = {
|
normalized[category] = {
|
||||||
renderInLists: settings.renderInLists !== false,
|
renderInLists: settings.renderInLists !== false,
|
||||||
showTitle: settings.showTitle !== false,
|
showTitle: settings.showTitle !== false,
|
||||||
|
title: sanitizeCategoryTitle(settings.title, category),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCategorySettings(value: unknown): Record<string, CategoryRenderSettings> {
|
||||||
|
const metadata = normalizeCategoryMetadata(value);
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(metadata).map(([category, data]) => [
|
||||||
|
category,
|
||||||
|
{ renderInLists: data.renderInLists, showTitle: data.showTitle },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MetaEngine manages project metadata like available tags and categories.
|
* MetaEngine manages project metadata like available tags and categories.
|
||||||
*
|
*
|
||||||
@@ -171,6 +209,10 @@ export class MetaEngine extends EventEmitter {
|
|||||||
return path.join(this.getMetaDir(), 'project.json');
|
return path.join(this.getMetaDir(), 'project.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCategoryMetadataFilePath(): string {
|
||||||
|
return path.join(this.getMetaDir(), 'category-meta.json');
|
||||||
|
}
|
||||||
|
|
||||||
setProjectContext(projectId: string, dataDir?: string): void {
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
this.dataDir = dataDir || null;
|
this.dataDir = dataDir || null;
|
||||||
@@ -211,7 +253,11 @@ export class MetaEngine extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
|
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
|
||||||
this.projectMetadata = normalizeProjectMetadata({ ...metadata });
|
this.projectMetadata = normalizeProjectMetadata({ ...metadata });
|
||||||
|
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||||
|
this.projectMetadata.categoryMetadata,
|
||||||
|
);
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
|
await this.saveCategoryMetadata();
|
||||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +273,13 @@ export class MetaEngine extends EventEmitter {
|
|||||||
normalizedUpdates.picoTheme = sanitizePicoTheme(updates.picoTheme);
|
normalizedUpdates.picoTheme = sanitizePicoTheme(updates.picoTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updates.categoryMetadata !== undefined || updates.categorySettings !== undefined) {
|
||||||
|
normalizedUpdates.categoryMetadata = normalizeCategoryMetadata(
|
||||||
|
updates.categoryMetadata ?? updates.categorySettings,
|
||||||
|
);
|
||||||
|
normalizedUpdates.categorySettings = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.projectMetadata) {
|
if (!this.projectMetadata) {
|
||||||
this.projectMetadata = normalizeProjectMetadata({
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
name: normalizedUpdates.name || '',
|
name: normalizedUpdates.name || '',
|
||||||
@@ -237,6 +290,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
defaultAuthor: normalizedUpdates.defaultAuthor,
|
defaultAuthor: normalizedUpdates.defaultAuthor,
|
||||||
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
||||||
picoTheme: normalizedUpdates.picoTheme,
|
picoTheme: normalizedUpdates.picoTheme,
|
||||||
|
categoryMetadata: normalizedUpdates.categoryMetadata,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.projectMetadata = normalizeProjectMetadata({
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
@@ -244,7 +298,11 @@ export class MetaEngine extends EventEmitter {
|
|||||||
...normalizedUpdates,
|
...normalizedUpdates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||||
|
this.projectMetadata.categoryMetadata,
|
||||||
|
);
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
|
await this.saveCategoryMetadata();
|
||||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,17 +338,19 @@ export class MetaEngine extends EventEmitter {
|
|||||||
this.categories.add(normalizedCategory);
|
this.categories.add(normalizedCategory);
|
||||||
const currentMetadata = this.projectMetadata;
|
const currentMetadata = this.projectMetadata;
|
||||||
if (currentMetadata) {
|
if (currentMetadata) {
|
||||||
const currentSettings = normalizeCategorySettings(currentMetadata.categorySettings);
|
const currentCategoryMetadata = normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings);
|
||||||
if (!currentSettings[normalizedCategory]) {
|
if (!currentCategoryMetadata[normalizedCategory]) {
|
||||||
currentSettings[normalizedCategory] = {
|
currentCategoryMetadata[normalizedCategory] = {
|
||||||
renderInLists: true,
|
renderInLists: true,
|
||||||
showTitle: true,
|
showTitle: true,
|
||||||
|
title: normalizedCategory,
|
||||||
};
|
};
|
||||||
this.projectMetadata = normalizeProjectMetadata({
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
...currentMetadata,
|
...currentMetadata,
|
||||||
categorySettings: currentSettings,
|
categoryMetadata: currentCategoryMetadata,
|
||||||
});
|
});
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
|
await this.saveCategoryMetadata();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.emit('categoriesChanged', await this.getCategories());
|
this.emit('categoriesChanged', await this.getCategories());
|
||||||
@@ -305,14 +365,18 @@ export class MetaEngine extends EventEmitter {
|
|||||||
const normalizedCategory = normalizeTaxonomyTerm(category);
|
const normalizedCategory = normalizeTaxonomyTerm(category);
|
||||||
if (this.categories.delete(normalizedCategory)) {
|
if (this.categories.delete(normalizedCategory)) {
|
||||||
const currentMetadata = this.projectMetadata;
|
const currentMetadata = this.projectMetadata;
|
||||||
if (currentMetadata?.categorySettings?.[normalizedCategory]) {
|
const currentCategoryMetadata = currentMetadata
|
||||||
const nextSettings = { ...currentMetadata.categorySettings };
|
? normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings)
|
||||||
delete nextSettings[normalizedCategory];
|
: null;
|
||||||
|
if (currentMetadata && currentCategoryMetadata?.[normalizedCategory]) {
|
||||||
|
const nextCategoryMetadata = { ...currentCategoryMetadata };
|
||||||
|
delete nextCategoryMetadata[normalizedCategory];
|
||||||
this.projectMetadata = normalizeProjectMetadata({
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
...currentMetadata,
|
...currentMetadata,
|
||||||
categorySettings: nextSettings,
|
categoryMetadata: nextCategoryMetadata,
|
||||||
});
|
});
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
|
await this.saveCategoryMetadata();
|
||||||
}
|
}
|
||||||
this.emit('categoriesChanged', await this.getCategories());
|
this.emit('categoriesChanged', await this.getCategories());
|
||||||
await this.saveCategories();
|
await this.saveCategories();
|
||||||
@@ -341,7 +405,12 @@ export class MetaEngine extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await this.ensureMetaDirExists();
|
await this.ensureMetaDirExists();
|
||||||
const filePath = this.getProjectMetadataFilePath();
|
const filePath = this.getProjectMetadataFilePath();
|
||||||
const { dataPath: _dataPath, ...persistedMetadata } = this.projectMetadata || {};
|
const {
|
||||||
|
dataPath: _dataPath,
|
||||||
|
categoryMetadata: _categoryMetadata,
|
||||||
|
categorySettings: _categorySettings,
|
||||||
|
...persistedMetadata
|
||||||
|
} = this.projectMetadata || {};
|
||||||
const content = JSON.stringify(persistedMetadata, null, 2);
|
const content = JSON.stringify(persistedMetadata, null, 2);
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
await fs.writeFile(filePath, content, 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -350,6 +419,24 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save category metadata to the filesystem.
|
||||||
|
*/
|
||||||
|
async saveCategoryMetadata(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.ensureMetaDirExists();
|
||||||
|
const filePath = this.getCategoryMetadataFilePath();
|
||||||
|
const metadata = this.ensureCategoryMetadataForKnownCategories(
|
||||||
|
this.projectMetadata?.categoryMetadata,
|
||||||
|
);
|
||||||
|
const content = JSON.stringify(metadata, null, 2);
|
||||||
|
await fs.writeFile(filePath, content, 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MetaEngine] Failed to save category metadata:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load project metadata from the filesystem.
|
* Load project metadata from the filesystem.
|
||||||
*/
|
*/
|
||||||
@@ -369,6 +456,24 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load category metadata from the filesystem.
|
||||||
|
*/
|
||||||
|
async loadCategoryMetadata(): Promise<Record<string, CategoryMetadata> | null> {
|
||||||
|
try {
|
||||||
|
const filePath = this.getCategoryMetadataFilePath();
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||||
|
return normalizeCategoryMetadata(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
console.error('[MetaEngine] Failed to load category metadata:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load categories from the filesystem.
|
* Load categories from the filesystem.
|
||||||
*/
|
*/
|
||||||
@@ -459,6 +564,26 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureCategoryMetadataForKnownCategories(
|
||||||
|
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||||
|
): Record<string, CategoryMetadata> {
|
||||||
|
const merged = normalizeCategoryMetadata(categoryMetadata);
|
||||||
|
|
||||||
|
for (const category of this.categories) {
|
||||||
|
if (!merged[category]) {
|
||||||
|
merged[category] = {
|
||||||
|
renderInLists: true,
|
||||||
|
showTitle: true,
|
||||||
|
title: category,
|
||||||
|
};
|
||||||
|
} else if (!merged[category].title || merged[category].title.trim().length === 0) {
|
||||||
|
merged[category].title = category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync tags and categories on startup.
|
* Sync tags and categories on startup.
|
||||||
*
|
*
|
||||||
@@ -474,9 +599,11 @@ export class MetaEngine extends EventEmitter {
|
|||||||
|
|
||||||
const categoriesFilePath = this.getCategoriesFilePath();
|
const categoriesFilePath = this.getCategoriesFilePath();
|
||||||
const projectMetadataFilePath = this.getProjectMetadataFilePath();
|
const projectMetadataFilePath = this.getProjectMetadataFilePath();
|
||||||
|
const categoryMetadataFilePath = this.getCategoryMetadataFilePath();
|
||||||
|
|
||||||
const categoriesFileExists = await this.fileExists(categoriesFilePath);
|
const categoriesFileExists = await this.fileExists(categoriesFilePath);
|
||||||
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
|
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
|
||||||
|
const categoryMetadataFileExists = await this.fileExists(categoryMetadataFilePath);
|
||||||
|
|
||||||
// Collect tags/categories from database (posts)
|
// Collect tags/categories from database (posts)
|
||||||
const dbTags = await this.collectTagsFromPosts();
|
const dbTags = await this.collectTagsFromPosts();
|
||||||
@@ -542,29 +669,28 @@ export class MetaEngine extends EventEmitter {
|
|||||||
name: projectData.name,
|
name: projectData.name,
|
||||||
description: projectData.description || undefined,
|
description: projectData.description || undefined,
|
||||||
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
|
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
|
||||||
categorySettings: getDefaultCategorySettings(),
|
|
||||||
};
|
};
|
||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.projectMetadata) {
|
if (this.projectMetadata) {
|
||||||
const mergedSettings = normalizeCategorySettings(this.projectMetadata.categorySettings);
|
const legacyCategoryMetadata = normalizeCategoryMetadata(
|
||||||
let metadataChanged = false;
|
this.projectMetadata.categoryMetadata ?? this.projectMetadata.categorySettings,
|
||||||
|
);
|
||||||
|
const fileCategoryMetadata = categoryMetadataFileExists
|
||||||
|
? await this.loadCategoryMetadata()
|
||||||
|
: null;
|
||||||
|
const mergedCategoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||||
|
fileCategoryMetadata ?? legacyCategoryMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
for (const category of this.categories) {
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
if (!mergedSettings[category]) {
|
...this.projectMetadata,
|
||||||
mergedSettings[category] = { renderInLists: true, showTitle: true };
|
categoryMetadata: mergedCategoryMetadata,
|
||||||
metadataChanged = true;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadataChanged) {
|
await this.saveProjectMetadata();
|
||||||
this.projectMetadata = normalizeProjectMetadata({
|
await this.saveCategoryMetadata();
|
||||||
...this.projectMetadata,
|
|
||||||
categorySettings: mergedSettings,
|
|
||||||
});
|
|
||||||
await this.saveProjectMetadata();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
|||||||
@@ -294,23 +294,47 @@ function buildMenuItemHref(item: MenuItemData): string {
|
|||||||
return '#';
|
return '#';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTemplateMenuItem(item: MenuItemData): TemplateMenuItem {
|
function resolveMenuItemTitle(
|
||||||
const children = (Array.isArray(item.children) ? item.children : []).map((child) => toTemplateMenuItem(child));
|
item: MenuItemData,
|
||||||
|
categoryMetadata?: Record<string, { title?: string }>,
|
||||||
|
): string {
|
||||||
|
if (item.kind === 'category-archive') {
|
||||||
|
const categoryName = (item.categoryName || '').trim();
|
||||||
|
const metadataTitle = categoryName.length > 0
|
||||||
|
? (categoryMetadata?.[categoryName]?.title || '').trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (metadataTitle.length > 0) {
|
||||||
|
return metadataTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTemplateMenuItem(
|
||||||
|
item: MenuItemData,
|
||||||
|
categoryMetadata?: Record<string, { title?: string }>,
|
||||||
|
): TemplateMenuItem {
|
||||||
|
const children = (Array.isArray(item.children) ? item.children : []).map((child) => toTemplateMenuItem(child, categoryMetadata));
|
||||||
return {
|
return {
|
||||||
title: item.title,
|
title: resolveMenuItemTitle(item, categoryMetadata),
|
||||||
href: buildMenuItemHref(item),
|
href: buildMenuItemHref(item),
|
||||||
has_children: children.length > 0,
|
has_children: children.length > 0,
|
||||||
children,
|
children,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTemplateMenuItems(menu: MenuDocument | null | undefined): TemplateMenuItem[] {
|
export function buildTemplateMenuItems(
|
||||||
|
menu: MenuDocument | null | undefined,
|
||||||
|
categoryMetadata?: Record<string, { title?: string }>,
|
||||||
|
): TemplateMenuItem[] {
|
||||||
const items = menu?.items;
|
const items = menu?.items;
|
||||||
if (!Array.isArray(items)) {
|
if (!Array.isArray(items)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.map((item) => toTemplateMenuItem(item));
|
return items.map((item) => toTemplateMenuItem(item, categoryMetadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeMacroName(name: string): string {
|
export function normalizeMacroName(name: string): string {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
|
import { getMetaEngine, type CategoryMetadata, type ProjectMetadata } from './MetaEngine';
|
||||||
import { getMediaEngine, type MediaData } from './MediaEngine';
|
import { getMediaEngine, type MediaData } from './MediaEngine';
|
||||||
import { getMenuEngine, type MenuDocument } from './MenuEngine';
|
import { getMenuEngine, type MenuDocument } from './MenuEngine';
|
||||||
import { getPostMediaEngine } from './PostMediaEngine';
|
import { getPostMediaEngine } from './PostMediaEngine';
|
||||||
@@ -182,8 +182,9 @@ export class PreviewServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await this.settingsEngine.getProjectMetadata();
|
const metadata = await this.settingsEngine.getProjectMetadata();
|
||||||
|
const categoryMetadata = this.resolveCategoryMetadata(metadata);
|
||||||
const menu = await this.menuEngine.getMenu().catch(() => ({ items: [] }));
|
const menu = await this.menuEngine.getMenu().catch(() => ({ items: [] }));
|
||||||
const menuItems = buildTemplateMenuItems(menu);
|
const menuItems = buildTemplateMenuItems(menu, categoryMetadata);
|
||||||
const categorySettings = this.resolveCategorySettings(metadata);
|
const categorySettings = this.resolveCategorySettings(metadata);
|
||||||
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
|
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
|
||||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||||
@@ -235,7 +236,7 @@ export class PreviewServer {
|
|||||||
menuItems,
|
menuItems,
|
||||||
picoStylesheetHref,
|
picoStylesheetHref,
|
||||||
htmlThemeAttribute: undefined,
|
htmlThemeAttribute: undefined,
|
||||||
}, categorySettings, listExcludedCategories, {
|
}, categorySettings, categoryMetadata, listExcludedCategories, {
|
||||||
useDraftContent,
|
useDraftContent,
|
||||||
draftPostId,
|
draftPostId,
|
||||||
});
|
});
|
||||||
@@ -264,6 +265,7 @@ export class PreviewServer {
|
|||||||
rewriteContext: HtmlRewriteContext,
|
rewriteContext: HtmlRewriteContext,
|
||||||
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
||||||
categorySettings: Record<string, CategoryRenderSettings>,
|
categorySettings: Record<string, CategoryRenderSettings>,
|
||||||
|
categoryMetadata: Record<string, CategoryMetadata>,
|
||||||
listExcludedCategories: string[],
|
listExcludedCategories: string[],
|
||||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
@@ -380,11 +382,12 @@ export class PreviewServer {
|
|||||||
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
|
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
|
||||||
if (categoryMatch) {
|
if (categoryMatch) {
|
||||||
const category = categoryMatch[1];
|
const category = categoryMatch[1];
|
||||||
|
const categoryDisplayTitle = categoryMetadata[category]?.title?.trim() || category;
|
||||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions);
|
const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions);
|
||||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||||
archiveGrouping: true,
|
archiveGrouping: true,
|
||||||
routeKind: 'non-date',
|
routeKind: 'non-date',
|
||||||
archiveContext: { kind: 'category', name: category },
|
archiveContext: { kind: 'category', name: categoryDisplayTitle },
|
||||||
basePathname: pagedPathname,
|
basePathname: pagedPathname,
|
||||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||||
categorySettings,
|
categorySettings,
|
||||||
@@ -840,32 +843,51 @@ export class PreviewServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCategorySettings(metadata: ProjectMetadata | null): Record<string, CategoryRenderSettings> {
|
private resolveCategoryMetadata(metadata: ProjectMetadata | null): Record<string, CategoryMetadata> {
|
||||||
const defaults: Record<string, CategoryRenderSettings> = {
|
const defaults: Record<string, CategoryMetadata> = {
|
||||||
article: { renderInLists: true, showTitle: true },
|
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||||
picture: { renderInLists: true, showTitle: true },
|
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||||
aside: { renderInLists: true, showTitle: false },
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||||
page: { renderInLists: false, showTitle: true },
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawSettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings;
|
const rawMetadata = (metadata as { categoryMetadata?: unknown } | null)?.categoryMetadata;
|
||||||
if (!rawSettings || typeof rawSettings !== 'object') {
|
const rawLegacySettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings;
|
||||||
|
const source = rawMetadata && typeof rawMetadata === 'object' ? rawMetadata : rawLegacySettings;
|
||||||
|
|
||||||
|
if (!source || typeof source !== 'object') {
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedSettings: Record<string, CategoryRenderSettings> = { ...defaults };
|
const merged: Record<string, CategoryMetadata> = { ...defaults };
|
||||||
for (const [category, rawValue] of Object.entries(rawSettings as Record<string, unknown>)) {
|
for (const [category, rawValue] of Object.entries(source as Record<string, unknown>)) {
|
||||||
if (!rawValue || typeof rawValue !== 'object') {
|
if (!rawValue || typeof rawValue !== 'object') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typedRawValue = rawValue as Record<string, unknown>;
|
const typedRawValue = rawValue as Record<string, unknown>;
|
||||||
mergedSettings[category] = {
|
const title = typeof typedRawValue.title === 'string' && typedRawValue.title.trim().length > 0
|
||||||
|
? typedRawValue.title.trim()
|
||||||
|
: category;
|
||||||
|
merged[category] = {
|
||||||
renderInLists: typedRawValue.renderInLists !== false,
|
renderInLists: typedRawValue.renderInLists !== false,
|
||||||
showTitle: typedRawValue.showTitle !== false,
|
showTitle: typedRawValue.showTitle !== false,
|
||||||
|
title,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCategorySettings(metadata: ProjectMetadata | null): Record<string, CategoryRenderSettings> {
|
||||||
|
const categoryMetadata = this.resolveCategoryMetadata(metadata);
|
||||||
|
const mergedSettings: Record<string, CategoryRenderSettings> = {};
|
||||||
|
for (const [category, value] of Object.entries(categoryMetadata)) {
|
||||||
|
mergedSettings[category] = {
|
||||||
|
renderInLists: value.renderInLists,
|
||||||
|
showTitle: value.showTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
return mergedSettings;
|
return mergedSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void {
|
|||||||
language,
|
language,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
picoTheme: metadata?.picoTheme,
|
picoTheme: metadata?.picoTheme,
|
||||||
|
categoryMetadata: (metadata as any)?.categoryMetadata,
|
||||||
categorySettings: (metadata as any)?.categorySettings,
|
categorySettings: (metadata as any)?.categorySettings,
|
||||||
menu,
|
menu,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -402,11 +402,15 @@ export function registerIpcHandlers(): void {
|
|||||||
const projectEngine = getProjectEngine();
|
const projectEngine = getProjectEngine();
|
||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
const engine = getPostEngine();
|
const engine = getPostEngine();
|
||||||
|
const metaEngine = getMetaEngine();
|
||||||
if (project) {
|
if (project) {
|
||||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
engine.setProjectContext(project.id, dataDir);
|
engine.setProjectContext(project.id, dataDir);
|
||||||
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
}
|
}
|
||||||
return engine.rebuildDatabaseFromFiles();
|
await engine.rebuildDatabaseFromFiles();
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('posts:search', async (_, query: string) => {
|
safeHandle('posts:search', async (_, query: string) => {
|
||||||
@@ -818,6 +822,24 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// ============ Meta Handlers ============
|
// ============ Meta Handlers ============
|
||||||
|
|
||||||
|
const ensureMetaContext = async (engine: ReturnType<typeof getMetaEngine>) => {
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const activeProject = await projectEngine.getActiveProject();
|
||||||
|
if (!activeProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = projectEngine.getDataDir(activeProject.id, activeProject.dataPath);
|
||||||
|
engine.setProjectContext(activeProject.id, dataDir);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureMetaReady = async (engine: ReturnType<typeof getMetaEngine>) => {
|
||||||
|
await ensureMetaContext(engine);
|
||||||
|
if (!engine.isInitialized()) {
|
||||||
|
await engine.syncOnStartup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
safeHandle('menu:get', async () => {
|
safeHandle('menu:get', async () => {
|
||||||
const projectEngine = getProjectEngine();
|
const projectEngine = getProjectEngine();
|
||||||
const menuEngine = getMenuEngine();
|
const menuEngine = getMenuEngine();
|
||||||
@@ -848,40 +870,47 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
safeHandle('meta:getTags', async () => {
|
safeHandle('meta:getTags', async () => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaReady(engine);
|
||||||
return engine.getTags();
|
return engine.getTags();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:getCategories', async () => {
|
safeHandle('meta:getCategories', async () => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaReady(engine);
|
||||||
return engine.getCategories();
|
return engine.getCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:addTag', async (_, tag: string) => {
|
safeHandle('meta:addTag', async (_, tag: string) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaReady(engine);
|
||||||
await engine.addTag(tag);
|
await engine.addTag(tag);
|
||||||
return engine.getTags();
|
return engine.getTags();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:removeTag', async (_, tag: string) => {
|
safeHandle('meta:removeTag', async (_, tag: string) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaReady(engine);
|
||||||
await engine.removeTag(tag);
|
await engine.removeTag(tag);
|
||||||
return engine.getTags();
|
return engine.getTags();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:addCategory', async (_, category: string) => {
|
safeHandle('meta:addCategory', async (_, category: string) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaReady(engine);
|
||||||
await engine.addCategory(category);
|
await engine.addCategory(category);
|
||||||
return engine.getCategories();
|
return engine.getCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:removeCategory', async (_, category: string) => {
|
safeHandle('meta:removeCategory', async (_, category: string) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaReady(engine);
|
||||||
await engine.removeCategory(category);
|
await engine.removeCategory(category);
|
||||||
return engine.getCategories();
|
return engine.getCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:syncOnStartup', async () => {
|
safeHandle('meta:syncOnStartup', async () => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaContext(engine);
|
||||||
await engine.syncOnStartup();
|
await engine.syncOnStartup();
|
||||||
return {
|
return {
|
||||||
tags: await engine.getTags(),
|
tags: await engine.getTags(),
|
||||||
@@ -892,20 +921,20 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
safeHandle('meta:getProjectMetadata', async () => {
|
safeHandle('meta:getProjectMetadata', async () => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
if (!engine.isInitialized()) {
|
await ensureMetaReady(engine);
|
||||||
await engine.syncOnStartup();
|
|
||||||
}
|
|
||||||
return engine.getProjectMetadata();
|
return engine.getProjectMetadata();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => {
|
safeHandle('meta:setProjectMetadata', async (_, metadata: { name: string; description?: string }) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaContext(engine);
|
||||||
await engine.setProjectMetadata(metadata);
|
await engine.setProjectMetadata(metadata);
|
||||||
return engine.getProjectMetadata();
|
return engine.getProjectMetadata();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('../shared/picoThemes').PicoThemeName; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => {
|
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
|
await ensureMetaContext(engine);
|
||||||
await engine.updateProjectMetadata(updates);
|
await engine.updateProjectMetadata(updates);
|
||||||
return engine.getProjectMetadata();
|
return engine.getProjectMetadata();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
|
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
|
||||||
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
||||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./shared/picoThemes').PicoThemeName; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
|
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tag Management (advanced tag operations)
|
// Tag Management (advanced tag operations)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface ProjectMetadata {
|
|||||||
defaultAuthor?: string;
|
defaultAuthor?: string;
|
||||||
maxPostsPerPage?: number;
|
maxPostsPerPage?: number;
|
||||||
picoTheme?: import('./picoThemes').PicoThemeName;
|
picoTheme?: import('./picoThemes').PicoThemeName;
|
||||||
|
categoryMetadata?: Record<string, CategoryMetadata>;
|
||||||
categorySettings?: Record<string, CategoryRenderSettings>;
|
categorySettings?: Record<string, CategoryRenderSettings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,10 @@ export interface CategoryRenderSettings {
|
|||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -567,7 +572,7 @@ export interface ElectronAPI {
|
|||||||
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
||||||
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; categorySettings?: Record<string, CategoryRenderSettings> }) => Promise<ProjectMetadata | null>;
|
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings> }) => Promise<ProjectMetadata | null>;
|
||||||
};
|
};
|
||||||
tags: {
|
tags: {
|
||||||
getAll: () => Promise<TagData[]>;
|
getAll: () => Promise<TagData[]>;
|
||||||
|
|||||||
@@ -343,67 +343,80 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Categories management styles */
|
/* Categories management styles */
|
||||||
.categories-list {
|
.categories-table-wrapper {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-item {
|
.categories-table {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
border-collapse: collapse;
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background-color: var(--vscode-badge-background);
|
|
||||||
color: var(--vscode-badge-foreground);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-name {
|
.categories-table th,
|
||||||
|
.categories-table td {
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-table th {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-table td input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-table td input[type="text"]:focus {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-name-cell {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 140px;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-settings-controls {
|
.category-checkbox-cell {
|
||||||
display: flex;
|
text-align: center;
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-setting-toggle {
|
.category-actions-cell {
|
||||||
display: inline-flex;
|
text-align: center;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-setting-toggle input[type="checkbox"] {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-remove {
|
.category-remove {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 16px;
|
width: 24px;
|
||||||
height: 16px;
|
height: 24px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: 1px solid var(--vscode-panel-border);
|
||||||
color: var(--vscode-badge-foreground);
|
color: var(--vscode-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.6;
|
opacity: 0.8;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 4px;
|
||||||
transition: opacity 0.15s, background-color 0.15s;
|
transition: opacity 0.15s, background-color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-remove:hover {
|
.category-remove:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: var(--vscode-toolbar-hoverBackground);
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-add-form {
|
.category-add-form {
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ interface Credentials {
|
|||||||
sshKeyPath: string;
|
sshKeyPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryRenderSettings {
|
interface CategoryMetadata {
|
||||||
renderInLists: boolean;
|
renderInLists: boolean;
|
||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
|
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
|
||||||
@@ -65,11 +66,11 @@ const SearchIcon = () => (
|
|||||||
// Default post categories based on VISION.md
|
// Default post categories based on VISION.md
|
||||||
const DEFAULT_POST_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
const DEFAULT_POST_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
||||||
|
|
||||||
const DEFAULT_CATEGORY_SETTINGS: Record<string, CategoryRenderSettings> = {
|
const DEFAULT_CATEGORY_METADATA: Record<string, CategoryMetadata> = {
|
||||||
article: { renderInLists: true, showTitle: true },
|
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||||
picture: { renderInLists: true, showTitle: true },
|
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||||
aside: { renderInLists: true, showTitle: false },
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||||
page: { renderInLists: false, showTitle: true },
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Standard categories that cannot be deleted
|
// Standard categories that cannot be deleted
|
||||||
@@ -142,7 +143,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
|
|
||||||
// Post categories management
|
// Post categories management
|
||||||
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
|
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
|
||||||
const [categorySettings, setCategorySettings] = useState<Record<string, CategoryRenderSettings>>(DEFAULT_CATEGORY_SETTINGS);
|
const [categoryMetadata, setCategoryMetadata] = useState<Record<string, CategoryMetadata>>(DEFAULT_CATEGORY_METADATA);
|
||||||
const [newCategoryInput, setNewCategoryInput] = useState('');
|
const [newCategoryInput, setNewCategoryInput] = useState('');
|
||||||
|
|
||||||
// AI Assistant settings
|
// AI Assistant settings
|
||||||
@@ -194,14 +195,21 @@ export const SettingsView: React.FC = () => {
|
|||||||
: 50;
|
: 50;
|
||||||
setProjectMaxPostsPerPage(maxPostsPerPage);
|
setProjectMaxPostsPerPage(maxPostsPerPage);
|
||||||
|
|
||||||
const incomingCategorySettings = (metadata as any)?.categorySettings as Record<string, CategoryRenderSettings> | undefined;
|
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
|
||||||
setCategorySettings((current) => {
|
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
|
||||||
const merged = { ...DEFAULT_CATEGORY_SETTINGS, ...current };
|
setCategoryMetadata((current) => {
|
||||||
if (incomingCategorySettings && typeof incomingCategorySettings === 'object') {
|
const merged = { ...DEFAULT_CATEGORY_METADATA, ...current };
|
||||||
for (const [category, settings] of Object.entries(incomingCategorySettings)) {
|
const source = incomingCategoryMetadata && typeof incomingCategoryMetadata === 'object'
|
||||||
|
? incomingCategoryMetadata
|
||||||
|
: incomingLegacyCategorySettings;
|
||||||
|
if (source && typeof source === 'object') {
|
||||||
|
for (const [category, settings] of Object.entries(source)) {
|
||||||
merged[category] = {
|
merged[category] = {
|
||||||
renderInLists: settings?.renderInLists !== false,
|
renderInLists: settings?.renderInLists !== false,
|
||||||
showTitle: settings?.showTitle !== false,
|
showTitle: settings?.showTitle !== false,
|
||||||
|
title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0
|
||||||
|
? (settings as any).title.trim()
|
||||||
|
: category,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,11 +232,11 @@ export const SettingsView: React.FC = () => {
|
|||||||
const categories = await window.electronAPI?.meta.getCategories();
|
const categories = await window.electronAPI?.meta.getCategories();
|
||||||
if (categories && categories.length > 0) {
|
if (categories && categories.length > 0) {
|
||||||
setPostCategories(categories);
|
setPostCategories(categories);
|
||||||
setCategorySettings((current) => {
|
setCategoryMetadata((current) => {
|
||||||
const next = { ...DEFAULT_CATEGORY_SETTINGS, ...current };
|
const next = { ...DEFAULT_CATEGORY_METADATA, ...current };
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
if (!next[category]) {
|
if (!next[category]) {
|
||||||
next[category] = { renderInLists: true, showTitle: true };
|
next[category] = { renderInLists: true, showTitle: true, title: category };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -236,7 +244,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
// Initialize with defaults if no categories exist
|
// Initialize with defaults if no categories exist
|
||||||
setPostCategories(DEFAULT_POST_CATEGORIES);
|
setPostCategories(DEFAULT_POST_CATEGORIES);
|
||||||
setCategorySettings(DEFAULT_CATEGORY_SETTINGS);
|
setCategoryMetadata(DEFAULT_CATEGORY_METADATA);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load AI settings
|
// Load AI settings
|
||||||
@@ -318,7 +326,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage),
|
mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage),
|
||||||
defaultAuthor: projectDefaultAuthor.trim() || undefined,
|
defaultAuthor: projectDefaultAuthor.trim() || undefined,
|
||||||
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
|
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
|
||||||
categorySettings,
|
categoryMetadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
showToast.success(t('settings.toast.projectSaved'));
|
showToast.success(t('settings.toast.projectSaved'));
|
||||||
@@ -573,12 +581,12 @@ export const SettingsView: React.FC = () => {
|
|||||||
if (updatedCategories) {
|
if (updatedCategories) {
|
||||||
setPostCategories(updatedCategories);
|
setPostCategories(updatedCategories);
|
||||||
}
|
}
|
||||||
const nextSettings = {
|
const nextCategoryMetadata = {
|
||||||
...categorySettings,
|
...categoryMetadata,
|
||||||
[trimmed]: categorySettings[trimmed] || { renderInLists: true, showTitle: true },
|
[trimmed]: categoryMetadata[trimmed] || { renderInLists: true, showTitle: true, title: trimmed },
|
||||||
};
|
};
|
||||||
setCategorySettings(nextSettings);
|
setCategoryMetadata(nextCategoryMetadata);
|
||||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||||
setNewCategoryInput('');
|
setNewCategoryInput('');
|
||||||
showToast.success(t('settings.toast.categoryAdded', { category: trimmed }));
|
showToast.success(t('settings.toast.categoryAdded', { category: trimmed }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -604,10 +612,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
if (updatedCategories) {
|
if (updatedCategories) {
|
||||||
setPostCategories(updatedCategories);
|
setPostCategories(updatedCategories);
|
||||||
}
|
}
|
||||||
const nextSettings = { ...categorySettings };
|
const nextCategoryMetadata = { ...categoryMetadata };
|
||||||
delete nextSettings[categoryToRemove];
|
delete nextCategoryMetadata[categoryToRemove];
|
||||||
setCategorySettings(nextSettings);
|
setCategoryMetadata(nextCategoryMetadata);
|
||||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||||
showToast.success(t('settings.toast.categoryRemoved', { category: categoryToRemove }));
|
showToast.success(t('settings.toast.categoryRemoved', { category: categoryToRemove }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove category:', error);
|
console.error('Failed to remove category:', error);
|
||||||
@@ -631,9 +639,9 @@ export const SettingsView: React.FC = () => {
|
|||||||
// Refresh the list
|
// Refresh the list
|
||||||
const updatedCategories = await window.electronAPI?.meta.getCategories();
|
const updatedCategories = await window.electronAPI?.meta.getCategories();
|
||||||
setPostCategories(updatedCategories || DEFAULT_POST_CATEGORIES);
|
setPostCategories(updatedCategories || DEFAULT_POST_CATEGORIES);
|
||||||
const defaults = { ...DEFAULT_CATEGORY_SETTINGS };
|
const defaults = { ...DEFAULT_CATEGORY_METADATA };
|
||||||
setCategorySettings(defaults);
|
setCategoryMetadata(defaults);
|
||||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults });
|
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: defaults });
|
||||||
showToast.success(t('settings.toast.categoriesReset'));
|
showToast.success(t('settings.toast.categoriesReset'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reset categories:', error);
|
console.error('Failed to reset categories:', error);
|
||||||
@@ -643,21 +651,51 @@ export const SettingsView: React.FC = () => {
|
|||||||
|
|
||||||
const handleCategorySettingToggle = async (
|
const handleCategorySettingToggle = async (
|
||||||
category: string,
|
category: string,
|
||||||
field: keyof CategoryRenderSettings,
|
field: keyof Pick<CategoryMetadata, 'renderInLists' | 'showTitle'>,
|
||||||
value: boolean,
|
value: boolean,
|
||||||
) => {
|
) => {
|
||||||
const nextSettings: Record<string, CategoryRenderSettings> = {
|
const nextCategoryMetadata: Record<string, CategoryMetadata> = {
|
||||||
...categorySettings,
|
...categoryMetadata,
|
||||||
[category]: {
|
[category]: {
|
||||||
...(categorySettings[category] || { renderInLists: true, showTitle: true }),
|
...(categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category }),
|
||||||
[field]: value,
|
[field]: value,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
setCategorySettings(nextSettings);
|
setCategoryMetadata(nextCategoryMetadata);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update category settings:', error);
|
||||||
|
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryTitleChange = (category: string, value: string) => {
|
||||||
|
setCategoryMetadata((current) => ({
|
||||||
|
...current,
|
||||||
|
[category]: {
|
||||||
|
...(current[category] || { renderInLists: true, showTitle: true, title: category }),
|
||||||
|
title: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistCategoryTitle = async (category: string) => {
|
||||||
|
const current = categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category };
|
||||||
|
const nextCategoryMetadata = {
|
||||||
|
...categoryMetadata,
|
||||||
|
[category]: {
|
||||||
|
...current,
|
||||||
|
title: current.title.trim().length > 0 ? current.title.trim() : category,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setCategoryMetadata(nextCategoryMetadata);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update category settings:', error);
|
console.error('Failed to update category settings:', error);
|
||||||
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
||||||
@@ -671,47 +709,67 @@ export const SettingsView: React.FC = () => {
|
|||||||
description={t('settings.content.description')}
|
description={t('settings.content.description')}
|
||||||
hidden={!sectionHasMatches(contentKeywords)}
|
hidden={!sectionHasMatches(contentKeywords)}
|
||||||
>
|
>
|
||||||
<div className="categories-list">
|
<div className="categories-table-wrapper">
|
||||||
{postCategories.map((cat) => {
|
<table className="categories-table">
|
||||||
const isProtected = PROTECTED_CATEGORIES.includes(cat);
|
<thead>
|
||||||
const setting = categorySettings[cat] || { renderInLists: true, showTitle: true };
|
<tr>
|
||||||
return (
|
<th>{t('settings.content.categoryColumn')}</th>
|
||||||
<div key={cat} className="category-item">
|
<th>{t('settings.content.titleColumn')}</th>
|
||||||
<span className="category-name">{cat}{isProtected && t('settings.content.standardSuffix')}</span>
|
<th>{t('settings.content.renderInLists')}</th>
|
||||||
<div className="category-settings-controls">
|
<th>{t('settings.content.showTitles')}</th>
|
||||||
<label className="category-setting-toggle" htmlFor={`category-${cat}-render-in-lists`}>
|
<th>{t('settings.content.actionsColumn')}</th>
|
||||||
<input
|
</tr>
|
||||||
id={`category-${cat}-render-in-lists`}
|
</thead>
|
||||||
aria-label={t('settings.content.renderInListsAria', { category: cat })}
|
<tbody>
|
||||||
type="checkbox"
|
{postCategories.map((cat) => {
|
||||||
checked={setting.renderInLists}
|
const isProtected = PROTECTED_CATEGORIES.includes(cat);
|
||||||
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
|
const metadata = categoryMetadata[cat] || { renderInLists: true, showTitle: true, title: cat };
|
||||||
/>
|
return (
|
||||||
<span>{t('settings.content.renderInLists')}</span>
|
<tr key={cat}>
|
||||||
</label>
|
<td className="category-name-cell">{cat}{isProtected && t('settings.content.standardSuffix')}</td>
|
||||||
<label className="category-setting-toggle" htmlFor={`category-${cat}-show-title`}>
|
<td>
|
||||||
<input
|
<input
|
||||||
id={`category-${cat}-show-title`}
|
type="text"
|
||||||
aria-label={t('settings.content.showTitlesAria', { category: cat })}
|
value={metadata.title}
|
||||||
type="checkbox"
|
onChange={(event) => handleCategoryTitleChange(cat, event.target.value)}
|
||||||
checked={setting.showTitle}
|
onBlur={() => void persistCategoryTitle(cat)}
|
||||||
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
aria-label={t('settings.content.categoryTitleAria', { category: cat })}
|
||||||
/>
|
/>
|
||||||
<span>{t('settings.content.showTitles')}</span>
|
</td>
|
||||||
</label>
|
<td className="category-checkbox-cell">
|
||||||
</div>
|
<input
|
||||||
{!isProtected && (
|
id={`category-${cat}-render-in-lists`}
|
||||||
<button
|
aria-label={t('settings.content.renderInListsAria', { category: cat })}
|
||||||
className="category-remove"
|
type="checkbox"
|
||||||
onClick={() => handleRemoveCategory(cat)}
|
checked={metadata.renderInLists}
|
||||||
title={t('settings.content.removeCategoryTitle', { category: cat })}
|
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
|
||||||
>
|
/>
|
||||||
✕
|
</td>
|
||||||
</button>
|
<td className="category-checkbox-cell">
|
||||||
)}
|
<input
|
||||||
</div>
|
id={`category-${cat}-show-title`}
|
||||||
);
|
aria-label={t('settings.content.showTitlesAria', { category: cat })}
|
||||||
})}
|
type="checkbox"
|
||||||
|
checked={metadata.showTitle}
|
||||||
|
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="category-actions-cell">
|
||||||
|
{!isProtected && (
|
||||||
|
<button
|
||||||
|
className="category-remove"
|
||||||
|
onClick={() => handleRemoveCategory(cat)}
|
||||||
|
title={t('settings.content.removeCategoryTitle', { category: cat })}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="category-add-form">
|
<div className="category-add-form">
|
||||||
|
|||||||
@@ -630,8 +630,12 @@
|
|||||||
"settings.content.resetDefaults": "Auf Standard zurücksetzen",
|
"settings.content.resetDefaults": "Auf Standard zurücksetzen",
|
||||||
"settings.content.description": "Verwalte die verfügbaren Kategorien für Blogbeiträge. Jeder Beitrag kann genau eine Kategorie haben, die seine Darstellungsvorlage bestimmt.",
|
"settings.content.description": "Verwalte die verfügbaren Kategorien für Blogbeiträge. Jeder Beitrag kann genau eine Kategorie haben, die seine Darstellungsvorlage bestimmt.",
|
||||||
"settings.content.standardSuffix": " (Standard)",
|
"settings.content.standardSuffix": " (Standard)",
|
||||||
|
"settings.content.categoryColumn": "Kategorie",
|
||||||
|
"settings.content.titleColumn": "Titel",
|
||||||
|
"settings.content.actionsColumn": "Aktionen",
|
||||||
"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.removeCategoryTitle": "Kategorie \"{category}\" entfernen",
|
"settings.content.removeCategoryTitle": "Kategorie \"{category}\" entfernen",
|
||||||
"settings.ai.description": "Konfiguriere den KI-Chat-Assistenten, der dir bei der Verwaltung deiner Bloginhalte hilft.",
|
"settings.ai.description": "Konfiguriere den KI-Chat-Assistenten, der dir bei der Verwaltung deiner Bloginhalte hilft.",
|
||||||
"settings.ai.apiKeyLabel": "OpenCode-API-Schlüssel",
|
"settings.ai.apiKeyLabel": "OpenCode-API-Schlüssel",
|
||||||
|
|||||||
@@ -630,8 +630,12 @@
|
|||||||
"settings.content.resetDefaults": "Reset to Defaults",
|
"settings.content.resetDefaults": "Reset to Defaults",
|
||||||
"settings.content.description": "Manage the available categories for blog posts. Each post can have one category that determines its display template.",
|
"settings.content.description": "Manage the available categories for blog posts. Each post can have one category that determines its display template.",
|
||||||
"settings.content.standardSuffix": " (standard)",
|
"settings.content.standardSuffix": " (standard)",
|
||||||
|
"settings.content.categoryColumn": "Category",
|
||||||
|
"settings.content.titleColumn": "Title",
|
||||||
|
"settings.content.actionsColumn": "Actions",
|
||||||
"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.removeCategoryTitle": "Remove \"{category}\" category",
|
"settings.content.removeCategoryTitle": "Remove \"{category}\" category",
|
||||||
"settings.ai.description": "Configure the AI chat assistant that helps you manage your blog content.",
|
"settings.ai.description": "Configure the AI chat assistant that helps you manage your blog content.",
|
||||||
"settings.ai.apiKeyLabel": "OpenCode API Key",
|
"settings.ai.apiKeyLabel": "OpenCode API Key",
|
||||||
|
|||||||
@@ -630,8 +630,12 @@
|
|||||||
"settings.content.resetDefaults": "Restablecer valores predeterminados",
|
"settings.content.resetDefaults": "Restablecer valores predeterminados",
|
||||||
"settings.content.description": "Gestiona las categorías disponibles para las entradas del blog. Cada entrada puede tener una sola categoría que determina su plantilla de visualización.",
|
"settings.content.description": "Gestiona las categorías disponibles para las entradas del blog. Cada entrada puede tener una sola categoría que determina su plantilla de visualización.",
|
||||||
"settings.content.standardSuffix": " (estándar)",
|
"settings.content.standardSuffix": " (estándar)",
|
||||||
|
"settings.content.categoryColumn": "Categoría",
|
||||||
|
"settings.content.titleColumn": "Título",
|
||||||
|
"settings.content.actionsColumn": "Acciones",
|
||||||
"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.removeCategoryTitle": "Eliminar categoría \"{category}\"",
|
"settings.content.removeCategoryTitle": "Eliminar categoría \"{category}\"",
|
||||||
"settings.ai.description": "Configura el asistente de chat con IA que te ayuda a gestionar el contenido de tu blog.",
|
"settings.ai.description": "Configura el asistente de chat con IA que te ayuda a gestionar el contenido de tu blog.",
|
||||||
"settings.ai.apiKeyLabel": "Clave API",
|
"settings.ai.apiKeyLabel": "Clave API",
|
||||||
|
|||||||
@@ -630,8 +630,12 @@
|
|||||||
"settings.content.resetDefaults": "Réinitialiser par défaut",
|
"settings.content.resetDefaults": "Réinitialiser par défaut",
|
||||||
"settings.content.description": "Gérez les catégories disponibles pour les articles du blog. Chaque article peut avoir une seule catégorie qui détermine son modèle d’affichage.",
|
"settings.content.description": "Gérez les catégories disponibles pour les articles du blog. Chaque article peut avoir une seule catégorie qui détermine son modèle d’affichage.",
|
||||||
"settings.content.standardSuffix": " (standard)",
|
"settings.content.standardSuffix": " (standard)",
|
||||||
|
"settings.content.categoryColumn": "Catégorie",
|
||||||
|
"settings.content.titleColumn": "Titre",
|
||||||
|
"settings.content.actionsColumn": "Actions",
|
||||||
"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.removeCategoryTitle": "Supprimer la catégorie \"{category}\"",
|
"settings.content.removeCategoryTitle": "Supprimer la catégorie \"{category}\"",
|
||||||
"settings.ai.description": "Configurez l’assistant de chat IA qui vous aide à gérer le contenu de votre blog.",
|
"settings.ai.description": "Configurez l’assistant de chat IA qui vous aide à gérer le contenu de votre blog.",
|
||||||
"settings.ai.apiKeyLabel": "Clé API",
|
"settings.ai.apiKeyLabel": "Clé API",
|
||||||
|
|||||||
@@ -630,8 +630,12 @@
|
|||||||
"settings.content.resetDefaults": "Ripristina predefiniti",
|
"settings.content.resetDefaults": "Ripristina predefiniti",
|
||||||
"settings.content.description": "Gestisci le categorie disponibili per i post del blog. Ogni post può avere una sola categoria che ne determina il template di visualizzazione.",
|
"settings.content.description": "Gestisci le categorie disponibili per i post del blog. Ogni post può avere una sola categoria che ne determina il template di visualizzazione.",
|
||||||
"settings.content.standardSuffix": " (standard)",
|
"settings.content.standardSuffix": " (standard)",
|
||||||
|
"settings.content.categoryColumn": "Categoria",
|
||||||
|
"settings.content.titleColumn": "Titolo",
|
||||||
|
"settings.content.actionsColumn": "Azioni",
|
||||||
"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.removeCategoryTitle": "Rimuovi la categoria \"{category}\"",
|
"settings.content.removeCategoryTitle": "Rimuovi la categoria \"{category}\"",
|
||||||
"settings.ai.description": "Configura l’assistente chat IA che ti aiuta a gestire i contenuti del tuo blog.",
|
"settings.ai.description": "Configura l’assistente chat IA che ti aiuta a gestire i contenuti del tuo blog.",
|
||||||
"settings.ai.apiKeyLabel": "Chiave API",
|
"settings.ai.apiKeyLabel": "Chiave API",
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
language: string;
|
language: string;
|
||||||
pageTitle: string;
|
pageTitle: string;
|
||||||
categorySettings: Record<string, { renderInLists: boolean; showTitle: boolean }>;
|
categorySettings: Record<string, { renderInLists: boolean; showTitle: boolean }>;
|
||||||
|
categoryMetadata: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>;
|
||||||
menu: MenuDocument;
|
menu: MenuDocument;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
@@ -200,6 +201,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
language: options?.language,
|
language: options?.language,
|
||||||
pageTitle: options?.pageTitle,
|
pageTitle: options?.pageTitle,
|
||||||
categorySettings: options?.categorySettings,
|
categorySettings: options?.categorySettings,
|
||||||
|
categoryMetadata: options?.categoryMetadata,
|
||||||
menu: options?.menu,
|
menu: options?.menu,
|
||||||
}, onProgress);
|
}, onProgress);
|
||||||
}
|
}
|
||||||
@@ -292,6 +294,35 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(tagHtml).toContain('class="blog-menu"');
|
expect(tagHtml).toContain('class="blog-menu"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders category menu links with category metadata title while keeping category URL', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({
|
||||||
|
id: '1',
|
||||||
|
slug: 'news-post',
|
||||||
|
title: 'News Post',
|
||||||
|
categories: ['news'],
|
||||||
|
createdAt: new Date('2025-03-15T10:00:00Z'),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await generate(posts, {
|
||||||
|
categoryMetadata: {
|
||||||
|
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
items: [
|
||||||
|
{ id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] },
|
||||||
|
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexHtml = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
|
||||||
|
expect(indexHtml).toContain('href="/category/news/"');
|
||||||
|
expect(indexHtml).toContain('>Newsroom</a>');
|
||||||
|
expect(indexHtml).not.toContain('>news</a>');
|
||||||
|
});
|
||||||
|
|
||||||
it('copies all required asset files to html/assets/ and html/images/', async () => {
|
it('copies all required asset files to html/assets/ and html/images/', async () => {
|
||||||
const result = await generate([]);
|
const result = await generate([]);
|
||||||
|
|
||||||
@@ -367,6 +398,25 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(newsHtml).toContain('data-template="post-list"');
|
expect(newsHtml).toContain('data-template="post-list"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses category title in rendered archive heading while keeping category name in URL path', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', slug: 'news-1', title: 'News 1', categories: ['news'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
await generate(posts, {
|
||||||
|
categoryMetadata: {
|
||||||
|
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newsPath = path.join(tempDir, 'html', 'category', 'news', 'index.html');
|
||||||
|
expect(await fileExists(newsPath)).toBe(true);
|
||||||
|
|
||||||
|
const newsHtml = await readFile(newsPath, 'utf-8');
|
||||||
|
expect(newsHtml).toContain('<h1 class="archive-heading">Newsroom</h1>');
|
||||||
|
expect(newsHtml).not.toContain('<h1 class="archive-heading">news</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
it('generates tag pages with correct archive context', async () => {
|
it('generates tag pages with correct archive context', async () => {
|
||||||
const posts = [
|
const posts = [
|
||||||
makePost({ id: '1', slug: 'tagged-1', title: 'Tagged 1', tags: ['javascript'] }),
|
makePost({ id: '1', slug: 'tagged-1', title: 'Tagged 1', tags: ['javascript'] }),
|
||||||
|
|||||||
@@ -593,23 +593,23 @@ describe('MetaEngine', () => {
|
|||||||
expect(parsed.picoTheme).toBe('slate');
|
expect(parsed.picoTheme).toBe('slate');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply default category settings for standard categories', async () => {
|
it('should apply default category metadata for standard categories', async () => {
|
||||||
await metaEngine.setProjectMetadata({
|
await metaEngine.setProjectMetadata({
|
||||||
name: 'Category Defaults Project',
|
name: 'Category Defaults Project',
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const metadata = await metaEngine.getProjectMetadata() as any;
|
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||||
expect(metadata.categorySettings).toEqual(
|
expect(metadata.categoryMetadata).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
article: { renderInLists: true, showTitle: true },
|
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||||
picture: { renderInLists: true, showTitle: true },
|
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||||
aside: { renderInLists: true, showTitle: false },
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||||
page: { renderInLists: false, showTitle: true },
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should persist category settings to project.json', async () => {
|
it('should persist legacy categorySettings input to category-meta.json', async () => {
|
||||||
await metaEngine.setProjectMetadata({
|
await metaEngine.setProjectMetadata({
|
||||||
name: 'Persisted Category Settings',
|
name: 'Persisted Category Settings',
|
||||||
categorySettings: {
|
categorySettings: {
|
||||||
@@ -621,20 +621,47 @@ describe('MetaEngine', () => {
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const metaDir = metaEngine.getMetaDir();
|
const metaDir = metaEngine.getMetaDir();
|
||||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
const categoryMetaPath = normalizePath(`${metaDir}/category-meta.json`);
|
||||||
const content = mockFiles.get(projectPath);
|
const content = mockFiles.get(categoryMetaPath);
|
||||||
const parsed = JSON.parse(content!);
|
const parsed = JSON.parse(content!);
|
||||||
|
|
||||||
expect(parsed.categorySettings).toEqual(
|
expect(parsed).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
custom: { renderInLists: false, showTitle: true },
|
custom: { renderInLists: false, showTitle: true, title: 'custom' },
|
||||||
aside: { renderInLists: true, showTitle: false },
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||||
page: { renderInLists: false, showTitle: true },
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge missing category settings with defaults when loading from filesystem', async () => {
|
it('should persist category metadata to category-meta.json and not to project.json', async () => {
|
||||||
|
await metaEngine.setProjectMetadata({
|
||||||
|
name: 'Persisted Category Metadata',
|
||||||
|
categoryMetadata: {
|
||||||
|
article: { renderInLists: true, showTitle: true, title: 'Articles' },
|
||||||
|
updates: { renderInLists: false, showTitle: true, title: 'Project Updates' },
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||||
|
const categoryMetaPath = normalizePath(`${metaDir}/category-meta.json`);
|
||||||
|
|
||||||
|
const projectContent = mockFiles.get(projectPath);
|
||||||
|
const categoryMetaContent = mockFiles.get(categoryMetaPath);
|
||||||
|
|
||||||
|
expect(projectContent).toBeDefined();
|
||||||
|
expect(categoryMetaContent).toBeDefined();
|
||||||
|
|
||||||
|
const parsedProject = JSON.parse(projectContent!);
|
||||||
|
const parsedCategoryMeta = JSON.parse(categoryMetaContent!);
|
||||||
|
|
||||||
|
expect(parsedProject.categorySettings).toBeUndefined();
|
||||||
|
expect(parsedProject.categoryMetadata).toBeUndefined();
|
||||||
|
expect(parsedCategoryMeta.updates).toEqual({ renderInLists: false, showTitle: true, title: 'Project Updates' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge missing category settings with defaults when loading legacy project metadata', async () => {
|
||||||
const metaDir = metaEngine.getMetaDir();
|
const metaDir = metaEngine.getMetaDir();
|
||||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||||
mockFiles.set(projectPath, JSON.stringify({
|
mockFiles.set(projectPath, JSON.stringify({
|
||||||
@@ -647,12 +674,12 @@ describe('MetaEngine', () => {
|
|||||||
await metaEngine.loadProjectMetadata();
|
await metaEngine.loadProjectMetadata();
|
||||||
|
|
||||||
const metadata = await metaEngine.getProjectMetadata() as any;
|
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||||
expect(metadata.categorySettings).toEqual(
|
expect(metadata.categoryMetadata).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
custom: { renderInLists: false, showTitle: false },
|
custom: { renderInLists: false, showTitle: false, title: 'custom' },
|
||||||
article: { renderInLists: true, showTitle: true },
|
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||||
aside: { renderInLists: true, showTitle: false },
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||||
page: { renderInLists: false, showTitle: true },
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -794,6 +821,46 @@ describe('MetaEngine', () => {
|
|||||||
expect(metadata?.description).toBe('Synced description');
|
expect(metadata?.description).toBe('Synced description');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should load category metadata from category-meta.json during syncOnStartup', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['news']));
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
|
||||||
|
name: 'Synced Project',
|
||||||
|
}));
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), JSON.stringify({
|
||||||
|
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||||
|
expect(metadata?.categoryMetadata?.news).toEqual({ renderInLists: true, showTitle: true, title: 'Newsroom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve customized category titles from category-meta.json across syncOnStartup', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['article', 'picture', 'aside', 'page', 'news']));
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
|
||||||
|
name: 'Synced Project',
|
||||||
|
}));
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), JSON.stringify({
|
||||||
|
article: { renderInLists: true, showTitle: true, title: 'Articles' },
|
||||||
|
picture: { renderInLists: true, showTitle: true, title: 'Photos' },
|
||||||
|
aside: { renderInLists: true, showTitle: false, title: 'Asides' },
|
||||||
|
page: { renderInLists: false, showTitle: true, title: 'Pages' },
|
||||||
|
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
const metadata = await metaEngine.getProjectMetadata() as any;
|
||||||
|
expect(metadata?.categoryMetadata?.article?.title).toBe('Articles');
|
||||||
|
expect(metadata?.categoryMetadata?.picture?.title).toBe('Photos');
|
||||||
|
expect(metadata?.categoryMetadata?.aside?.title).toBe('Asides');
|
||||||
|
expect(metadata?.categoryMetadata?.page?.title).toBe('Pages');
|
||||||
|
expect(metadata?.categoryMetadata?.news?.title).toBe('Newsroom');
|
||||||
|
});
|
||||||
|
|
||||||
it('should create project.json with data from database during syncOnStartup if file does not exist', async () => {
|
it('should create project.json with data from database during syncOnStartup if file does not exist', async () => {
|
||||||
const metaDir = metaEngine.getMetaDir();
|
const metaDir = metaEngine.getMetaDir();
|
||||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||||
|
|||||||
@@ -254,6 +254,41 @@ describe('PreviewServer', () => {
|
|||||||
expect(tagHtml).toContain('class="blog-menu"');
|
expect(tagHtml).toContain('class="blog-menu"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders category menu link labels from category metadata title', async () => {
|
||||||
|
const posts = [
|
||||||
|
makePost({ id: '1', slug: 'news-post', title: 'News Post', categories: ['news'], createdAt: new Date('2025-01-03T10:00:00.000Z') }),
|
||||||
|
];
|
||||||
|
|
||||||
|
server = new PreviewServer({
|
||||||
|
postEngine: makeEngine(posts),
|
||||||
|
settingsEngine: {
|
||||||
|
setProjectContext: vi.fn(),
|
||||||
|
async getProjectMetadata() {
|
||||||
|
return {
|
||||||
|
maxPostsPerPage: 50,
|
||||||
|
categoryMetadata: {
|
||||||
|
news: { renderInLists: true, showTitle: true, title: 'Newsroom' },
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
menuEngine: makeMenuEngine({
|
||||||
|
items: [
|
||||||
|
{ id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] },
|
||||||
|
{ id: 'news', title: 'news', kind: 'category-archive', categoryName: 'news', children: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start(0);
|
||||||
|
|
||||||
|
const rootHtml = await (await fetch(`${server.getBaseUrl()}/`)).text();
|
||||||
|
expect(rootHtml).toContain('href="/category/news/"');
|
||||||
|
expect(rootHtml).toContain('>Newsroom</a>');
|
||||||
|
expect(rootHtml).not.toContain('>news</a>');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses local CSS/JS assets and serves them from the preview server', async () => {
|
it('uses local CSS/JS assets and serves them from the preview server', async () => {
|
||||||
server = new PreviewServer({
|
server = new PreviewServer({
|
||||||
postEngine: makeEngine([makePost()]),
|
postEngine: makeEngine([makePost()]),
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ const mockMetaEngine = {
|
|||||||
removeCategory: vi.fn(),
|
removeCategory: vi.fn(),
|
||||||
getProjectMetadata: vi.fn(),
|
getProjectMetadata: vi.fn(),
|
||||||
setProjectMetadata: vi.fn(),
|
setProjectMetadata: vi.fn(),
|
||||||
|
updateProjectMetadata: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockTagEngine = {
|
const mockTagEngine = {
|
||||||
@@ -1222,6 +1223,23 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('meta:getCategories', () => {
|
||||||
|
it('should set context and sync before returning categories when uninitialized', async () => {
|
||||||
|
const activeProject = createMockProject({ id: 'project-cats', dataPath: '/cats/data' });
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/cats-data');
|
||||||
|
mockMetaEngine.isInitialized.mockReturnValue(false);
|
||||||
|
mockMetaEngine.syncOnStartup.mockResolvedValue(undefined);
|
||||||
|
mockMetaEngine.getCategories.mockResolvedValue(['article', 'news', 'travel']);
|
||||||
|
|
||||||
|
const result = await invokeHandler('meta:getCategories');
|
||||||
|
|
||||||
|
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-cats', '/resolved/cats-data');
|
||||||
|
expect(mockMetaEngine.syncOnStartup).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(['article', 'news', 'travel']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('meta:getProjectMetadata', () => {
|
describe('meta:getProjectMetadata', () => {
|
||||||
it('should return project metadata', async () => {
|
it('should return project metadata', async () => {
|
||||||
const metadata = { name: 'Test Blog', description: 'A test blog', mainLanguage: 'de' };
|
const metadata = { name: 'Test Blog', description: 'A test blog', mainLanguage: 'de' };
|
||||||
@@ -1234,6 +1252,20 @@ describe('IPC Handlers', () => {
|
|||||||
expect(result).toEqual(metadata);
|
expect(result).toEqual(metadata);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set meta engine context from active project before reading metadata', async () => {
|
||||||
|
const activeProject = createMockProject({ id: 'project-ctx', dataPath: '/ctx/data' });
|
||||||
|
const metadata = { name: 'Ctx Blog' };
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/ctx-data');
|
||||||
|
mockMetaEngine.isInitialized.mockReturnValue(true);
|
||||||
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(metadata);
|
||||||
|
|
||||||
|
const result = await invokeHandler('meta:getProjectMetadata');
|
||||||
|
|
||||||
|
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-ctx', '/resolved/ctx-data');
|
||||||
|
expect(result).toEqual(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
it('should sync metadata before reading when engine is not initialized', async () => {
|
it('should sync metadata before reading when engine is not initialized', async () => {
|
||||||
const metadata = { name: 'Test Blog', mainLanguage: 'de', defaultAuthor: 'Max' };
|
const metadata = { name: 'Test Blog', mainLanguage: 'de', defaultAuthor: 'Max' };
|
||||||
mockMetaEngine.isInitialized.mockReturnValue(false);
|
mockMetaEngine.isInitialized.mockReturnValue(false);
|
||||||
@@ -1260,6 +1292,24 @@ describe('IPC Handlers', () => {
|
|||||||
expect(result).toEqual(newMetadata);
|
expect(result).toEqual(newMetadata);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('meta:updateProjectMetadata', () => {
|
||||||
|
it('should set meta engine context from active project before updating metadata', async () => {
|
||||||
|
const activeProject = createMockProject({ id: 'project-update', dataPath: '/update/data' });
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/update-data');
|
||||||
|
mockMetaEngine.updateProjectMetadata.mockResolvedValue(undefined);
|
||||||
|
const updatedMetadata = { name: 'Updated' };
|
||||||
|
mockMetaEngine.getProjectMetadata.mockResolvedValue(updatedMetadata);
|
||||||
|
|
||||||
|
const updates = { defaultAuthor: 'Author Name' };
|
||||||
|
const result = await invokeHandler('meta:updateProjectMetadata', updates);
|
||||||
|
|
||||||
|
expect(mockMetaEngine.setProjectContext).toHaveBeenCalledWith('project-update', '/resolved/update-data');
|
||||||
|
expect(mockMetaEngine.updateProjectMetadata).toHaveBeenCalledWith(updates);
|
||||||
|
expect(result).toEqual(updatedMetadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ Menu Handlers ============
|
// ============ Menu Handlers ============
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
|
|
||||||
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
categorySettings: expect.objectContaining({
|
categoryMetadata: expect.objectContaining({
|
||||||
page: expect.objectContaining({ renderInLists: true, showTitle: true }),
|
page: expect.objectContaining({ renderInLists: true, showTitle: true, title: 'page' }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user