feat: categories with titles
This commit is contained in:
@@ -33,11 +33,16 @@ export interface BlogGenerationOptions {
|
||||
language?: string;
|
||||
pageTitle?: string;
|
||||
picoTheme?: PicoThemeName;
|
||||
categoryMetadata?: Record<string, CategoryMetadata>;
|
||||
categorySettings?: Record<string, CategoryRenderSettings>;
|
||||
menu?: MenuDocument;
|
||||
sections?: BlogGenerationSection[];
|
||||
}
|
||||
|
||||
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type BlogGenerationSection = 'core' | 'single' | 'category' | 'tag' | 'date';
|
||||
|
||||
export interface BlogGenerationResult {
|
||||
@@ -105,6 +110,7 @@ function clampMaxPostsPerPage(value: unknown): number {
|
||||
}
|
||||
|
||||
function resolveCategorySettings(
|
||||
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||
value: Record<string, CategoryRenderSettings> | undefined,
|
||||
): Record<string, CategoryRenderSettings> {
|
||||
const defaults: Record<string, CategoryRenderSettings> = {
|
||||
@@ -114,11 +120,20 @@ function resolveCategorySettings(
|
||||
page: { renderInLists: false, showTitle: true },
|
||||
};
|
||||
|
||||
if (!value) {
|
||||
return defaults;
|
||||
const merged = { ...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)) {
|
||||
merged[category] = {
|
||||
renderInLists: settings?.renderInLists !== false,
|
||||
@@ -128,6 +143,15 @@ function resolveCategorySettings(
|
||||
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 {
|
||||
const year = createdAt.getFullYear();
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
@@ -332,7 +356,7 @@ export class BlogGenerationEngine {
|
||||
const includeTag = selectedSections.has('tag');
|
||||
const includeDate = selectedSections.has('date');
|
||||
|
||||
const categorySettings = resolveCategorySettings(options.categorySettings);
|
||||
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
||||
const listExcludedCategories = Object.entries(categorySettings)
|
||||
.filter(([, settings]) => settings.renderInLists === false)
|
||||
.map(([category]) => category);
|
||||
@@ -658,7 +682,7 @@ export class BlogGenerationEngine {
|
||||
const pageContext = {
|
||||
page_title: pageTitle,
|
||||
language,
|
||||
menu_items: buildTemplateMenuItems(options.menu),
|
||||
menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata),
|
||||
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
|
||||
};
|
||||
|
||||
@@ -680,7 +704,7 @@ export class BlogGenerationEngine {
|
||||
|
||||
if (includeCategory) {
|
||||
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) {
|
||||
@@ -723,7 +747,7 @@ export class BlogGenerationEngine {
|
||||
onProgress(0, 'Collecting sitemap URLs...');
|
||||
|
||||
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||
const categorySettings = resolveCategorySettings(options.categorySettings);
|
||||
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
||||
const listExcludedCategories = Object.entries(categorySettings)
|
||||
.filter(([, settings]) => settings.renderInLists === false)
|
||||
.map(([category]) => category);
|
||||
@@ -1117,7 +1141,7 @@ export class BlogGenerationEngine {
|
||||
renderedUrlCount += generationResult.pagesGenerated;
|
||||
}
|
||||
} else {
|
||||
const categorySettings = resolveCategorySettings(options.categorySettings);
|
||||
const categorySettings = resolveCategorySettings(options.categoryMetadata, options.categorySettings);
|
||||
const listExcludedCategories = Object.entries(categorySettings)
|
||||
.filter(([, settings]) => settings.renderInLists === false)
|
||||
.map(([category]) => category);
|
||||
@@ -1274,7 +1298,7 @@ export class BlogGenerationEngine {
|
||||
const pageContext = {
|
||||
page_title: pageTitle,
|
||||
language,
|
||||
menu_items: buildTemplateMenuItems(options.menu),
|
||||
menu_items: buildTemplateMenuItems(options.menu, options.categoryMetadata),
|
||||
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
|
||||
};
|
||||
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine);
|
||||
@@ -1367,6 +1391,7 @@ export class BlogGenerationEngine {
|
||||
pageContext,
|
||||
pageRenderer,
|
||||
categorySettings,
|
||||
options.categoryMetadata,
|
||||
onPageGenerated,
|
||||
);
|
||||
}
|
||||
@@ -1560,6 +1585,7 @@ export class BlogGenerationEngine {
|
||||
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string },
|
||||
pageRenderer: PageRenderer,
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
||||
onPageGenerated: (message: string) => void,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
@@ -1568,6 +1594,8 @@ export class BlogGenerationEngine {
|
||||
const categoryPosts = posts.filter((post) => (post.categories || []).includes(category));
|
||||
if (categoryPosts.length === 0) continue;
|
||||
|
||||
const categoryDisplayTitle = resolveCategoryDisplayTitle(category, categoryMetadata);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / maxPostsPerPage));
|
||||
const encodedCategory = encodeURIComponent(category);
|
||||
const basePathname = `/category/${encodedCategory}`;
|
||||
@@ -1580,7 +1608,7 @@ export class BlogGenerationEngine {
|
||||
const html = await pageRenderer.renderPostList(pagePosts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'non-date',
|
||||
archiveContext: { kind: 'category', name: category },
|
||||
archiveContext: { kind: 'category', name: categoryDisplayTitle },
|
||||
basePathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: categoryPosts.length },
|
||||
categorySettings,
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ProjectMetadata {
|
||||
defaultAuthor?: string; // Default author for new posts and media
|
||||
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,6 +33,10 @@ export interface CategoryRenderSettings {
|
||||
showTitle: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||
const MIN_MAX_POSTS_PER_PAGE = 1;
|
||||
const MAX_MAX_POSTS_PER_PAGE = 500;
|
||||
@@ -66,17 +71,25 @@ function sanitizePublicUrl(value: unknown): string | 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 {
|
||||
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
||||
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
||||
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
|
||||
const categorySettings = normalizeCategorySettings(metadata.categorySettings);
|
||||
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
|
||||
return {
|
||||
...metadata,
|
||||
publicUrl,
|
||||
maxPostsPerPage,
|
||||
picoTheme,
|
||||
categorySettings,
|
||||
categoryMetadata,
|
||||
categorySettings: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,37 +99,62 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||
export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
||||
|
||||
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 {
|
||||
article: { renderInLists: true, showTitle: true },
|
||||
picture: { renderInLists: true, showTitle: true },
|
||||
aside: { renderInLists: true, showTitle: false },
|
||||
page: { renderInLists: false, showTitle: true },
|
||||
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCategorySettings(value: unknown): Record<string, CategoryRenderSettings> {
|
||||
const defaults = getDefaultCategorySettings();
|
||||
function normalizeCategoryMetadata(value: unknown): Record<string, CategoryMetadata> {
|
||||
const defaults = getDefaultCategoryMetadata();
|
||||
if (!value || typeof value !== 'object') {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const normalized: Record<string, CategoryRenderSettings> = { ...defaults };
|
||||
for (const [rawCategory, rawSettings] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalized: Record<string, CategoryMetadata> = { ...defaults };
|
||||
for (const [rawCategory, rawSettings] of Object.entries(value as RawCategoryMetadataInput)) {
|
||||
const category = normalizeTaxonomyTerm(rawCategory);
|
||||
if (!category || !rawSettings || typeof rawSettings !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const settings = rawSettings as Record<string, unknown>;
|
||||
const settings = rawSettings as unknown as {
|
||||
renderInLists?: unknown;
|
||||
showTitle?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
normalized[category] = {
|
||||
renderInLists: settings.renderInLists !== false,
|
||||
showTitle: settings.showTitle !== false,
|
||||
title: sanitizeCategoryTitle(settings.title, category),
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -171,6 +209,10 @@ export class MetaEngine extends EventEmitter {
|
||||
return path.join(this.getMetaDir(), 'project.json');
|
||||
}
|
||||
|
||||
private getCategoryMetadataFilePath(): string {
|
||||
return path.join(this.getMetaDir(), 'category-meta.json');
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
this.dataDir = dataDir || null;
|
||||
@@ -211,7 +253,11 @@ export class MetaEngine extends EventEmitter {
|
||||
*/
|
||||
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
|
||||
this.projectMetadata = normalizeProjectMetadata({ ...metadata });
|
||||
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||
this.projectMetadata.categoryMetadata,
|
||||
);
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||
}
|
||||
|
||||
@@ -227,6 +273,13 @@ export class MetaEngine extends EventEmitter {
|
||||
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) {
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
name: normalizedUpdates.name || '',
|
||||
@@ -237,6 +290,7 @@ export class MetaEngine extends EventEmitter {
|
||||
defaultAuthor: normalizedUpdates.defaultAuthor,
|
||||
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
||||
picoTheme: normalizedUpdates.picoTheme,
|
||||
categoryMetadata: normalizedUpdates.categoryMetadata,
|
||||
});
|
||||
} else {
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
@@ -244,7 +298,11 @@ export class MetaEngine extends EventEmitter {
|
||||
...normalizedUpdates,
|
||||
});
|
||||
}
|
||||
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||
this.projectMetadata.categoryMetadata,
|
||||
);
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||
}
|
||||
|
||||
@@ -280,17 +338,19 @@ export class MetaEngine extends EventEmitter {
|
||||
this.categories.add(normalizedCategory);
|
||||
const currentMetadata = this.projectMetadata;
|
||||
if (currentMetadata) {
|
||||
const currentSettings = normalizeCategorySettings(currentMetadata.categorySettings);
|
||||
if (!currentSettings[normalizedCategory]) {
|
||||
currentSettings[normalizedCategory] = {
|
||||
const currentCategoryMetadata = normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings);
|
||||
if (!currentCategoryMetadata[normalizedCategory]) {
|
||||
currentCategoryMetadata[normalizedCategory] = {
|
||||
renderInLists: true,
|
||||
showTitle: true,
|
||||
title: normalizedCategory,
|
||||
};
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
...currentMetadata,
|
||||
categorySettings: currentSettings,
|
||||
categoryMetadata: currentCategoryMetadata,
|
||||
});
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
}
|
||||
}
|
||||
this.emit('categoriesChanged', await this.getCategories());
|
||||
@@ -305,14 +365,18 @@ export class MetaEngine extends EventEmitter {
|
||||
const normalizedCategory = normalizeTaxonomyTerm(category);
|
||||
if (this.categories.delete(normalizedCategory)) {
|
||||
const currentMetadata = this.projectMetadata;
|
||||
if (currentMetadata?.categorySettings?.[normalizedCategory]) {
|
||||
const nextSettings = { ...currentMetadata.categorySettings };
|
||||
delete nextSettings[normalizedCategory];
|
||||
const currentCategoryMetadata = currentMetadata
|
||||
? normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings)
|
||||
: null;
|
||||
if (currentMetadata && currentCategoryMetadata?.[normalizedCategory]) {
|
||||
const nextCategoryMetadata = { ...currentCategoryMetadata };
|
||||
delete nextCategoryMetadata[normalizedCategory];
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
...currentMetadata,
|
||||
categorySettings: nextSettings,
|
||||
categoryMetadata: nextCategoryMetadata,
|
||||
});
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
}
|
||||
this.emit('categoriesChanged', await this.getCategories());
|
||||
await this.saveCategories();
|
||||
@@ -341,7 +405,12 @@ export class MetaEngine extends EventEmitter {
|
||||
try {
|
||||
await this.ensureMetaDirExists();
|
||||
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);
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
} 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.
|
||||
*/
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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.
|
||||
*
|
||||
@@ -474,9 +599,11 @@ export class MetaEngine extends EventEmitter {
|
||||
|
||||
const categoriesFilePath = this.getCategoriesFilePath();
|
||||
const projectMetadataFilePath = this.getProjectMetadataFilePath();
|
||||
const categoryMetadataFilePath = this.getCategoryMetadataFilePath();
|
||||
|
||||
const categoriesFileExists = await this.fileExists(categoriesFilePath);
|
||||
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
|
||||
const categoryMetadataFileExists = await this.fileExists(categoryMetadataFilePath);
|
||||
|
||||
// Collect tags/categories from database (posts)
|
||||
const dbTags = await this.collectTagsFromPosts();
|
||||
@@ -542,29 +669,28 @@ export class MetaEngine extends EventEmitter {
|
||||
name: projectData.name,
|
||||
description: projectData.description || undefined,
|
||||
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
|
||||
categorySettings: getDefaultCategorySettings(),
|
||||
};
|
||||
await this.saveProjectMetadata();
|
||||
}
|
||||
|
||||
if (this.projectMetadata) {
|
||||
const mergedSettings = normalizeCategorySettings(this.projectMetadata.categorySettings);
|
||||
let metadataChanged = false;
|
||||
const legacyCategoryMetadata = normalizeCategoryMetadata(
|
||||
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) {
|
||||
if (!mergedSettings[category]) {
|
||||
mergedSettings[category] = { renderInLists: true, showTitle: true };
|
||||
metadataChanged = true;
|
||||
}
|
||||
}
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
...this.projectMetadata,
|
||||
categoryMetadata: mergedCategoryMetadata,
|
||||
});
|
||||
|
||||
if (metadataChanged) {
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
...this.projectMetadata,
|
||||
categorySettings: mergedSettings,
|
||||
});
|
||||
await this.saveProjectMetadata();
|
||||
}
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
@@ -294,23 +294,47 @@ function buildMenuItemHref(item: MenuItemData): string {
|
||||
return '#';
|
||||
}
|
||||
|
||||
function toTemplateMenuItem(item: MenuItemData): TemplateMenuItem {
|
||||
const children = (Array.isArray(item.children) ? item.children : []).map((child) => toTemplateMenuItem(child));
|
||||
function resolveMenuItemTitle(
|
||||
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 {
|
||||
title: item.title,
|
||||
title: resolveMenuItemTitle(item, categoryMetadata),
|
||||
href: buildMenuItemHref(item),
|
||||
has_children: children.length > 0,
|
||||
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;
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((item) => toTemplateMenuItem(item));
|
||||
return items.map((item) => toTemplateMenuItem(item, categoryMetadata));
|
||||
}
|
||||
|
||||
export function normalizeMacroName(name: string): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
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 { getMenuEngine, type MenuDocument } from './MenuEngine';
|
||||
import { getPostMediaEngine } from './PostMediaEngine';
|
||||
@@ -182,8 +182,9 @@ export class PreviewServer {
|
||||
}
|
||||
|
||||
const metadata = await this.settingsEngine.getProjectMetadata();
|
||||
const categoryMetadata = this.resolveCategoryMetadata(metadata);
|
||||
const menu = await this.menuEngine.getMenu().catch(() => ({ items: [] }));
|
||||
const menuItems = buildTemplateMenuItems(menu);
|
||||
const menuItems = buildTemplateMenuItems(menu, categoryMetadata);
|
||||
const categorySettings = this.resolveCategorySettings(metadata);
|
||||
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
|
||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||
@@ -235,7 +236,7 @@ export class PreviewServer {
|
||||
menuItems,
|
||||
picoStylesheetHref,
|
||||
htmlThemeAttribute: undefined,
|
||||
}, categorySettings, listExcludedCategories, {
|
||||
}, categorySettings, categoryMetadata, listExcludedCategories, {
|
||||
useDraftContent,
|
||||
draftPostId,
|
||||
});
|
||||
@@ -264,6 +265,7 @@ export class PreviewServer {
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
categoryMetadata: Record<string, CategoryMetadata>,
|
||||
listExcludedCategories: string[],
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||
): Promise<string | null> {
|
||||
@@ -380,11 +382,12 @@ export class PreviewServer {
|
||||
const categoryMatch = pagedPathname.match(/^\/category\/([^/]+)$/);
|
||||
if (categoryMatch) {
|
||||
const category = categoryMatch[1];
|
||||
const categoryDisplayTitle = categoryMetadata[category]?.title?.trim() || category;
|
||||
const result = await this.loadPublishedSnapshotsPage({ status: 'published', categories: [category], excludeCategories: listExcludedCategories }, pageOptions);
|
||||
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
routeKind: 'non-date',
|
||||
archiveContext: { kind: 'category', name: category },
|
||||
archiveContext: { kind: 'category', name: categoryDisplayTitle },
|
||||
basePathname: pagedPathname,
|
||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||
categorySettings,
|
||||
@@ -840,32 +843,51 @@ export class PreviewServer {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCategorySettings(metadata: ProjectMetadata | null): Record<string, CategoryRenderSettings> {
|
||||
const defaults: Record<string, CategoryRenderSettings> = {
|
||||
article: { renderInLists: true, showTitle: true },
|
||||
picture: { renderInLists: true, showTitle: true },
|
||||
aside: { renderInLists: true, showTitle: false },
|
||||
page: { renderInLists: false, showTitle: true },
|
||||
private resolveCategoryMetadata(metadata: ProjectMetadata | null): Record<string, CategoryMetadata> {
|
||||
const defaults: Record<string, CategoryMetadata> = {
|
||||
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||
};
|
||||
|
||||
const rawSettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings;
|
||||
if (!rawSettings || typeof rawSettings !== 'object') {
|
||||
const rawMetadata = (metadata as { categoryMetadata?: unknown } | null)?.categoryMetadata;
|
||||
const rawLegacySettings = (metadata as { categorySettings?: unknown } | null)?.categorySettings;
|
||||
const source = rawMetadata && typeof rawMetadata === 'object' ? rawMetadata : rawLegacySettings;
|
||||
|
||||
if (!source || typeof source !== 'object') {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const mergedSettings: Record<string, CategoryRenderSettings> = { ...defaults };
|
||||
for (const [category, rawValue] of Object.entries(rawSettings as Record<string, unknown>)) {
|
||||
const merged: Record<string, CategoryMetadata> = { ...defaults };
|
||||
for (const [category, rawValue] of Object.entries(source as Record<string, unknown>)) {
|
||||
if (!rawValue || typeof rawValue !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user